Selaa lähdekoodia

重构login页与portal页

zengy 6 päivää sitten
vanhempi
commit
09fd5aa109

+ 2 - 2
memory/index.md

@@ -41,7 +41,7 @@ npm run dev       # 默认 http://localhost:80
 - 报警清单 `alarm/common/list.vue` 顶部新增报警总数、监测异常、监测已恢复三张统计卡片,按当前全部筛选结果计数、不受分页影响;既有查询区和表格保持原样。复用 `/api/alarm/statistics`,后端分级统计新增 abnormal/recovered,需要同步更新管理后端,无数据库结构变更(2026-09-24)。
 
 - 业务菜单主要由后端 `/getRouters` 动态下发,前端常量路由不包含完整业务树。
-- 门户页调用 `userStore.logout()`,用户 Store 当前定义的方法名为 `logOut()`,需要后续确认是否修复。
+- PC 登录页和门户页已按 2026-10-15 高保真原型重构;门户统计为全网统一口径,公告来自 `sys_notice` 并支持详情弹窗。后端接口位于 `/portal/*`,无菜单权限注解但保持登录鉴权;移动端下载二维码待放到 `public/images/mobile-app-qr.png`。
 - 供水 19 个页面已按 5.3.2 文档命名补齐并接入后端接口,包含设施 CRUD、设备、监测、报警、统计和轻量 GIS;部署菜单仍依赖后端 `sys_menu` 与角色授权。
 - 供水 T3 统计(Issue #7)与 T4 运行监测(Issue #8)已人工验收通过并在 Gogs 关闭(2026-09-05)。
 - 报警提醒/清单/审核及配套 `utils`、`styles` 位于 `src/views/subSystem/alarm/common`;排水研判及 GIS 位于 `src/views/subSystem/drainage/alarm`。详情为 `GET /api/alarm/{id}` 并要求 `rootTypeId`。本次已有环境执行 `migration_alarm_common_components.sql` 精准更新七个公共页面菜单的 component,同步部署前后端并重新登录;path/query/权限保持不变,初装脚本已同步(2026-09-24)。
@@ -67,7 +67,7 @@ npm run dev       # 默认 http://localhost:80
 
 ## 会话记录
 
-- [2026-10-15:预警按钮授权、发布人员选择与排序、状态颜色统一及关联页设备大类传参修复](sessions/2026-10-15.md)
+- [2026-10-15:权限管理子系统、移动端工单权限、PC 登录页与门户高保真重构](sessions/2026-10-15.md)
 - [2026-10-14:故障通知筛选与两端核实命名、工单取消限制、本人登录日志、照片按组切换、性别单选、移动端接口分层及统一前缀](sessions/2026-10-14.md)
 - [2026-10-13:移动端工单管理、待办、详情与操作接入](sessions/2026-10-13.md)
 - [2026-10-12:工单派单增加核验人](sessions/2026-10-12.md)

+ 2 - 0
memory/long-term.md

@@ -40,6 +40,8 @@
 
 ## 用户已确认决策
 
+- PC Portal 通知公告滚动栏的时间使用“今天/昨天/前天 HH:mm”,其他日期使用 `yyyy-MM-dd HH:mm`;详情发布时间固定 `yyyy-MM-dd HH:mm:ss`(2026-10-15)。
+- PC 门户的设备、今日预警、今日报警指标按全网统一口径统计,所有登录用户看到相同结果,不应用部门或角色数据范围;公告取启用状态的 `sys_notice`,点击公告展示详情。门户接口统一放在管理后端 `com.zksy.web.controller.system.PortalController`,不要求具体菜单权限但仍要求登录。登录页移动端下载二维码固定放在前端 `public/images/mobile-app-qr.png`(2026-10-15)。
 - 移动端专用接口统一放到后端 `zksy-admin` 的 `com.zksy.web.controller.mobile`,与 PC 共用现有业务 Service,数据权限也留在 Service 校验;不复制移动端业务流程。七类 Controller 的 URL 统一为 `/api/mobile/...`:工单 `/ops/workOrder`、巡检 `/ops/inspectTask`、设备 `/equipment-base`、故障通知 `/alarm`、公告 `/notice`、本人登录日志 `/monitor/logininfor/list`、通讯录 `/contacts`(均接在统一前缀后)。移动端调用同步更新,不保留旧移动端专用双路由;此决定取代同日初版保留旧 URL 的约定。登录、资料、通用上传及工单处置等两端共用接口仍保留原入口;报警不再单设 `MobileAlarmService`(2026-10-14)。
 - 移动端“我的-基本资料”性别仅提供男(0)、女(1)两个 radio 单选项,不再提供保密或下拉选择。历史保密(2)或未设置的资料显示为未选中,保存前需明确选择男或女,避免静默修改性别(2026-10-14)。
 - 移动端“日常-故障通知”按当前用户部门自身或祖先 101/102/103 对应设备类型的直接 parent_id=1/2/3 过滤,不包含共享类型 4,也不扩展类型树;管理员同样按公司范围读取,客户端 rootTypeId 只能收窄。已转工单通过 work_order.device_id 关联设备,未转工单按报警设备编码关联;关联损坏不可回退放宽。列表与 PC 审核页一致只查未处理报警,右上角展示待核实/确认报警/误报警;顶部按全部/待核实/确认报警/误报警分 tab,报警等级下拉放在搜索输入框前,使用工单同款公共搜索栏。详情用“故障信息/处置全流程”左右页签,待核实且未处理并有 alarm:review:audit 权限时提供底部“核实”抽屉。移动端状态0统一显示“待核实”,操作与表单使用“核实”文案,PC 同步使用“待核实/核实”,接口枚举和权限保持原约定。核实结果必选、意见选填最多500字、图片最多5张(JPG/JPEG/PNG、每张10MB),复用 PC 附件票据和审核事务。历史沿用 alarm_log,不复制工单日志(2026-10-14)。

+ 37 - 0
memory/sessions/2026-10-15.md

@@ -14,3 +14,40 @@
 - 部门/公司设备范围仅用于待派单且当前用户拥有派单权限的工单。
 - 无设备待派单工单对所有有派单权限的用户可见、可派;有设备时按 101/102/103 对应 parent_id 1/2/3 并共同包含 4。
 - 派单后不再按部门过滤,工单管理按创建人、责任人、核验人控制,待办按责任人/核验人与状态进一步收窄,详情同步。
+
+## PC 登录页与系统门户重构
+
+- 登录页与门户页分别按 `prd/portal/login.html`、`prd/portal/portal.html` 重构;门户仍只展示当前用户有权访问的顶级子系统,并优先进入子系统“首页”,无首页时进入第一个可见菜单。
+- 新增 `/portal/statistics`、`/portal/notices`、`/portal/notices/{noticeId}`:要求已登录但不校验具体菜单权限。统计为全网统一口径,不应用用户、角色或部门数据范围;设备取 `equipment_base` 及每台设备最新有效 `equipment_status`,今日/昨日预警按非 DRAFT 的 `early_warning.publish_time`,报警按 `alarm_data.audit_status=1` 和 `create_time`。
+- 门户公告仅展示 `sys_notice.status='0'` 的最新 10 条,点击后查询正文并以弹窗展示;正文经 DOMPurify 清理后渲染。
+- 登录页移动端下载二维码固定为 `public/images/mobile-app-qr.png`,文件尚未配置时展示占位,不阻断页面或生产构建。
+- 验证:前端生产构建、3 组路由回归测试、后端 `zksy-admin` 及依赖模块编译通过;1440x900 浏览器截图检查通过。构建仅有项目既有 Sass 弃用警告。
+
+## Portal 通知公告时间展示
+
+- 左侧“公告”改为“通知公告”;滚动时间按本地日历日展示“今天 HH:mm”“昨天 HH:mm”“前天 HH:mm”,更早或未来日期使用 `yyyy-MM-dd HH:mm`。
+- 公告详情发布时间固定使用 `yyyy-MM-dd HH:mm:ss`,不使用相对日期;兼容接口普通日期及 ISO 日期输入。
+- 变更文件:`src/views/portal/index.vue`。验证:直接运行组件中的格式化函数,覆盖最近三天、历史/未来日期、午夜边界、ISO 输入、秒数补零和无效值,均通过;`git diff --check` 通过。无需后端变更。
+
+## Portal 退出登录入口强调
+
+- 右上角退出登录按钮改为浅蓝底、蓝色边框和蓝色半粗体文字,字号 13px、图标 15px;保持门户整体蓝色风格,增加悬停、按下及键盘焦点反馈。
+- 仅修改 `src/views/portal/index.vue` 样式,不改变退出交互;验证 Vue SFC 样式编译及差异检查。
+
+## Portal 退出确认框引用修复
+
+- 修复点击退出时报 `ElMessageBox is not defined`:`src/views/portal/index.vue` 显式从 `element-plus` 导入 `ElMessageBox`;项目自动导入仅覆盖 Vue、Vue Router 和 Pinia,不包含 Element Plus 服务。
+- 检查门户无其他同类服务引用;SFC 编译导入绑定检查、模拟确认/取消分支和 `git diff --check` 通过。真实退出接口未在本次检查调用。
+
+## Portal 子系统说明文案
+
+- `src/views/portal/index.vue` 子系统总览说明改为“地下管网核心业务系统统一入口”,不再显示县名与子系统数量;仅修改展示文案。
+
+## 开发登录默认填写
+
+- 按用户要求,登录表单仅在 Vite 开发环境默认填写指定的开发登录信息;生产构建不预填密码,并沿用记住账号逻辑。不将登录信息写入记忆库或新增密码 Cookie。
+- 变更:`src/views/login.vue`;验证开发/生产环境默认值与恢复账号逻辑,SFC 编译检查和差异检查。
+
+## 忘记密码提示
+
+- 登录页“忘记密码?”弹窗提示统一为“请联系平台管理员”;保留现有弹窗与确认按钮,并补齐 `ElMessageBox` 显式导入。

+ 1 - 0
package.json

@@ -23,6 +23,7 @@
     "@wangeditor/editor": "5.1.23",
     "@wangeditor/editor-for-vue": "5.1.12",
     "axios": "0.28.1",
+    "dompurify": "^3.3.0",
     "echarts": "^5.6.0",
     "element-plus": "2.7.6",
     "file-saver": "2.0.5",

BIN
public/images/mobile-app-qr.png


+ 13 - 0
src/api/portal.js

@@ -0,0 +1,13 @@
+import request from '@/utils/request'
+
+export function getPortalStatistics() {
+  return request({ url: '/portal/statistics', method: 'get' })
+}
+
+export function getPortalNotices() {
+  return request({ url: '/portal/notices', method: 'get' })
+}
+
+export function getPortalNotice(noticeId) {
+  return request({ url: `/portal/notices/${noticeId}`, method: 'get' })
+}

+ 217 - 186
src/views/login.vue

@@ -1,214 +1,245 @@
 <template>
-  <div class="login">
-    <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
-      <h3 class="title">沅陵太常片区城市地下管网管控系统</h3>
-      <el-form-item prop="username">
-        <el-input v-model="loginForm.username" type="text" size="large" auto-complete="off" placeholder="账号">
-          <template #prefix><svg-icon icon-class="user" class="el-input__icon input-icon" /></template>
-        </el-input>
-      </el-form-item>
-      <el-form-item prop="password">
-        <el-input v-model="loginForm.password" type="password" size="large" auto-complete="off" placeholder="密码"
-          @keyup.enter="handleLogin">
-          <template #prefix><svg-icon icon-class="password" class="el-input__icon input-icon" /></template>
-        </el-input>
-      </el-form-item>
-      <el-form-item prop="code" v-if="captchaEnabled">
-        <el-input v-model="loginForm.code" size="large" auto-complete="off" placeholder="验证码" style="width: 63%"
-          @keyup.enter="handleLogin">
-          <template #prefix><svg-icon icon-class="validCode" class="el-input__icon input-icon" /></template>
-        </el-input>
-        <div class="login-code">
-          <img :src="codeUrl" @click="getCode" class="login-code-img" />
+  <main class="login-page">
+    <div class="glow glow-one"></div>
+    <div class="glow glow-two"></div>
+
+    <div class="stage">
+      <section class="panel" aria-label="登录">
+        <div class="brand-side">
+          <div class="brand-top">
+            <div class="logo" 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>
+            <div class="brand-name">沅陵县城市地下管网管控平台</div>
+          </div>
+
+          <div class="hero">
+            <h1>汇聚地下管网运行态势<br><span>统览城市生命线安全</span></h1>
+            <p>供水 · 排水 · 燃气 · 窨井盖全域感知,智能预警联动处置,<br>协同应急指挥调度,守护城市运行安全。</p>
+            <div class="tags"><span>实时监测</span><span>智能预警</span><span>联动处置</span><span>应急指挥</span></div>
+          </div>
+
+          <div class="illustration" aria-hidden="true">
+            <svg viewBox="0 0 520 360" xmlns="http://www.w3.org/2000/svg">
+              <defs>
+                <linearGradient id="login-p1" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7db4ff" /><stop offset="1" stop-color="#2f7cf6" /></linearGradient>
+                <linearGradient id="login-p2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#a9d2ff" /><stop offset="1" stop-color="#5ba0f5" /></linearGradient>
+                <linearGradient id="login-p3" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" /><stop offset="1" stop-color="#d8e7ff" /></linearGradient>
+                <linearGradient id="login-p4" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4fa0ff" /><stop offset="1" stop-color="#1e66e8" /></linearGradient>
+                <linearGradient id="login-screen" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#eaf3ff" /><stop offset="1" stop-color="#c7ddff" /></linearGradient>
+              </defs>
+              <ellipse cx="260" cy="300" rx="210" ry="46" fill="#b9d4ff" opacity=".55" />
+              <ellipse cx="260" cy="288" rx="210" ry="46" fill="url(#login-p3)" />
+              <ellipse cx="260" cy="286" rx="168" ry="35" fill="none" stroke="#bcd2f5" stroke-width="2" />
+              <polygon points="150,250 260,300 370,250 260,205" fill="url(#login-p2)" opacity=".9" />
+              <polygon points="150,250 260,300 260,322 150,272" fill="#3d82e6" />
+              <polygon points="370,250 260,300 260,322 370,272" fill="#2b6bd0" />
+              <polygon points="185,232 260,266 335,232 260,200" fill="#e8f2ff" />
+              <g>
+                <polygon points="188,205 252,176 252,214 188,243" fill="url(#login-p4)" /><polygon points="252,176 316,205 316,243 252,214" fill="#6ea8fb" /><polygon points="188,205 252,176 316,205 252,234" fill="url(#login-p1)" />
+                <circle cx="220" cy="209" r="7" fill="#dceaff" /><circle cx="220" cy="209" r="3.5" fill="#2f7cf6" /><circle cx="286" cy="224" r="7" fill="#dceaff" /><circle cx="286" cy="224" r="3.5" fill="#2f7cf6" />
+              </g>
+              <g>
+                <polygon points="300,120 402,80 438,100 330,146" fill="#fff" /><polygon points="330,146 438,100 438,172 330,214" fill="#cfe1ff" /><polygon points="300,120 330,146 330,214 300,186" fill="#9ec2fb" /><polygon points="314,132 392,102 414,116 330,150" fill="url(#login-screen)" />
+                <polyline points="322,140 345,128 362,136 380,118 402,124" fill="none" stroke="#2f7cf6" stroke-width="2.5" stroke-linecap="round" /><rect x="338" y="160" width="66" height="6" rx="3" fill="#9ec2fb" /><rect x="338" y="174" width="44" height="6" rx="3" fill="#bcd4fb" />
+              </g>
+              <g>
+                <rect x="96" y="150" width="86" height="58" rx="8" fill="#fff" /><rect x="106" y="160" width="40" height="7" rx="3.5" fill="#8fb9f8" /><rect x="106" y="174" width="66" height="5" rx="2.5" fill="#d3e3fb" /><rect x="106" y="185" width="52" height="5" rx="2.5" fill="#d3e3fb" />
+                <circle cx="166" cy="196" r="9" fill="#e7f1ff" stroke="#8fb9f8" stroke-width="2" /><path d="M162 196l3 3 5-6" stroke="#2f7cf6" stroke-width="2" fill="none" stroke-linecap="round" />
+              </g>
+              <g><polygon points="218,178 240,168 240,214 218,224" fill="#2b6bd0" /><polygon points="240,168 262,178 262,224 240,214" fill="#5f9af7" /><ellipse cx="240" cy="173" rx="22" ry="10" fill="#8fbbff" /><ellipse cx="240" cy="173" rx="11" ry="5" fill="#2563d8" /></g>
+              <circle cx="128" cy="250" r="12" fill="#cfe2ff" stroke="#9ec2fb" stroke-width="2" /><circle cx="392" cy="250" r="9" fill="#dceaff" stroke="#9ec2fb" stroke-width="2" /><circle cx="150" cy="120" r="6" fill="#7db4ff" opacity=".7" /><circle cx="440" cy="220" r="5" fill="#16b4f2" opacity=".7" />
+              <path d="M150 208 Q200 120 310 130" fill="none" stroke="#7db4ff" stroke-width="2" stroke-dasharray="5 6" /><circle cx="150" cy="208" r="4" fill="#2f7cf6" /><circle cx="310" cy="130" r="4" fill="#16b4f2" />
+            </svg>
+          </div>
         </div>
-      </el-form-item>
-      <el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
-      <el-form-item style="width:100%;">
-        <el-button :loading="loading" size="large" type="primary" style="width:100%;" @click.prevent="handleLogin">
-          <span v-if="!loading">登 录</span>
-          <span v-else>登 录 中...</span>
-        </el-button>
-        <div style="float: right;" v-if="register">
-          <router-link class="link-type" :to="'/register'">立即注册</router-link>
+
+        <div class="form-side">
+          <div class="login-tabs"><span>账号登录</span></div>
+          <el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form" @submit.prevent="handleLogin">
+            <el-form-item prop="username" class="field">
+              <label for="login-username">账号</label>
+              <div class="input-box">
+                <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /></svg>
+                <input id="login-username" v-model.trim="loginForm.username" type="text" autocomplete="username" placeholder="请输入账号">
+              </div>
+            </el-form-item>
+            <el-form-item prop="password" class="field">
+              <label for="login-password">密码</label>
+              <div class="input-box">
+                <svg viewBox="0 0 24 24"><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1s3.1 1.39 3.1 3.1v2z" /></svg>
+                <input id="login-password" v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="请输入密码" @keyup.enter="handleLogin">
+              </div>
+            </el-form-item>
+            <el-form-item v-if="captchaEnabled" prop="code" class="field">
+              <label for="login-code">验证码</label>
+              <div class="input-box code-box">
+                <svg viewBox="0 0 24 24"><path d="M9 11H7v6h2v-6zm4 0h-2v6h2v-6zm4 0h-2v6h2v-6zm2.5-9H18V0h-2v2H8V0H6v2H4.5C3.67 2 3 2.67 3 3.5v15c0 .83.67 1.5 1.5 1.5h15c.83 0 1.5-.67 1.5-1.5v-15c0-.83-.67-1.5-1.5-1.5zM19 18H5V8h14v10z" /></svg>
+                <input id="login-code" v-model.trim="loginForm.code" type="text" autocomplete="off" placeholder="请输入计算结果" maxlength="6" @keyup.enter="handleLogin">
+                <button type="button" class="captcha" title="点击刷新验证码" @click="getCode"><img :src="codeUrl" alt="验证码"></button>
+              </div>
+            </el-form-item>
+            <div class="form-row">
+              <label class="remember"><input v-model="loginForm.rememberMe" type="checkbox">记住账号</label>
+              <button type="button" class="forgot" @click="showForgotPassword">忘记密码?</button>
+            </div>
+            <button type="submit" class="login-button" :disabled="loading">{{ loading ? '登 录 中...' : '立 即 登 录' }}</button>
+          </el-form>
+
+          <div class="mini-qr">
+            <div class="qr-box">
+              <img v-if="qrAvailable" :src="mobileAppQrUrl" alt="移动端 APP 下载二维码" @error="qrAvailable = false">
+              <span v-else>二维码<br>待配置</span>
+            </div>
+            <div class="qr-text"><b>移动端APP下载</b>扫码下载,随时掌握管网动态</div>
+          </div>
         </div>
-      </el-form-item>
-    </el-form>
-    <!--  底部  -->
-    <div class="el-login-footer">
-      <span>Copyright © 2026 沅陵县住房和城乡建设局 All Rights Reserved.</span>
+      </section>
     </div>
-  </div>
+    <footer>Copyright © 2026 沅陵县住房和城乡建设局 All Rights Reserved.</footer>
+  </main>
 </template>
 
 <script setup>
-import { getCodeImg } from "@/api/login";
-import Cookies from "js-cookie";
-import { encrypt, decrypt } from "@/utils/jsencrypt";
+import Cookies from 'js-cookie'
+import { ElMessageBox } from 'element-plus'
+import { getCodeImg } from '@/api/login'
 import useUserStore from '@/store/modules/user'
 
 const userStore = useUserStore()
-const route = useRoute();
-const router = useRouter();
-const { proxy } = getCurrentInstance();
-
+const router = useRouter()
+const loginRef = ref()
 const loginForm = ref({
-  username: "admin",
-  password: "Admin@123",
+  username: import.meta.env.DEV ? 'admin' : '',
+  password: import.meta.env.DEV ? 'Admin@123' : '',
   rememberMe: false,
-  code: "",
-  uuid: ""
-});
-
+  code: '',
+  uuid: ''
+})
 const loginRules = {
-  username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
-  password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],
-  code: [{ required: true, trigger: "change", message: "请输入验证码" }]
-};
-
-const codeUrl = ref("");
-const loading = ref(false);
-// 验证码开关
-const captchaEnabled = ref(true);
-// 注册开关
-const register = ref(false);
-const redirect = ref(undefined);
-
-watch(route, (newRoute) => {
-  redirect.value = newRoute.query && newRoute.query.redirect;
-}, { immediate: true });
-
-function handleLogin() {
-  proxy.$refs.loginRef.validate(valid => {
-    if (valid) {
-      loading.value = true;
-      // 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
-      if (loginForm.value.rememberMe) {
-        Cookies.set("username", loginForm.value.username, { expires: 30 });
-        Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 });
-        Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 });
-      } else {
-        // 否则移除
-        Cookies.remove("username");
-        Cookies.remove("password");
-        Cookies.remove("rememberMe");
-      }
-      // 调用action的登录方法
-      userStore.login(loginForm.value).then(() => {
-        const query = route.query;
-        const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
-          if (cur !== "redirect") {
-            acc[cur] = query[cur];
-          }
-          return acc;
-        }, {});
-        router.push("/portal");
-        // router.push({ path: redirect.value || "/", query: otherQueryParams });
-      }).catch(() => {
-        loading.value = false;
-        // 重新获取验证码
-        if (captchaEnabled.value) {
-          getCode();
-        }
-      });
-    }
-  });
+  username: [{ required: true, trigger: 'blur', message: '请输入您的账号' }],
+  password: [{ required: true, trigger: 'blur', message: '请输入您的密码' }],
+  code: [{ required: true, trigger: 'change', message: '请输入验证码' }]
 }
-
-function getCode() {
-  getCodeImg().then(res => {
-    captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled;
-    if (captchaEnabled.value) {
-      codeUrl.value = "data:image/gif;base64," + res.img;
-      loginForm.value.uuid = res.uuid;
-    }
-  });
-}
-
-function getCookie() {
-  const username = Cookies.get("username");
-  const password = Cookies.get("password");
-  const rememberMe = Cookies.get("rememberMe");
-  loginForm.value = {
-    username: username === undefined ? loginForm.value.username : username,
-    password: password === undefined ? loginForm.value.password : decrypt(password),
-    rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
-  };
-}
-
-getCode();
-getCookie();
-</script>
-
-<style lang='scss' scoped>
-.login {
-  display: flex;
-  justify-content: end;
-  align-items: center;
-  height: 100%;
-  background-image: url("../assets/images/login-background.jpg");
-  background-size: cover;
-  padding-right: 200px;
-}
-
-.title {
-  margin: 0px auto 30px auto;
-  text-align: center;
-  color: #707070;
+const codeUrl = ref('')
+const loading = ref(false)
+const captchaEnabled = ref(true)
+const qrAvailable = ref(true)
+const mobileAppQrUrl = `${import.meta.env.BASE_URL}images/mobile-app-qr.png`
+
+async function handleLogin() {
+  if (loading.value) return
+  const valid = await loginRef.value.validate().catch(() => false)
+  if (!valid) return
+  loading.value = true
+  if (loginForm.value.rememberMe) {
+    Cookies.set('username', loginForm.value.username, { expires: 30 })
+    Cookies.set('rememberMe', 'true', { expires: 30 })
+  } else {
+    Cookies.remove('username')
+    Cookies.remove('rememberMe')
+  }
+  Cookies.remove('password')
+  try {
+    await userStore.login(loginForm.value)
+    await router.push('/portal')
+  } catch {
+    loading.value = false
+    loginForm.value.code = ''
+    if (captchaEnabled.value) getCode()
+  }
 }
 
-.login-form {
-  border-radius: 6px;
-  background: #ffffff;
-  width: 400px;
-  padding: 25px 25px 5px 25px;
-
-  .el-input {
-    height: 40px;
-
-    input {
-      height: 40px;
+async function getCode() {
+  try {
+    const res = await getCodeImg()
+    captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled
+    if (captchaEnabled.value) {
+      codeUrl.value = `data:image/gif;base64,${res.img}`
+      loginForm.value.uuid = res.uuid
+    } else {
+      loginForm.value.code = ''
+      loginForm.value.uuid = ''
     }
+  } catch {
+    codeUrl.value = ''
   }
+}
 
-  .input-icon {
-    height: 39px;
-    width: 14px;
-    margin-left: 0px;
-  }
+function restoreRememberedAccount() {
+  if (!import.meta.env.DEV) loginForm.value.username = Cookies.get('username') || ''
+  loginForm.value.rememberMe = Cookies.get('rememberMe') === 'true'
+  Cookies.remove('password')
 }
 
-.login-tip {
-  font-size: 13px;
-  text-align: center;
-  color: #bfbfbf;
+function showForgotPassword() {
+  ElMessageBox.alert('请联系平台管理员', '忘记密码', { confirmButtonText: '我知道了' })
 }
 
-.login-code {
-  width: 33%;
-  height: 40px;
-  float: right;
+restoreRememberedAccount()
+getCode()
+</script>
 
-  img {
-    cursor: pointer;
-    vertical-align: middle;
-  }
+<style scoped lang="scss">
+.login-page {
+  --blue: #2f7cf6; --text-one: #16233b; --text-two: #52627a; --text-three: #8a9ab2; --line: #e6ecf5;
+  min-width: 100%; min-height: 100vh; position: relative; overflow: hidden; color: var(--text-one);
+  background: linear-gradient(135deg, #dcebff 0%, #f2f7ff 45%, #e7f0ff 100%);
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
 }
-
-.el-login-footer {
-  height: 40px;
-  line-height: 40px;
-  position: fixed;
-  bottom: 0;
-  width: 100%;
-  text-align: center;
-  color: #fff;
-  font-family: Arial;
-  font-size: 12px;
-  letter-spacing: 1px;
+.login-page::before { content: ''; position: fixed; inset: 0; background-image: linear-gradient(rgba(47, 124, 246, .06) 1px, transparent 1px), linear-gradient(90deg, rgba(47, 124, 246, .06) 1px, transparent 1px); background-size: 52px 52px; mask-image: radial-gradient(ellipse at 40% 50%, #000 0%, transparent 78%); }
+.glow { position: fixed; border-radius: 50%; filter: blur(10px); pointer-events: none; }
+.glow-one { width: 420px; height: 420px; right: -120px; top: -120px; background: radial-gradient(circle, rgba(64, 140, 255, .28), transparent 70%); }
+.glow-two { width: 360px; height: 360px; left: -100px; bottom: -120px; background: radial-gradient(circle, rgba(88, 170, 255, .22), transparent 70%); }
+.stage { position: relative; z-index: 1; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px 48px 56px; }
+.panel { width: 100%; max-width: 1180px; height: min(700px, calc(100vh - 110px)); min-height: 590px; display: flex; overflow: hidden; background: rgba(255, 255, 255, .55); border: 1px solid rgba(255, 255, 255, .9); border-radius: 20px; box-shadow: 0 30px 80px rgba(42, 92, 170, .18); backdrop-filter: blur(10px); }
+.brand-side { flex: 1.15; min-width: 0; padding: 44px 48px 36px; display: flex; flex-direction: column; background: radial-gradient(circle at 80% 20%, rgba(90, 160, 255, .18), transparent 55%), linear-gradient(160deg, rgba(255, 255, 255, .35), rgba(231, 240, 255, .25)); }
+.brand-top { display: flex; align-items: center; gap: 11px; }
+.logo { width: 38px; height: 38px; flex: 0 0 auto; border-radius: 9px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #3b8bff, #1e66e8); box-shadow: 0 8px 18px rgba(47, 124, 246, .35); }
+.logo svg { width: 21px; height: 21px; fill: #fff; }
+.brand-name { font-size: 32px; font-weight: 800; line-height: 1.3; }
+.hero { margin-top: 72px; position: relative; z-index: 1; }
+.hero h1 { margin: 0; font-size: 17px; line-height: 1.6; font-weight: 700; }
+.hero h1 span { color: transparent; background: linear-gradient(90deg, #1e66e8, #16b4f2); background-clip: text; }
+.hero p { margin: 14px 0 0; font-size: 14px; color: var(--text-two); line-height: 1.9; }
+.tags { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 20px; }
+.tags span { padding: 5px 12px; border: 1px solid rgba(47, 124, 246, .18); border-radius: 20px; color: #2b6ad0; background: rgba(47, 124, 246, .1); font-size: 12px; }
+.illustration { flex: 1; min-height: 0; margin: 8px -10px 0; display: flex; align-items: center; justify-content: center; }
+.illustration svg { width: min(100%, 480px); height: auto; filter: drop-shadow(0 24px 30px rgba(40, 100, 200, .18)); }
+.form-side { width: 400px; flex: 0 0 400px; padding: 46px 44px 30px; display: flex; flex-direction: column; background: #fff; }
+.login-tabs { display: flex; margin-bottom: 30px; }
+.login-tabs span { position: relative; padding-bottom: 10px; color: var(--text-one); font-size: 15px; font-weight: 700; }
+.login-tabs span::after { content: ''; position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 2px; background: linear-gradient(90deg, #2f7cf6, #16b4f2); }
+.login-form { width: 100%; }
+.field { margin-bottom: 17px; }
+.field label { display: block; width: 100%; margin-bottom: 8px; color: var(--text-two); font-size: 13px; line-height: 18px; }
+.input-box { width: 100%; height: 44px; padding: 0 13px; display: flex; align-items: center; gap: 10px; background: #f8fafd; border: 1px solid var(--line); border-radius: 10px; transition: all .2s; }
+.input-box:focus-within { background: #fff; border-color: var(--blue); box-shadow: 0 0 0 3px rgba(47, 124, 246, .1); }
+.input-box > svg { width: 17px; height: 17px; flex: 0 0 auto; fill: #a8b6cb; }
+.input-box input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--text-one); background: transparent; font: inherit; font-size: 14px; }
+.input-box input::placeholder { color: #b6c2d4; }
+.code-box { padding-right: 0; overflow: hidden; gap: 8px; }
+.captcha { align-self: stretch; width: 118px; flex: 0 0 118px; padding: 0; overflow: hidden; cursor: pointer; border: 0; border-left: 1px solid var(--line); background: linear-gradient(135deg, #eef4ff, #dce8fb); }
+.captcha img { width: 100%; height: 100%; display: block; object-fit: fill; }
+.form-row { margin: 2px 0 22px; display: flex; align-items: center; justify-content: space-between; font-size: 12px; }
+.remember { display: flex; align-items: center; gap: 6px; color: var(--text-three); cursor: pointer; }
+.remember input { accent-color: var(--blue); }
+.forgot { padding: 0; border: 0; color: var(--blue); background: none; cursor: pointer; font-size: 12px; }
+.login-button { width: 100%; height: 46px; border: 0; border-radius: 10px; color: #fff; background: linear-gradient(90deg, #2f7cf6, #1f9bf0); box-shadow: 0 12px 24px rgba(47, 124, 246, .32); cursor: pointer; font-size: 15px; font-weight: 700; letter-spacing: 6px; transition: transform .15s, box-shadow .15s; }
+.login-button:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 16px 30px rgba(47, 124, 246, .4); }
+.login-button:disabled { cursor: wait; opacity: .72; }
+.mini-qr { margin-top: 44px; padding-top: 16px; display: flex; align-items: center; justify-content: center; gap: 12px; border-top: 1px dashed var(--line); }
+.qr-box { width: 96px; height: 96px; flex: 0 0 96px; display: flex; align-items: center; justify-content: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; color: var(--text-three); background: #f8fafd; font-size: 12px; line-height: 1.6; text-align: center; }
+.qr-box img { width: 100%; height: 100%; display: block; object-fit: contain; }
+.qr-text { color: var(--text-two); font-size: 12px; line-height: 1.6; }
+.qr-text b { display: block; margin-bottom: 2px; color: var(--text-one); }
+footer { position: fixed; right: 0; bottom: 14px; left: 0; z-index: 2; color: #8aa0bd; font-size: 12px; text-align: center; }
+:deep(.el-form-item__content) { display: block; line-height: normal; }
+:deep(.el-form-item__error) { position: absolute; top: calc(100% + 2px); padding: 0; }
+@media (max-width: 980px) {
+  .stage { padding: 24px; } .brand-side { padding: 36px 32px; } .brand-name { font-size: 24px; } .form-side { width: 380px; flex-basis: 380px; padding-right: 36px; padding-left: 36px; }
 }
-
-.login-code-img {
-  height: 40px;
-  padding-left: 12px;
+@media (max-width: 760px) {
+  .login-page { overflow: auto; } .stage { min-height: 100vh; padding: 20px 16px 54px; } .panel { height: auto; min-height: 0; max-width: 420px; } .brand-side { display: none; } .form-side { width: 100%; flex-basis: 100%; padding: 34px 28px 28px; } footer { position: absolute; padding: 0 16px; }
 }
 </style>

+ 302 - 447
src/views/portal/index.vue

@@ -1,209 +1,142 @@
 <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>
-      <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>
     </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>
+        <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>
+      <template #footer><el-button type="primary" @click="noticeDialogVisible = false">关闭</el-button></template>
+    </el-dialog>
   </div>
 </template>
 
 <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 { 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 userStore = useUserStore()
 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 = '') {
   if (!route || route.hidden) return ''
@@ -219,9 +152,9 @@ function findRoutePath(route, matcher, parentPath = '') {
 function findFirstVisibleLeafPath(route, parentPath = '') {
   if (!route || route.hidden) return ''
   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)
     if (result) return result
   }
@@ -229,288 +162,210 @@ function findFirstVisibleLeafPath(route, parentPath = '') {
 }
 
 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>