feat(frontend): 合入 RuoYi 3.9.2 前端升级
- 升级 vue-router 4.3 → 4.6.4 (router4 新写法) - 升级 echarts 5.4 → 5.6.0 - 修复 permission.js router4 过期 next() 写法 - 新增 isPathMatch 通配符白名单匹配 - 新增 TreePanel 树分割组件 (左树右表) - 新增 ExcelImportDialog 导入组件 - 新增锁屏功能 (lock.js + lock.vue) - 新增密码规则校验 (passwordRule.js) - 新增 HeaderNotice 顶部通知组件 - 新增 TopBar 顶部工具栏组件 - 新增 Copyright 版权组件 - 增强 TagsView 持久化标签页 - 添加升级计划文档 (UPGRADE_PLAN_v2.0.md)
This commit is contained in:
275
docs/RUOYI_392_UPGRADE_CHECKLIST.md
Normal file
275
docs/RUOYI_392_UPGRADE_CHECKLIST.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# RuoYi 3.9.2 前端合入清单
|
||||||
|
|
||||||
|
> **编制日期**: 2026-06-04
|
||||||
|
> **基线**: RuoYi-Vue3 v3.9.2 (2026-03-26)
|
||||||
|
> **目标**: 从 RuoYi 3.9.2 合入高价值前端组件,不破坏现有业务
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行原则
|
||||||
|
|
||||||
|
1. **渐进式合入** — 每次只合一个组件,验证通过再合下一个
|
||||||
|
2. **保留业务代码** — `com.openhis.*` 目录不动,只改脚手架层
|
||||||
|
3. **兼容优先** — 优先合入无侵入的独立组件
|
||||||
|
4. **验证必做** — 每步完成后跑 `npm run dev` + 核心页面冒烟
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A: 基础设施修复(0.5 天)
|
||||||
|
|
||||||
|
### A.1 修复 router4 过期写法 `next()`
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `src/permission.js` |
|
||||||
|
| **变更** | `next()` → `return { path: '/' }` / `return true` / `return false` |
|
||||||
|
| **参考** | RuoYi 3.9.2 `src/permission.js` 第 1-76 行 |
|
||||||
|
| **风险** | 🟡 中 — 所有路由跳转都经过这里 |
|
||||||
|
| **验证** | 登录→首页→各菜单跳转→返回→刷新→404页→白名单 |
|
||||||
|
|
||||||
|
**具体变更点:**
|
||||||
|
```
|
||||||
|
// 旧写法 (我们当前)
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (getToken()) {
|
||||||
|
if (to.path === '/login') {
|
||||||
|
next({ path: '/' })
|
||||||
|
} else {
|
||||||
|
if (useUserStore().roles.length === 0) {
|
||||||
|
// ...
|
||||||
|
next({ ...to, replace: true })
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next(`/login?redirect=${to.fullPath}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 新写法 (RuoYi 3.9.2)
|
||||||
|
router.beforeEach(async (to, from) => {
|
||||||
|
if (getToken()) {
|
||||||
|
if (to.path === '/login') {
|
||||||
|
return { path: '/' }
|
||||||
|
}
|
||||||
|
if (useUserStore().roles.length === 0) {
|
||||||
|
// ...
|
||||||
|
return { ...to, replace: true }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return `/login?redirect=${to.fullPath}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A.2 引入通配符白名单匹配
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `src/utils/validate.js` |
|
||||||
|
| **变更** | 新增 `isPathMatch(pattern, path)` 函数 |
|
||||||
|
| **参考** | RuoYi 3.9.2 `src/utils/validate.js` |
|
||||||
|
| **风险** | 🟢 低 — 纯新增函数 |
|
||||||
|
| **验证** | 白名单路径 `/login`、`/register` 仍正常 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase B: 核心组件合入(2-3 天)
|
||||||
|
|
||||||
|
### B.1 TreePanel 树分割组件
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/components/TreePanel/` |
|
||||||
|
| **目标** | 我们的 `src/components/TreePanel/`(新建) |
|
||||||
|
| **依赖** | Element Plus Tree + Table |
|
||||||
|
| **风险** | 🟢 低 — 独立组件,不影响现有代码 |
|
||||||
|
| **验证** | 新建一个测试页面引入 TreePanel,确认左右分栏正常 |
|
||||||
|
|
||||||
|
**HIS 适用场景:**
|
||||||
|
- 基础管理 → 组织机构(左树右表)
|
||||||
|
- 基础管理 → 药品目录(左分类右列表)
|
||||||
|
- 数据字典 → 分类管理
|
||||||
|
- 病区管理 → 病区/床位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B.2 ExcelImportDialog 导入组件
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/components/ExcelImportDialog/` |
|
||||||
|
| **目标** | 我们的 `src/components/ExcelImportDialog/`(新建) |
|
||||||
|
| **依赖** | Element Plus Dialog + Upload |
|
||||||
|
| **风险** | 🟢 低 — 独立组件 |
|
||||||
|
| **验证** | 上传 Excel → 预览 → 确认导入 |
|
||||||
|
|
||||||
|
**HIS 适用场景:**
|
||||||
|
- 基础管理 → 药品批量导入
|
||||||
|
- 基础管理 → 诊断目录导入
|
||||||
|
- 基础管理 → 医保目录同步
|
||||||
|
- 患者管理 → 批量建档
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B.3 锁屏功能
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 |
|
||||||
|
| **涉及文件** | `src/store/modules/lock.js`(新增)<br>`src/views/lock.vue`(新增)<br>`src/permission.js`(加锁屏拦截)<br>`src/store/modules/user.js`(加 unlockScreen) |
|
||||||
|
| **风险** | 🟡 中 — 涉及 store 和路由 |
|
||||||
|
| **验证** | 锁屏→输入密码解锁→自动锁屏→手动锁屏 |
|
||||||
|
|
||||||
|
**操作步骤:**
|
||||||
|
1. 复制 `lock.js` 到 `src/store/modules/`
|
||||||
|
2. 复制 `lock.vue` 到 `src/views/`
|
||||||
|
3. 修改 `permission.js` 添加锁屏路由检查
|
||||||
|
4. 修改 `user.js` 登录成功后调用 `unlockScreen()`
|
||||||
|
5. 在 Navbar 添加锁屏按钮
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B.4 密码规则校验
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/utils/passwordRule.js` |
|
||||||
|
| **目标** | 我们的 `src/utils/passwordRule.js`(新增) |
|
||||||
|
| **风险** | 🟢 低 — 独立工具函数 |
|
||||||
|
| **验证** | 修改密码页测试密码强度校验 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase C: Layout 增强(1-2 天)
|
||||||
|
|
||||||
|
### C.1 HeaderNotice 顶部通知
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/layout/components/HeaderNotice/` |
|
||||||
|
| **目标** | 我们的 `src/layout/components/HeaderNotice/`(新增) |
|
||||||
|
| **依赖** | 我们已有的通知公告接口 |
|
||||||
|
| **风险** | 🟢 低 — 新增组件 |
|
||||||
|
| **验证** | 顶部显示通知铃铛 → 点击展开通知列表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C.2 TopBar 顶部工具栏
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/layout/components/TopBar/` |
|
||||||
|
| **目标** | 我们的 `src/layout/components/TopBar/`(新增) |
|
||||||
|
| **风险** | 🟡 中 — 需要修改 `layout/index.vue` 引入 |
|
||||||
|
| **验证** | 顶部工具栏显示搜索、全屏、通知等 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C.3 Copyright 版权组件
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **来源** | RuoYi 3.9.2 `src/layout/components/Copyright/` |
|
||||||
|
| **目标** | 我们的 `src/layout/components/Copyright/`(新增) |
|
||||||
|
| **风险** | 🟢 低 |
|
||||||
|
| **验证** | 侧边栏底部显示版权信息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase D: 持久化标签页增强(0.5 天)
|
||||||
|
|
||||||
|
### D.1 TagsView 持久化
|
||||||
|
|
||||||
|
| 项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `src/store/modules/tagsView.js` |
|
||||||
|
| **变更** | 从 RuoYi 3.9.2 复制增强版 |
|
||||||
|
| **新增功能** | 刷新后保持标签页状态、Chrome 风格标签页 |
|
||||||
|
| **风险** | 🟡 中 — 替换现有 store |
|
||||||
|
| **验证** | 打开多个标签 → 刷新页面 → 标签页仍在 → 关闭浏览器重开 → 标签页恢复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase E: 后端小版本升级(30 分钟)
|
||||||
|
|
||||||
|
### E.1 依赖版本升级
|
||||||
|
|
||||||
|
| 组件 | 当前 | 升级到 | 文件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Druid | 1.2.27 | 1.2.28 | `pom.xml` |
|
||||||
|
| Fastjson2 | 2.0.58 | 2.0.61 | `pom.xml` |
|
||||||
|
| OSHI | 6.6.5 | 6.10.0 | `pom.xml` |
|
||||||
|
| Commons IO | 2.13.0 | 2.21.0 | `pom.xml` |
|
||||||
|
| BouncyCastle | bcprov-jdk15on 1.69 | bcprov-jdk18on 1.80 | `pom.xml` |
|
||||||
|
|
||||||
|
**操作:**
|
||||||
|
```bash
|
||||||
|
cd openhis-server-new
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
# 验证启动正常
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase F: 前端依赖升级(30 分钟)
|
||||||
|
|
||||||
|
### F.1 版本号更新
|
||||||
|
|
||||||
|
| 组件 | 当前 | 升级到 | 风险 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| vue-router | ^4.3.0 | ^4.6.4 | 🟢 低 |
|
||||||
|
| echarts | ^5.4.3 | ^5.6.0 | 🟢 低 |
|
||||||
|
| element-plus | ^2.14.1 | 保持 | ✅ 我们更新 |
|
||||||
|
| @vueuse/core | ^14.3.0 | 保持 | ✅ 我们更新 |
|
||||||
|
|
||||||
|
**操作:**
|
||||||
|
```bash
|
||||||
|
cd openhis-ui-vue3
|
||||||
|
npm install vue-router@^4.6.4 echarts@^5.6.0
|
||||||
|
npm run dev # 验证无报错
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 执行顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
Day 1 上午: A.1 (permission.js router4 修复) + A.2 (validate.js)
|
||||||
|
Day 1 下午: E.1 (后端小版本升级) + F.1 (前端依赖升级)
|
||||||
|
Day 2 上午: B.1 (TreePanel) + B.2 (ExcelImportDialog)
|
||||||
|
Day 2 下午: B.3 (锁屏功能) + B.4 (密码规则)
|
||||||
|
Day 3 上午: C.1 (HeaderNotice) + C.2 (TopBar) + C.3 (Copyright)
|
||||||
|
Day 3 下午: D.1 (TagsView 持久化) + 全量验证
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
每步完成后逐项检查:
|
||||||
|
|
||||||
|
- [ ] `npm run dev` 无报错
|
||||||
|
- [ ] 登录页正常
|
||||||
|
- [ ] 首页加载正常
|
||||||
|
- [ ] 菜单导航正常
|
||||||
|
- [ ] 各业务模块页面正常(至少抽查 5 个)
|
||||||
|
- [ ] 表格渲染正常(VXE Table)
|
||||||
|
- [ ] 打印功能正常(vue-plugin-hiprint)
|
||||||
|
- [ ] 权限控制正常(hasPermi 指令)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险控制
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| permission.js 改坏导致无法登录 | 备份当前文件,改完立即测试登录流程 |
|
||||||
|
| store 变更导致状态丢失 | 测试登录→刷新→各页面切换 |
|
||||||
|
| 新组件与现有样式冲突 | 先在独立页面测试,确认无冲突再引入 layout |
|
||||||
|
| npm 依赖冲突 | 锁版本,避免自动升级无关依赖 |
|
||||||
|
|
||||||
64
docs/UPGRADE_LOG.md
Normal file
64
docs/UPGRADE_LOG.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# OpenHIS 组件升级日志
|
||||||
|
|
||||||
|
> 每次升级后在此记录,方便跨 session 追踪进度。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RuoYi 3.9.2 前端合入进度
|
||||||
|
|
||||||
|
### Phase A: 基础设施修复
|
||||||
|
- [x] A.1 permission.js router4 过期写法修复 ✅ 2026-06-04
|
||||||
|
- [x] A.2 validate.js 通配符匹配 isPathMatch ✅ 2026-06-04
|
||||||
|
|
||||||
|
### Phase B: 核心组件合入
|
||||||
|
- [x] B.1 TreePanel 树分割组件 ✅ 2026-06-04
|
||||||
|
- [x] B.2 ExcelImportDialog 导入组件 ✅ 2026-06-04
|
||||||
|
- [x] B.3 锁屏功能 (lock.js + lock.vue) ✅ 2026-06-04
|
||||||
|
- [x] B.4 密码规则校验 (passwordRule.js) ✅ 2026-06-04
|
||||||
|
|
||||||
|
### Phase C: Layout 增强
|
||||||
|
- [x] C.1 HeaderNotice 顶部通知 ✅ 2026-06-04
|
||||||
|
- [x] C.2 TopBar 顶部工具栏 ✅ 2026-06-04
|
||||||
|
- [x] C.3 Copyright 版权组件 ✅ 2026-06-04
|
||||||
|
|
||||||
|
### Phase D: 持久化标签页
|
||||||
|
- [x] D.1 TagsView 持久化增强 ✅ 2026-06-04
|
||||||
|
|
||||||
|
### Phase E: 后端小版本升级
|
||||||
|
- [ ] E.1 Druid 1.2.27 → 1.2.28
|
||||||
|
- [ ] E.1 Fastjson2 2.0.58 → 2.0.61
|
||||||
|
- [ ] E.1 OSHI 6.6.5 → 6.10.0
|
||||||
|
- [ ] E.1 Commons IO 2.13.0 → 2.21.0
|
||||||
|
- [ ] E.1 BouncyCastle 1.69 → 1.80
|
||||||
|
|
||||||
|
### Phase F: 前端依赖升级
|
||||||
|
- [x] F.1 vue-router ^4.3.0 → 4.6.4 ✅ 2026-06-04
|
||||||
|
- [x] F.1 echarts ^5.4.3 → 5.6.0 ✅ 2026-06-04
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级记录
|
||||||
|
|
||||||
|
### 2026-06-04 RuoYi 3.9.2 前端合入
|
||||||
|
|
||||||
|
**变更文件:**
|
||||||
|
- `src/permission.js` — router4 新写法 + 锁屏检查 + 通配符白名单
|
||||||
|
- `src/utils/validate.js` — 新增 isPathMatch + isEmpty
|
||||||
|
- `src/utils/passwordRule.js` — 新增密码规则校验
|
||||||
|
- `src/store/modules/lock.js` — 新增锁屏 store
|
||||||
|
- `src/store/modules/tagsView.js` — RuoYi 3.9.2 增强版
|
||||||
|
- `src/views/lock.vue` — 新增锁屏页面
|
||||||
|
- `src/router/index.js` — 新增 /lock 路由
|
||||||
|
- `src/api/login.js` — 新增 unlockScreen API
|
||||||
|
- `src/components/TreePanel/` — 新增树分割组件
|
||||||
|
- `src/components/ExcelImportDialog/` — 新增 Excel 导入组件
|
||||||
|
- `src/layout/components/HeaderNotice/` — 新增顶部通知
|
||||||
|
- `src/layout/components/TopBar/` — 新增顶部工具栏
|
||||||
|
- `package.json` — vue-router 4.6.4 + echarts 5.6.0
|
||||||
|
|
||||||
|
**验证结果:**
|
||||||
|
- ✅ npm run build:dev 编译成功 (1m 41s)
|
||||||
|
- ✅ 前端 HTTP 200
|
||||||
|
- ✅ API 代理 HTTP 200
|
||||||
|
- ✅ 1825 文件,107M
|
||||||
|
|
||||||
171
docs/UPGRADE_PLAN_v2.0.md
Normal file
171
docs/UPGRADE_PLAN_v2.0.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# OpenHIS 二次开发版本 — 组件升级计划
|
||||||
|
|
||||||
|
> **编制日期**: 2026-06-03
|
||||||
|
> **对比基线**: Gitee `tntlinking-opensource/openhis-itai-pro` 2.0 分支
|
||||||
|
> **目标**: 在不破坏现有业务的前提下,逐步引入高价值组件升级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级原则
|
||||||
|
|
||||||
|
1. **独立可验证** — 每个 Phase 完成后必须独立通过编译 + 冒烟测试
|
||||||
|
2. **不破坏业务** — 一次只升级一个组件,出问题可快速回滚
|
||||||
|
3. **先补丁后重构** — 小版本升级直接改版本号,大版本升级单独评估
|
||||||
|
4. **文档同步** — 每次升级后更新 `UPGRADE_LOG.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: 安全修复(预估 0.5 天)
|
||||||
|
|
||||||
|
> 🔴 **最高优先级** — 安全漏洞,必须立即处理
|
||||||
|
|
||||||
|
### 0.1 BouncyCastle 1.69 → 1.80
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `openhis-server-new/pom.xml` |
|
||||||
|
| **变更** | `<bcprov-jdk15on.version>1.69</bcprov-jdk15on.version>` → 删除,改用 jdk18on |
|
||||||
|
| **新依赖** | `org.bouncycastle:bcprov-jdk18on:1.80` + `org.bouncycastle:bcpkix-jdk18on:1.80` |
|
||||||
|
| **原因** | 1.69 有已知安全漏洞;1.80 支持国密 SM2/SM3 算法 |
|
||||||
|
| **影响面** | `rg "bouncycastle\|bcprov\|bcpkix" --type java` 搜索所有引用 |
|
||||||
|
| **验证** | `mvn compile` + 启动后检查加解密功能(登录、token 签发) |
|
||||||
|
|
||||||
|
### 0.2 vue-router 4.3 → 4.5
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `openhis-ui-vue3/package.json` |
|
||||||
|
| **变更** | `"vue-router": "^4.3.0"` → `"^4.5.1"` |
|
||||||
|
| **风险** | 低 — 4.x 小版本,API 兼容 |
|
||||||
|
| **验证** | 前端 `npm run dev` → 测试所有页面路由跳转、返回、权限拦截 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 核心组件升级(预估 1-2 天)
|
||||||
|
|
||||||
|
> 🟡 **高价值** — 改动可控,收益明显
|
||||||
|
|
||||||
|
### 1.1 echarts 5.4 → 6.0
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `openhis-ui-vue3/package.json` |
|
||||||
|
| **变更** | `"echarts": "^5.4.3"` → `"^6.0.0"` |
|
||||||
|
| **影响面** | `rg "echarts" --type vue --type js` 搜索所有图表组件 |
|
||||||
|
| **Breaking Changes** | ECharts 6 主要变更:Tree-shaking 更彻底、部分 API 重命名 |
|
||||||
|
| **验证清单** | 首页统计图表、门诊量趋势、药品销售报表、住院床位占用图 |
|
||||||
|
| **回滚方案** | 改回 `"^5.4.3"` 即可 |
|
||||||
|
|
||||||
|
### 1.2 lodash-es → es-toolkit
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `openhis-ui-vue3/package.json` + 所有引用文件 |
|
||||||
|
| **变更** | `"lodash-es": "^4.17.21"` → 删除,添加 `"es-toolkit": "^1.41.0"` |
|
||||||
|
| **迁移映射** | `_.cloneDeep` → `cloneDeep`、`_.debounce` → `debounce`、`_.isEqual` → `isEqual`、`_.get` → `get` |
|
||||||
|
| **影响面** | `rg "from 'lodash-es'" --type vue --type js` 逐个替换 |
|
||||||
|
| **风险** | 中 — 需逐个替换 import,但 API 基本一致 |
|
||||||
|
| **验证** | 全站功能冒烟测试 |
|
||||||
|
|
||||||
|
### 1.3 引入 MapStruct(后端)
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **文件** | `openhis-server-new/pom.xml` (parent) + `openhis-application/pom.xml` |
|
||||||
|
| **新增依赖** | `org.mapstruct:mapstruct:1.5.5.Final` + `mapstruct-processor` + `lombok-mapstruct-binding` |
|
||||||
|
| **使用方式** | 新增 `@Mapper(componentModel = "spring")` 接口替代 `BeanUtils.copyProperties` |
|
||||||
|
| **策略** | **渐进式** — 不改造现有代码,仅新功能使用 MapStruct |
|
||||||
|
| **验证** | `mvn compile` 确认注解处理器工作 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 富文本 + 数据库迁移(预估 3-5 天)
|
||||||
|
|
||||||
|
> 🟢 **中等工作量** — 需要一定的改造
|
||||||
|
|
||||||
|
### 2.1 tiptap 富文本编辑器(替代 vue-quill)
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **新增依赖** | `@tiptap/vue-3`、`@tiptap/starter-kit`、`@tiptap/extension-*` 系列 |
|
||||||
|
| **替换目标** | `@vueup/vue-quill`(当前用于病历编辑、处方备注等) |
|
||||||
|
| **影响面** | `rg "vue-quill\|Quill" --type vue` 搜索所有引用 |
|
||||||
|
| **新增能力** | 表格编辑、图片内嵌、协作编辑、自定义节点 |
|
||||||
|
| **策略** | 新页面用 tiptap,旧页面逐步迁移 |
|
||||||
|
| **验证** | 病历编辑器、处方备注、各种富文本输入场景 |
|
||||||
|
|
||||||
|
### 2.2 引入 Flyway 数据库迁移
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **新增依赖** | `org.flywaydb:flyway-core` + `flyway-database-postgresql` |
|
||||||
|
| **配置** | `application-dev.yml` 添加 Flyway 配置 |
|
||||||
|
| **目录** | `src/main/resources/db/migration/` |
|
||||||
|
| **迁移文件命名** | `V1__init.sql`、`V2__add_xxx.sql` |
|
||||||
|
| **策略** | **不对现有表做迁移**,仅新功能的 DDL 用 Flyway 管理 |
|
||||||
|
| **风险** | 中 — 需确保现有数据库与 Flyway 基线一致 |
|
||||||
|
| **验证** | 启动时 Flyway 自动执行 → 检查 `flyway_schema_history` 表 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: UI 框架评估(预估 5-10 天,可选)
|
||||||
|
|
||||||
|
> ⚪ **长期规划** — 工作量大,收益高但风险也高
|
||||||
|
|
||||||
|
### 3.1 Tailwind CSS 引入
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **新增依赖** | `tailwindcss`、`autoprefixer`、`postcss` |
|
||||||
|
| **策略** | **渐进式** — Tailwind 与现有 SCSS 共存,新页面用 Tailwind |
|
||||||
|
| **影响面** | 全局样式可能冲突,需仔细测试 |
|
||||||
|
| **建议** | 先在 `help-center` 或独立页面试水 |
|
||||||
|
|
||||||
|
### 3.2 Vben Admin 组件库评估
|
||||||
|
|
||||||
|
| 项目 | 详情 |
|
||||||
|
|---|---|
|
||||||
|
| **可引入的包** | `@vben/access`(权限)、`@vben/request`(请求封装)、`@vben/preferences`(偏好设置) |
|
||||||
|
| **风险** | 高 — Vben 组件与我们现有架构耦合度未知 |
|
||||||
|
| **策略** | 仅评估,不做实施。等 Phase 0-2 完成后再决定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级路线图
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1: Phase 0 (BouncyCastle + vue-router) ← 立即执行
|
||||||
|
Week 1: Phase 1.1 (echarts 6) ← 紧随其后
|
||||||
|
Week 2: Phase 1.2 (es-toolkit) + 1.3 (MapStruct)
|
||||||
|
Week 3: Phase 2.1 (tiptap) ← 可并行
|
||||||
|
Week 3: Phase 2.2 (Flyway) ← 可并行
|
||||||
|
Week 4+: Phase 3 (Tailwind + Vben 评估) ← 按需
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 升级日志模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [日期] 升级记录
|
||||||
|
|
||||||
|
### 组件: XXX Y.Y → Z.Z
|
||||||
|
- **Phase**: 0/1/2/3
|
||||||
|
- **变更文件**: list...
|
||||||
|
- **验证结果**: ✅ 编译通过 / ✅ 冒烟测试通过
|
||||||
|
- **回滚方案**: 改回旧版本号
|
||||||
|
- **备注**: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险矩阵
|
||||||
|
|
||||||
|
| 风险 | 概率 | 影响 | 缓解措施 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| echarts 6 API 不兼容 | 中 | 高 | 先在测试环境验证所有图表 |
|
||||||
|
| es-toolkit 行为差异 | 低 | 中 | 逐个替换,每个改完跑测试 |
|
||||||
|
| Flyway 与现有 SQL 冲突 | 中 | 高 | 设置 baseline,不管理已有表 |
|
||||||
|
| tiptap 与现有编辑器冲突 | 低 | 低 | 新旧共存,逐步迁移 |
|
||||||
|
| Tailwind 样式覆盖 | 高 | 中 | 使用 CSS Module 隔离 |
|
||||||
|
|
||||||
29
openhis-ui-vue3/package-lock.json
generated
29
openhis-ui-vue3/package-lock.json
generated
@@ -7,7 +7,6 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "openhis",
|
"name": "openhis",
|
||||||
"version": "3.8.10",
|
"version": "3.8.10",
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.6.0",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"element-plus": "^2.14.1",
|
"element-plus": "^2.14.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
@@ -41,7 +40,7 @@
|
|||||||
"vue-area-linkage": "^5.1.0",
|
"vue-area-linkage": "^5.1.0",
|
||||||
"vue-cropper": "^1.1.1",
|
"vue-cropper": "^1.1.1",
|
||||||
"vue-plugin-hiprint": "^0.0.60",
|
"vue-plugin-hiprint": "^0.0.60",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.6.4",
|
||||||
"vxe-table": "^4.19.6",
|
"vxe-table": "^4.19.6",
|
||||||
"xe-utils": "^4.0.8"
|
"xe-utils": "^4.0.8"
|
||||||
},
|
},
|
||||||
@@ -4377,12 +4376,13 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/echarts": {
|
"node_modules/echarts": {
|
||||||
"version": "5.4.3",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
|
||||||
"integrity": "sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==",
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0",
|
"tslib": "2.3.0",
|
||||||
"zrender": "5.4.4"
|
"zrender": "5.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/editorconfig": {
|
"node_modules/editorconfig": {
|
||||||
@@ -10426,8 +10426,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
@@ -11296,8 +11297,9 @@
|
|||||||
},
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/devtools-api": "^6.6.4"
|
"@vue/devtools-api": "^6.6.4"
|
||||||
},
|
},
|
||||||
@@ -11655,9 +11657,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zrender": {
|
"node_modules/zrender": {
|
||||||
"version": "5.4.4",
|
"version": "5.6.1",
|
||||||
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
|
||||||
"integrity": "sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==",
|
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.3.0"
|
"tslib": "2.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"echarts": "^5.4.3",
|
"echarts": "^5.6.0",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
"element-plus": "^2.14.1",
|
"element-plus": "^2.14.1",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
"vue-area-linkage": "^5.1.0",
|
"vue-area-linkage": "^5.1.0",
|
||||||
"vue-cropper": "^1.1.1",
|
"vue-cropper": "^1.1.1",
|
||||||
"vue-plugin-hiprint": "^0.0.60",
|
"vue-plugin-hiprint": "^0.0.60",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.6.4",
|
||||||
"vxe-table": "^4.19.6",
|
"vxe-table": "^4.19.6",
|
||||||
"xe-utils": "^4.0.8"
|
"xe-utils": "^4.0.8"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,3 +97,11 @@ export function sign(practitionerId, mac, ip) {
|
|||||||
method: 'post',
|
method: 'post',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// 锁屏解锁(验证密码)
|
||||||
|
export function unlockScreen(password) {
|
||||||
|
return request({
|
||||||
|
url: '/auth/unlock',
|
||||||
|
method: 'post',
|
||||||
|
data: { password }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
137
openhis-ui-vue3/src/components/ExcelImportDialog/index.vue
Normal file
137
openhis-ui-vue3/src/components/ExcelImportDialog/index.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose">
|
||||||
|
<el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="headers" :action="uploadUrl" :disabled="isUploading" :on-progress="handleProgress" :on-change="handleFileChange" :on-remove="handleFileRemove" :on-success="handleSuccess" :auto-upload="false" drag>
|
||||||
|
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||||
|
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||||
|
<template #tip>
|
||||||
|
<div class="el-upload__tip text-center">
|
||||||
|
<div class="el-upload__tip">
|
||||||
|
<el-checkbox v-model="updateSupport"> {{ updateSupportLabel }} </el-checkbox>
|
||||||
|
</div>
|
||||||
|
<span>仅允许导入xls、xlsx格式文件。</span>
|
||||||
|
<el-link v-if="templateUrl" type="primary" underline="never" style="font-size: 12px; vertical-align: baseline" @click="handleDownloadTemplate">下载模板</el-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="handleSubmit">确 定</el-button>
|
||||||
|
<el-button @click="visible = false">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// 对话框标题
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '数据导入'
|
||||||
|
},
|
||||||
|
// 对话框宽度
|
||||||
|
width: {
|
||||||
|
type: String,
|
||||||
|
default: '400px'
|
||||||
|
},
|
||||||
|
// 上传接口地址(必传)
|
||||||
|
action: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
// 模板下载接口地址,不传则不显示下载模板链接
|
||||||
|
templateAction: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
// 模板文件名前缀
|
||||||
|
templateFileName: {
|
||||||
|
type: String,
|
||||||
|
default: 'template'
|
||||||
|
},
|
||||||
|
// 覆盖更新勾选框的说明文字
|
||||||
|
updateSupportLabel: {
|
||||||
|
type: String,
|
||||||
|
default: '是否更新已经存在的数据'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['success'])
|
||||||
|
|
||||||
|
const uploadRef = ref(null)
|
||||||
|
const visible = ref(false)
|
||||||
|
const selectedFile = ref(null)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const updateSupport = ref(false)
|
||||||
|
const headers = { Authorization: 'Bearer ' + getToken() }
|
||||||
|
|
||||||
|
const uploadUrl = computed(() => {
|
||||||
|
return import.meta.env.VITE_APP_BASE_API + props.action + '?updateSupport=' + (updateSupport.value ? 1 : 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const templateUrl = computed(() => !!props.templateAction)
|
||||||
|
|
||||||
|
// 打开对话框(供父组件通过 ref 调用)
|
||||||
|
function open() {
|
||||||
|
updateSupport.value = false
|
||||||
|
isUploading.value = false
|
||||||
|
visible.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
selectedFile.value = null
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭时清理
|
||||||
|
function handleClose() {
|
||||||
|
isUploading.value = false
|
||||||
|
selectedFile.value = null
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载模板
|
||||||
|
function handleDownloadTemplate() {
|
||||||
|
proxy.download(props.templateAction, {}, `${props.templateFileName}_${new Date().getTime()}.xlsx`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传进度
|
||||||
|
function handleProgress() {
|
||||||
|
isUploading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件选择处理 */
|
||||||
|
const handleFileChange = (file, fileList) => {
|
||||||
|
selectedFile.value = file
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件删除处理 */
|
||||||
|
const handleFileRemove = (file, fileList) => {
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传成功
|
||||||
|
function handleSuccess(response) {
|
||||||
|
visible.value = false
|
||||||
|
isUploading.value = false
|
||||||
|
selectedFile.value = null
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
||||||
|
emit('success')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交上传
|
||||||
|
function handleSubmit() {
|
||||||
|
const file = selectedFile.value
|
||||||
|
if (!file || file.length === 0 || !file.name.toLowerCase().endsWith('.xls') && !file.name.toLowerCase().endsWith('.xlsx')) {
|
||||||
|
proxy.$modal.msgError("请选择后缀为 “xls”或“xlsx”的文件。")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadRef.value.submit()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
756
openhis-ui-vue3/src/components/TreePanel/index.vue
Normal file
756
openhis-ui-vue3/src/components/TreePanel/index.vue
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tree-sidebar" :class="{ collapsed: collapsed, resizing: isResizing, 'no-initial-transition': isLoadingFromStorage}" :style="{ width: sidebarWidth + 'px' }">
|
||||||
|
<!-- 右侧拖动条 -->
|
||||||
|
<div v-if="!collapsed" class="resize-handle" @mousedown="startResize" @touchstart="startResize" :class="{ active: isResizing }" />
|
||||||
|
<div class="tree-header">
|
||||||
|
<span class="tree-title" v-show="!collapsed">
|
||||||
|
<el-icon><component :is="titleIcon" /></el-icon> {{ title }}
|
||||||
|
</span>
|
||||||
|
<div class="tree-actions" v-show="!collapsed">
|
||||||
|
<el-tooltip :content="isExpandedAll ? '收起全部' : '展开全部'" placement="right">
|
||||||
|
<el-icon class="tree-action-icon" @click="toggleExpandAll">
|
||||||
|
<ArrowDown v-if="isExpandedAll" />
|
||||||
|
<ArrowUp v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="刷新" placement="right">
|
||||||
|
<el-icon class="tree-action-icon" @click="handleRefresh"><Refresh /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 侧边栏展开/收起按钮 -->
|
||||||
|
<div class="collapse-button-container">
|
||||||
|
<el-tooltip :content="collapsed ? '展开' : '收起'" placement="right">
|
||||||
|
<el-icon class="collapse-button" @click="toggleCollapsed">
|
||||||
|
<DArrowRight v-if="collapsed" />
|
||||||
|
<DArrowLeft v-else />
|
||||||
|
</el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-search" v-show="!collapsed" v-if="showSearch">
|
||||||
|
<el-input v-model="searchKeyword" :placeholder="searchPlaceholder" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-wrap" v-show="!collapsed">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
:data="treeData"
|
||||||
|
:props="treeProps"
|
||||||
|
:expand-on-click-node="expandOnClickNode"
|
||||||
|
:filter-node-method="filterNodeMethod"
|
||||||
|
:default-expand-all="defaultExpandAll"
|
||||||
|
:default-expanded-keys="defaultExpandedKeys"
|
||||||
|
:node-key="nodeKey"
|
||||||
|
:check-strictly="checkStrictly"
|
||||||
|
:show-checkbox="showCheckbox"
|
||||||
|
@node-click="onNodeClick"
|
||||||
|
@check="onCheck"
|
||||||
|
@node-expand="onNodeExpand"
|
||||||
|
@node-collapse="onNodeCollapse"
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<slot name="node" :node="node" :data="data">
|
||||||
|
<span class="tree-node">
|
||||||
|
<el-icon class="node-icon">
|
||||||
|
<Folder v-if="data.children && data.children.length" />
|
||||||
|
<Document v-else />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-label" :title="node.label">{{ node.label }}</span>
|
||||||
|
</span>
|
||||||
|
</slot>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
// 树形数据
|
||||||
|
treeData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
// 标题
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '树形结构'
|
||||||
|
},
|
||||||
|
// 标题图标
|
||||||
|
titleIcon: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: 'OfficeBuilding'
|
||||||
|
},
|
||||||
|
// 是否显示搜索框
|
||||||
|
showSearch: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 搜索框占位符
|
||||||
|
searchPlaceholder: {
|
||||||
|
type: String,
|
||||||
|
default: '请输入名称'
|
||||||
|
},
|
||||||
|
// 是否默认收起侧边栏
|
||||||
|
defaultCollapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 树配置项
|
||||||
|
treeProps: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
children: "children",
|
||||||
|
label: "label"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
// 节点唯一标识字段
|
||||||
|
nodeKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'id'
|
||||||
|
},
|
||||||
|
// 是否在点击节点时展开或收起
|
||||||
|
expandOnClickNode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 是否显示复选框
|
||||||
|
showCheckbox: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 是否严格的遵循父子不互相关联
|
||||||
|
checkStrictly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 是否默认展开所有节点
|
||||||
|
defaultExpandAll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 默认展开的节点的key数组
|
||||||
|
defaultExpandedKeys: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
// 默认宽度
|
||||||
|
defaultWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 220
|
||||||
|
},
|
||||||
|
// 收起时的宽度
|
||||||
|
collapsedWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
},
|
||||||
|
// 最小宽度
|
||||||
|
minWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 180
|
||||||
|
},
|
||||||
|
// 最大宽度
|
||||||
|
maxWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 400
|
||||||
|
},
|
||||||
|
// 本地存储的宽度key
|
||||||
|
storageKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'tree-sidebar-width'
|
||||||
|
},
|
||||||
|
// 是否启用本地存储宽度
|
||||||
|
enableStorage: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
// 自定义过滤方法
|
||||||
|
filterMethod: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'collapsed-change',
|
||||||
|
'expanded-all-change',
|
||||||
|
'refresh',
|
||||||
|
'node-click',
|
||||||
|
'check',
|
||||||
|
'node-expand',
|
||||||
|
'node-collapse',
|
||||||
|
'search'
|
||||||
|
])
|
||||||
|
|
||||||
|
const treeRef = ref(null)
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const searchKeyword = ref('')
|
||||||
|
const collapsed = ref(props.defaultCollapsed)
|
||||||
|
const sidebarWidth = ref(props.defaultCollapsed ? props.collapsedWidth : props.defaultWidth)
|
||||||
|
const isResizing = ref(false)
|
||||||
|
const startX = ref(0)
|
||||||
|
const startWidth = ref(0)
|
||||||
|
const saveWidthTimer = ref(null)
|
||||||
|
const rafId = ref(null)
|
||||||
|
const isLoadingFromStorage = ref(false)
|
||||||
|
const expandedAll = ref(props.defaultExpandAll)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isExpandedAll = computed({
|
||||||
|
get: () => expandedAll.value,
|
||||||
|
set: (val) => {
|
||||||
|
expandedAll.value = val
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 节点过滤方法
|
||||||
|
const filterNodeMethod = (value, data) => {
|
||||||
|
if (props.filterMethod) {
|
||||||
|
return props.filterMethod(value, data)
|
||||||
|
}
|
||||||
|
if (!value) return true
|
||||||
|
return data.label && data.label.indexOf(value) !== -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听折叠状态
|
||||||
|
watch(collapsed, (newVal, oldVal) => {
|
||||||
|
if (newVal !== oldVal) {
|
||||||
|
handleCollapseChange(newVal)
|
||||||
|
emit('collapsed-change', newVal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听内部展开状态变化,触发实际树的展开/收起
|
||||||
|
watch(expandedAll, (newVal) => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (newVal) {
|
||||||
|
expandAllNodes()
|
||||||
|
} else {
|
||||||
|
collapseAllNodes()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
emit('expanded-all-change', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听搜索关键词
|
||||||
|
watch(searchKeyword, (val) => {
|
||||||
|
if (treeRef.value) {
|
||||||
|
treeRef.value.filter(val)
|
||||||
|
emit('search', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清理定时器和动画帧
|
||||||
|
const cleanup = () => {
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value)
|
||||||
|
rafId.value = null
|
||||||
|
}
|
||||||
|
if (saveWidthTimer.value) {
|
||||||
|
clearTimeout(saveWidthTimer.value)
|
||||||
|
saveWidthTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理收起/展开状态变化
|
||||||
|
const handleCollapseChange = (isCollapsed) => {
|
||||||
|
if (isCollapsed) {
|
||||||
|
saveWidthToStorage()
|
||||||
|
sidebarWidth.value = props.collapsedWidth
|
||||||
|
} else {
|
||||||
|
const savedWidth = getSavedWidth()
|
||||||
|
sidebarWidth.value = savedWidth !== null ? savedWidth : props.defaultWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取保存的宽度
|
||||||
|
const getSavedWidth = () => {
|
||||||
|
if (!props.enableStorage) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const savedWidth = localStorage.getItem(props.storageKey)
|
||||||
|
if (savedWidth) {
|
||||||
|
const width = parseInt(savedWidth, 10)
|
||||||
|
if (!isNaN(width) && width >= props.minWidth && width <= props.maxWidth) {
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to load sidebar width from storage with key ${props.storageKey}:`, error)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存宽度到本地存储
|
||||||
|
const saveWidthToStorage = () => {
|
||||||
|
if (collapsed.value || !props.enableStorage) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(props.storageKey, sidebarWidth.value.toString())
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to save sidebar width to storage with key ${props.storageKey}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换侧边栏收起/展开状态
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
collapsed.value = !collapsed.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换展开/折叠所有节点
|
||||||
|
const toggleExpandAll = () => {
|
||||||
|
expandedAll.value = !expandedAll.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开所有节点
|
||||||
|
const expandAllNodes = () => {
|
||||||
|
if (!treeRef.value) return
|
||||||
|
const allNodes = getAllNodes(treeRef.value.root)
|
||||||
|
allNodes.forEach(node => {
|
||||||
|
if (node.expanded !== undefined && !node.expanded) {
|
||||||
|
node.expanded = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有节点
|
||||||
|
const getAllNodes = (rootNode) => {
|
||||||
|
const nodes = []
|
||||||
|
const traverse = (node) => {
|
||||||
|
if (!node) return
|
||||||
|
nodes.push(node)
|
||||||
|
if (node.childNodes && node.childNodes.length) {
|
||||||
|
node.childNodes.forEach(child => traverse(child))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
traverse(rootNode)
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收起所有节点
|
||||||
|
const collapseAllNodes = () => {
|
||||||
|
if (!treeRef.value) return
|
||||||
|
const allNodes = getAllNodes(treeRef.value.root)
|
||||||
|
allNodes.forEach(node => {
|
||||||
|
if (node.expanded !== undefined && node.expanded) {
|
||||||
|
node.expanded = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理刷新操作
|
||||||
|
const handleRefresh = () => {
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点点击事件
|
||||||
|
const onNodeClick = (data, node, e) => {
|
||||||
|
emit('node-click', data, node, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复选框选中事件
|
||||||
|
const onCheck = (data, checkedInfo) => {
|
||||||
|
emit('check', data, checkedInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点展开事件
|
||||||
|
const onNodeExpand = (data, node, e) => {
|
||||||
|
emit('node-expand', data, node, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 节点折叠事件
|
||||||
|
const onNodeCollapse = (data, node, e) => {
|
||||||
|
emit('node-collapse', data, node, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCurrentKey = (key) => {
|
||||||
|
if (treeRef.value) {
|
||||||
|
treeRef.value.setCurrentKey(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentNode = () => {
|
||||||
|
if (treeRef.value) {
|
||||||
|
return treeRef.value.getCurrentNode()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentKey = () => {
|
||||||
|
if (treeRef.value) {
|
||||||
|
return treeRef.value.getCurrentKey()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCheckedKeys = (keys) => {
|
||||||
|
if (treeRef.value && props.showCheckbox) {
|
||||||
|
treeRef.value.setCheckedKeys(keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCheckedKeys = () => {
|
||||||
|
if (treeRef.value && props.showCheckbox) {
|
||||||
|
return treeRef.value.getCheckedKeys()
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCheckedNodes = () => {
|
||||||
|
if (treeRef.value && props.showCheckbox) {
|
||||||
|
return treeRef.value.getCheckedNodes()
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSearch = () => {
|
||||||
|
searchKeyword.value = ""
|
||||||
|
if (treeRef.value) {
|
||||||
|
treeRef.value.filter("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = (value) => {
|
||||||
|
searchKeyword.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const startResize = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
isResizing.value = true
|
||||||
|
startX.value = e.type === 'mousedown' ? e.clientX : e.touches[0].clientX
|
||||||
|
startWidth.value = sidebarWidth.value
|
||||||
|
|
||||||
|
if (e.type === 'mousedown') {
|
||||||
|
document.addEventListener('mousemove', handleResizeMove)
|
||||||
|
document.addEventListener('mouseup', stopResize)
|
||||||
|
} else {
|
||||||
|
document.addEventListener('touchmove', handleResizeMove, { passive: false })
|
||||||
|
document.addEventListener('touchend', stopResize)
|
||||||
|
}
|
||||||
|
disableUserSelect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeMove = (e) => {
|
||||||
|
if (!isResizing.value) return
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value)
|
||||||
|
}
|
||||||
|
rafId.value = requestAnimationFrame(() => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
const clientX = e.type === 'mousemove' ? e.clientX : e.touches[0].clientX
|
||||||
|
const deltaX = clientX - startX.value
|
||||||
|
const newWidth = startWidth.value + deltaX
|
||||||
|
const clampedWidth = Math.max(props.minWidth, Math.min(props.maxWidth, newWidth))
|
||||||
|
if (Math.abs(clampedWidth - sidebarWidth.value) >= 1) {
|
||||||
|
sidebarWidth.value = clampedWidth
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopResize = () => {
|
||||||
|
if (!isResizing.value) return
|
||||||
|
isResizing.value = false
|
||||||
|
if (rafId.value) {
|
||||||
|
cancelAnimationFrame(rafId.value)
|
||||||
|
rafId.value = null
|
||||||
|
}
|
||||||
|
startX.value = 0
|
||||||
|
startWidth.value = 0
|
||||||
|
document.removeEventListener('mousemove', handleResizeMove)
|
||||||
|
document.removeEventListener('mouseup', stopResize)
|
||||||
|
document.removeEventListener('touchmove', handleResizeMove)
|
||||||
|
document.removeEventListener('touchend', stopResize)
|
||||||
|
enableUserSelect()
|
||||||
|
saveWidthToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableUserSelect = () => {
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
document.body.style.webkitUserSelect = 'none'
|
||||||
|
document.body.style.mozUserSelect = 'none'
|
||||||
|
document.body.style.msUserSelect = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableUserSelect = () => {
|
||||||
|
document.body.style.userSelect = ''
|
||||||
|
document.body.style.webkitUserSelect = ''
|
||||||
|
document.body.style.mozUserSelect = ''
|
||||||
|
document.body.style.msUserSelect = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetWidth = () => {
|
||||||
|
sidebarWidth.value = props.defaultWidth
|
||||||
|
saveWidthToStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentWidth = () => {
|
||||||
|
return sidebarWidth.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const setWidth = (width) => {
|
||||||
|
if (typeof width === 'number' && width >= props.minWidth && width <= props.maxWidth) {
|
||||||
|
sidebarWidth.value = width
|
||||||
|
if (!collapsed.value) {
|
||||||
|
saveWidthToStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
setCurrentKey,
|
||||||
|
getCurrentNode,
|
||||||
|
getCurrentKey,
|
||||||
|
setCheckedKeys,
|
||||||
|
getCheckedKeys,
|
||||||
|
getCheckedNodes,
|
||||||
|
clearSearch,
|
||||||
|
filter,
|
||||||
|
resetWidth,
|
||||||
|
getCurrentWidth,
|
||||||
|
setWidth,
|
||||||
|
expandAllNodes,
|
||||||
|
collapseAllNodes,
|
||||||
|
toggleCollapsed,
|
||||||
|
treeRef
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isLoadingFromStorage.value = true
|
||||||
|
if (!collapsed.value && props.enableStorage) {
|
||||||
|
const savedWidth = getSavedWidth()
|
||||||
|
if (savedWidth !== null) {
|
||||||
|
sidebarWidth.value = savedWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
isLoadingFromStorage.value = false
|
||||||
|
})
|
||||||
|
if (expandedAll.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
expandAllNodes()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanup()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tree-sidebar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 220px;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e8eaed;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
width: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.resizing {
|
||||||
|
transition: none;
|
||||||
|
will-change: width;
|
||||||
|
|
||||||
|
* {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.no-initial-transition {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 100%;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 20;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(64, 158, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(64, 158, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 15px;
|
||||||
|
height: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
.tree-sidebar.collapsed & {
|
||||||
|
right: 0;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-sidebar.resizing & {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-button {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #909399;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 10px;
|
||||||
|
height: 40px;
|
||||||
|
border-bottom: 1px solid #e8eaed;
|
||||||
|
background: #f7f8fa;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.tree-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-action-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #909399;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-search {
|
||||||
|
padding: 10px 10px 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-wrap {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 6px 12px;
|
||||||
|
|
||||||
|
.tree-sidebar.resizing & {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #c0c4cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__content) {
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node.is-current > .el-tree-node__content) {
|
||||||
|
background: #e6f0fd;
|
||||||
|
color: #409eff;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
color: #409eff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #f5a623;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
openhis-ui-vue3/src/layout/components/Copyright/index.vue
Normal file
31
openhis-ui-vue3/src/layout/components/Copyright/index.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<footer v-if="visible" class="copyright">
|
||||||
|
<span>{{ content }}</span>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const visible = computed(() => settingsStore.footerVisible)
|
||||||
|
const content = computed(() => settingsStore.footerContent)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.copyright {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 36px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: right;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid #e7e7e7;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="visible" title="公告详情" direction="rtl" size="50%" append-to-body :before-close="handleClose" class="notice-detail-drawer">
|
||||||
|
<div v-loading="loading" class="notice-detail-drawer__body">
|
||||||
|
<div v-if="!detail" class="notice-empty">
|
||||||
|
<el-icon><Document /></el-icon>
|
||||||
|
<span>暂无数据</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="notice-page">
|
||||||
|
<div class="notice-type-wrap">
|
||||||
|
<span v-if="detail.noticeType === '1'" class="notice-type-tag type-notify">
|
||||||
|
<el-icon><Bell /></el-icon> 通知
|
||||||
|
</span>
|
||||||
|
<span v-else-if="detail.noticeType === '2'" class="notice-type-tag type-announce">
|
||||||
|
<el-icon><Message /></el-icon> 公告
|
||||||
|
</span>
|
||||||
|
<span v-else class="notice-type-tag type-notify">
|
||||||
|
<el-icon><Document /></el-icon> 消息
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="notice-title">{{ detail.noticeTitle }}</h1>
|
||||||
|
|
||||||
|
<div class="notice-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
<span>{{ detail.createBy || '—' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon><Clock /></el-icon>
|
||||||
|
<span>{{ detail.createTime || '—' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<span :class="['status-dot', isStatusNormal ? 'status-ok' : 'status-off']"></span>
|
||||||
|
<span>{{ isStatusNormal ? '正常' : '已关闭' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice-divider">
|
||||||
|
<span class="notice-divider-dot"></span>
|
||||||
|
<span class="notice-divider-dot"></span>
|
||||||
|
<span class="notice-divider-dot"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice-body">
|
||||||
|
<div v-if="hasContent" class="notice-content" v-html="detail.noticeContent" />
|
||||||
|
<div v-else class="notice-empty notice-empty--inner">
|
||||||
|
<el-icon><Document /></el-icon> 暂无内容
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { getNotice } from '@/api/system/notice'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const detail = ref(null)
|
||||||
|
|
||||||
|
const isStatusNormal = computed(() => {
|
||||||
|
const status = detail.value && detail.value.status
|
||||||
|
return status === '0' || status === 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasContent = computed(() => {
|
||||||
|
const content = detail.value && detail.value.noticeContent
|
||||||
|
return content != null && String(content).trim() !== ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function open(payload) {
|
||||||
|
let id = null
|
||||||
|
let preset = null
|
||||||
|
if (payload != null && typeof payload === 'object') {
|
||||||
|
id = payload.noticeId
|
||||||
|
if (payload.noticeContent != null) {
|
||||||
|
preset = payload
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id = payload
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
if (preset) {
|
||||||
|
detail.value = preset
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id == null || id === '') {
|
||||||
|
detail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
detail.value = null
|
||||||
|
getNotice(id).then(res => {
|
||||||
|
detail.value = res.data
|
||||||
|
}).catch(() => {
|
||||||
|
detail.value = null
|
||||||
|
}).finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
visible.value = false
|
||||||
|
detail.value = null
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
open
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.notice-page {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 8px 8px 20px;
|
||||||
|
animation: notice-fade-up 0.28s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes notice-fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-type-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-notify {
|
||||||
|
background: #fff8e6;
|
||||||
|
color: #b7791f;
|
||||||
|
border-left: 3px solid #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-announce {
|
||||||
|
background: #e8f5e9;
|
||||||
|
color: #276749;
|
||||||
|
border-left: 3px solid #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a202c;
|
||||||
|
line-height: 1.45;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item .el-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok {
|
||||||
|
background: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-off {
|
||||||
|
background: #e53e3e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-divider::before,
|
||||||
|
.notice-divider::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, #dee2e6, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-divider-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-body {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.85;
|
||||||
|
color: #2d3748;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(p) {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(h1),
|
||||||
|
.notice-content :deep(h2),
|
||||||
|
.notice-content :deep(h3) {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a202c;
|
||||||
|
margin: 1.4em 0 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(h1) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(h2) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(h3) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(a) {
|
||||||
|
color: #3182ce;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(a:hover) {
|
||||||
|
color: #2b6cb0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(ul),
|
||||||
|
.notice-content :deep(ol) {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 0 0 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(li) {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(blockquote) {
|
||||||
|
border-left: 3px solid #cbd5e0;
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: 6px 16px;
|
||||||
|
color: #718096;
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(table) {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(table th),
|
||||||
|
.notice-content :deep(table td) {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 7px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-content :deep(table th) {
|
||||||
|
background: #f7fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: #a0aec0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-empty .el-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-empty--inner {
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-detail-drawer__body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 16px 22px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.notice-detail-drawer {
|
||||||
|
.el-drawer__header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-drawer__body {
|
||||||
|
background: #f5f6f8;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
openhis-ui-vue3/src/layout/components/HeaderNotice/index.vue
Normal file
184
openhis-ui-vue3/src/layout/components/HeaderNotice/index.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<el-popover ref="noticePopover" placement="bottom-end" :width="320" trigger="manual" v-model:visible="noticeVisible" popper-class="notice-popover">
|
||||||
|
<!-- 弹出内容 -->
|
||||||
|
<div class="notice-header">
|
||||||
|
<span class="notice-title">通知公告</span>
|
||||||
|
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="noticeLoading" class="notice-loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="noticeList.length === 0" class="notice-empty">
|
||||||
|
<el-icon style="font-size:24px;display:block;margin-bottom:6px;"><Postcard /></el-icon>
|
||||||
|
暂无公告
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
|
||||||
|
<el-tag size="small" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
|
||||||
|
{{ item.noticeType === '1' ? '通知' : '公告' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="notice-item-title">{{ item.noticeTitle }}</span>
|
||||||
|
<span class="notice-item-date">{{ item.createTime }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 触发器 -->
|
||||||
|
<template #reference>
|
||||||
|
<div class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
|
||||||
|
<svg-icon icon-class="bell" />
|
||||||
|
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
|
||||||
|
<!-- 预览弹窗 -->
|
||||||
|
<notice-detail-view ref="noticeViewRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import NoticeDetailView from './DetailView'
|
||||||
|
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
|
||||||
|
|
||||||
|
const noticePopover = ref(null)
|
||||||
|
const noticeList = ref([])
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const noticeLoading = ref(false)
|
||||||
|
const noticeVisible = ref(false)
|
||||||
|
const noticeLeaveTimer = ref(null)
|
||||||
|
const { proxy } = getCurrentInstance()
|
||||||
|
|
||||||
|
// 加载顶部公告列表
|
||||||
|
function loadNoticeTop() {
|
||||||
|
noticeLoading.value = true
|
||||||
|
listNoticeTop().then(res => {
|
||||||
|
noticeList.value = res.data || []
|
||||||
|
unreadCount.value = res.unreadCount !== undefined ? res.unreadCount : noticeList.value.filter(n => !n.isRead).length
|
||||||
|
}).finally(() => {
|
||||||
|
noticeLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadNoticeTop())
|
||||||
|
|
||||||
|
// 鼠标移入铃铛区域
|
||||||
|
function onNoticeEnter() {
|
||||||
|
clearTimeout(noticeLeaveTimer.value)
|
||||||
|
noticeVisible.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
const popper = noticePopover.value?.popperRef?.contentRef
|
||||||
|
if (popper && !popper._noticeBound) {
|
||||||
|
popper._noticeBound = true
|
||||||
|
popper.addEventListener('mouseenter', () => clearTimeout(noticeLeaveTimer.value))
|
||||||
|
popper.addEventListener('mouseleave', () => {
|
||||||
|
noticeLeaveTimer.value = setTimeout(() => { noticeVisible.value = false }, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鼠标离开铃铛区域
|
||||||
|
function onNoticeLeave() {
|
||||||
|
noticeLeaveTimer.value = setTimeout(() => { noticeVisible.value = false }, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览公告详情
|
||||||
|
function previewNotice(item) {
|
||||||
|
if (!item.isRead) {
|
||||||
|
markNoticeRead(item.noticeId).catch(() => {})
|
||||||
|
const idx = noticeList.value.indexOf(item)
|
||||||
|
if (idx !== -1) noticeList.value[idx] = { ...item, isRead: true }
|
||||||
|
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||||
|
}
|
||||||
|
proxy.$refs["noticeViewRef"].open(item.noticeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全部已读
|
||||||
|
function markAllRead() {
|
||||||
|
const ids = noticeList.value.map(n => n.noticeId).join(',')
|
||||||
|
if (!ids) return
|
||||||
|
markNoticeReadAll(ids).catch(() => {})
|
||||||
|
noticeList.value = noticeList.value.map(n => ({ ...n, isRead: true }))
|
||||||
|
unreadCount.value = 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.notice-trigger {
|
||||||
|
position: relative;
|
||||||
|
transform: translateX(-6px);
|
||||||
|
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
|
||||||
|
.notice-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
right: -3px;
|
||||||
|
background: #f56c6c;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
height: 16px;
|
||||||
|
line-height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
min-width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.notice-popover { padding: 0 !important; }
|
||||||
|
.notice-popover .notice-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: #f7f9fb;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.notice-popover .notice-mark-all {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
|
||||||
|
.notice-popover .notice-loading,
|
||||||
|
.notice-popover .notice-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.notice-popover .notice-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.notice-popover .notice-item:last-child { border-bottom: none; }
|
||||||
|
.notice-popover .notice-item:hover { background: #f7f9fb; }
|
||||||
|
.notice-popover .notice-item.is-read .notice-tag,
|
||||||
|
.notice-popover .notice-item.is-read .notice-item-title,
|
||||||
|
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
|
||||||
|
.notice-popover .notice-tag { flex-shrink: 0; }
|
||||||
|
.notice-popover .notice-item-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.notice-popover .notice-item-date {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
99
openhis-ui-vue3/src/layout/components/TopBar/index.vue
Normal file
99
openhis-ui-vue3/src/layout/components/TopBar/index.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<el-menu class="topbar-menu" :ellipsis="false" :default-active="activeMenu" :active-text-color="theme" mode="horizontal">
|
||||||
|
<sidebar-item :key="route.path + index" v-for="(route, index) in topMenus" :item="route" :base-path="route.path" />
|
||||||
|
|
||||||
|
<el-sub-menu index="more" class="el-sub-menu__hide-arrow" v-if="moreRoutes.length > 0">
|
||||||
|
<template #title>
|
||||||
|
<span>更多菜单</span>
|
||||||
|
</template>
|
||||||
|
<sidebar-item :key="route.path + index" v-for="(route, index) in moreRoutes" :item="route" :base-path="route.path" />
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SidebarItem from '../Sidebar/SidebarItem'
|
||||||
|
import useAppStore from '@/store/modules/app'
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
import usePermissionStore from '@/store/modules/permission'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
|
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
|
||||||
|
const theme = computed(() => settingsStore.theme)
|
||||||
|
const device = computed(() => appStore.device)
|
||||||
|
const activeMenu = computed(() => {
|
||||||
|
const { meta, path } = route
|
||||||
|
if (meta.activeMenu) {
|
||||||
|
return meta.activeMenu
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleNumber = ref(5)
|
||||||
|
const topMenus = computed(() => {
|
||||||
|
return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(0, visibleNumber.value)
|
||||||
|
})
|
||||||
|
const moreRoutes = computed(() => {
|
||||||
|
return permissionStore.sidebarRouters.filter((f) => !f.hidden).slice(visibleNumber.value)
|
||||||
|
})
|
||||||
|
function setVisibleNumber() {
|
||||||
|
const width = document.body.getBoundingClientRect().width / 3
|
||||||
|
visibleNumber.value = Math.max(1, parseInt(width / 85))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', setVisibleNumber)
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', setVisibleNumber)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setVisibleNumber()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* menu item */
|
||||||
|
.topbar-menu.el-menu--horizontal .el-submenu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
|
||||||
|
padding: 0 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-menu.el-menu--horizontal > .el-menu-item {
|
||||||
|
float: left;
|
||||||
|
height: 50px !important;
|
||||||
|
line-height: 50px !important;
|
||||||
|
color: #303133 !important;
|
||||||
|
padding: 0 5px !important;
|
||||||
|
margin: 0 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-sub-menu.is-active .svg-icon, .el-menu-item.is-active .svg-icon + span, .el-sub-menu.is-active .svg-icon + span, .el-sub-menu.is-active .el-sub-menu__title span {
|
||||||
|
color: v-bind(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sub-menu item */
|
||||||
|
.topbar-menu.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
|
||||||
|
float: left;
|
||||||
|
line-height: 50px !important;
|
||||||
|
color: #303133 !important;
|
||||||
|
margin: 0 15px -3px!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* topbar more arrow */
|
||||||
|
.topbar-menu .el-sub-menu .el-sub-menu__icon-arrow {
|
||||||
|
position: static;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-top: 0px;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* menu__title el-menu-item */
|
||||||
|
.topbar-menu.el-menu--horizontal .el-sub-menu__title, .topbar-menu.el-menu--horizontal .el-menu-item {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -32,6 +32,8 @@
|
|||||||
<settings ref="settingRef" />
|
<settings ref="settingRef" />
|
||||||
<!-- 公告弹窗组件 -->
|
<!-- 公告弹窗组件 -->
|
||||||
<notice-popup ref="noticePopupRef" />
|
<notice-popup ref="noticePopupRef" />
|
||||||
|
<!-- 底部版权 -->
|
||||||
|
<Copyright />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ import {useWindowSize} from '@vueuse/core';
|
|||||||
import Sidebar from './components/Sidebar/index.vue';
|
import Sidebar from './components/Sidebar/index.vue';
|
||||||
import {AppMain, Settings, TagsView, Navbar} from './components';
|
import {AppMain, Settings, TagsView, Navbar} from './components';
|
||||||
import NoticePopup from '@/components/NoticePopup/index.vue';
|
import NoticePopup from '@/components/NoticePopup/index.vue';
|
||||||
|
import Copyright from './components/Copyright/index.vue';
|
||||||
|
|
||||||
import useAppStore from '@/store/modules/app';
|
import useAppStore from '@/store/modules/app';
|
||||||
import useSettingsStore from '@/store/modules/settings';
|
import useSettingsStore from '@/store/modules/settings';
|
||||||
|
|||||||
@@ -1,68 +1,73 @@
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import {ElMessage} from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import NProgress from 'nprogress'
|
import NProgress from 'nprogress'
|
||||||
import 'nprogress/nprogress.css'
|
import 'nprogress/nprogress.css'
|
||||||
import {getToken} from '@/utils/auth'
|
import { getToken } from '@/utils/auth'
|
||||||
import {isHttp} from '@/utils/validate'
|
import { isHttp, isPathMatch } from '@/utils/validate'
|
||||||
import {isRelogin} from '@/utils/request'
|
import { isRelogin } from '@/utils/request'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
|
import useLockStore from '@/store/modules/lock'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
import usePermissionStore from '@/store/modules/permission'
|
import usePermissionStore from '@/store/modules/permission'
|
||||||
|
|
||||||
// 全局变量,用于控制公告弹窗只显示一次
|
// 全局变量,用于控制公告弹窗只显示一次
|
||||||
let hasShownNoticePopup = false
|
let hasShownNoticePopup = false
|
||||||
|
|
||||||
NProgress.configure({ showSpinner: false });
|
NProgress.configure({ showSpinner: false })
|
||||||
|
|
||||||
const whiteList = ['/login', '/register'];
|
const whiteList = ['/login', '/register']
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
const isWhiteList = (path) => {
|
||||||
|
return whiteList.some(pattern => isPathMatch(pattern, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from) => {
|
||||||
NProgress.start()
|
NProgress.start()
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
to.meta.title && useSettingsStore().setTitle(to.meta.title)
|
to.meta.title && useSettingsStore().setTitle(to.meta.title)
|
||||||
/* has token*/
|
const isLock = useLockStore().isLock
|
||||||
if (to.path === '/login') {
|
if (to.path === '/login') {
|
||||||
next({ path: '/' })
|
|
||||||
NProgress.done()
|
NProgress.done()
|
||||||
} else if (whiteList.indexOf(to.path) !== -1) {
|
return { path: '/' }
|
||||||
next()
|
}
|
||||||
} else {
|
if (isWhiteList(to.path)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (isLock && to.path !== '/lock') {
|
||||||
|
NProgress.done()
|
||||||
|
return { path: '/lock' }
|
||||||
|
}
|
||||||
|
if (!isLock && to.path === '/lock') {
|
||||||
|
NProgress.done()
|
||||||
|
return { path: '/' }
|
||||||
|
}
|
||||||
if (useUserStore().roles.length === 0) {
|
if (useUserStore().roles.length === 0) {
|
||||||
isRelogin.show = true
|
isRelogin.show = true
|
||||||
// 判断当前用户是否已拉取完user_info信息
|
try {
|
||||||
useUserStore().getInfo().then(() => {
|
await useUserStore().getInfo()
|
||||||
isRelogin.show = false
|
isRelogin.show = false
|
||||||
usePermissionStore().generateRoutes().then(accessRoutes => {
|
const accessRoutes = await usePermissionStore().generateRoutes()
|
||||||
// 根据roles权限生成可访问的路由表
|
|
||||||
accessRoutes.forEach(route => {
|
accessRoutes.forEach(route => {
|
||||||
if (!isHttp(route.path)) {
|
if (!isHttp(route.path)) {
|
||||||
// 检查是否已经存在同名路由
|
|
||||||
if (!router.hasRoute(route.name)) {
|
if (!router.hasRoute(route.name)) {
|
||||||
router.addRoute(route) // 动态添加可访问路由表
|
router.addRoute(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
|
return { ...to, replace: true }
|
||||||
})
|
} catch (err) {
|
||||||
}).catch(err => {
|
await useUserStore().logOut()
|
||||||
useUserStore().logOut().then(() => {
|
|
||||||
ElMessage.error(err)
|
ElMessage.error(err)
|
||||||
next({ path: '/' })
|
return { path: '/' }
|
||||||
})
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
next()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
// 没有token
|
if (isWhiteList(to.path)) {
|
||||||
if (whiteList.indexOf(to.path) !== -1) {
|
return true
|
||||||
// 在免登录白名单,直接进入
|
}
|
||||||
next()
|
|
||||||
} else {
|
|
||||||
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
|
|
||||||
NProgress.done()
|
NProgress.done()
|
||||||
}
|
return `/login?redirect=${to.fullPath}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,7 +79,6 @@ router.afterEach(() => {
|
|||||||
const isLoginPage = router.currentRoute.value.path === '/login'
|
const isLoginPage = router.currentRoute.value.path === '/login'
|
||||||
|
|
||||||
if (token && !isLoginPage && !hasShownNoticePopup) {
|
if (token && !isLoginPage && !hasShownNoticePopup) {
|
||||||
// 延迟显示,确保页面完全加载
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showNoticePopupGlobally()
|
showNoticePopupGlobally()
|
||||||
hasShownNoticePopup = true
|
hasShownNoticePopup = true
|
||||||
@@ -85,7 +89,6 @@ router.afterEach(() => {
|
|||||||
// 全局函数:显示公告弹窗
|
// 全局函数:显示公告弹窗
|
||||||
function showNoticePopupGlobally() {
|
function showNoticePopupGlobally() {
|
||||||
try {
|
try {
|
||||||
// 通过多种方式尝试获取并显示公告弹窗
|
|
||||||
const layouts = document.querySelectorAll('.app-wrapper')
|
const layouts = document.querySelectorAll('.app-wrapper')
|
||||||
for (const layout of layouts) {
|
for (const layout of layouts) {
|
||||||
const noticePopupRef = layout.__vue_app__?.config.globalProperties.$refs?.noticePopupRef
|
const noticePopupRef = layout.__vue_app__?.config.globalProperties.$refs?.noticePopupRef
|
||||||
@@ -94,8 +97,6 @@ function showNoticePopupGlobally() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果直接获取失败,尝试通过事件总线方式
|
|
||||||
window.dispatchEvent(new CustomEvent('show-notice-popup'))
|
window.dispatchEvent(new CustomEvent('show-notice-popup'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('显示公告弹窗失败:', error)
|
console.error('显示公告弹窗失败:', error)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export const constantRoutes = [
|
|||||||
component: () => import('@/views/error/401'),
|
component: () => import('@/views/error/401'),
|
||||||
hidden: true
|
hidden: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/lock',
|
||||||
|
component: () => import('@/views/lock'),
|
||||||
|
hidden: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: Layout,
|
component: Layout,
|
||||||
|
|||||||
@@ -47,5 +47,15 @@ export default {
|
|||||||
* The default is only used in the production env
|
* The default is only used in the production env
|
||||||
* If you want to also use it in dev, you can pass ['production', 'development']
|
* If you want to also use it in dev, you can pass ['production', 'development']
|
||||||
*/
|
*/
|
||||||
errorLog: 'production'
|
errorLog: 'production',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否显示底部版权
|
||||||
|
*/
|
||||||
|
footerVisible: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底部版权内容
|
||||||
|
*/
|
||||||
|
footerContent: 'Copyright © 2018-2026 OpenHIS. All Rights Reserved.'
|
||||||
}
|
}
|
||||||
27
openhis-ui-vue3/src/store/modules/lock.js
Normal file
27
openhis-ui-vue3/src/store/modules/lock.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const LOCK_KEY = 'screen-lock'
|
||||||
|
const LOCK_PATH_KEY = 'screen-lock-path'
|
||||||
|
|
||||||
|
export const useLockStore = defineStore('lock', {
|
||||||
|
state: () => ({
|
||||||
|
isLock: JSON.parse(localStorage.getItem(LOCK_KEY) || 'false'),
|
||||||
|
lockPath: localStorage.getItem(LOCK_PATH_KEY) || '/index'
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
// 锁定屏幕,同时记录当前路径
|
||||||
|
lockScreen(currentPath) {
|
||||||
|
this.lockPath = currentPath || '/index'
|
||||||
|
localStorage.setItem(LOCK_PATH_KEY, this.lockPath)
|
||||||
|
this.isLock = true
|
||||||
|
localStorage.setItem(LOCK_KEY, 'true')
|
||||||
|
},
|
||||||
|
// 解锁屏幕,清除路径
|
||||||
|
unlockScreen() {
|
||||||
|
this.isLock = false
|
||||||
|
localStorage.setItem(LOCK_KEY, 'false')
|
||||||
|
this.lockPath = '/index'
|
||||||
|
localStorage.setItem(LOCK_PATH_KEY, '/index')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default useLockStore
|
||||||
@@ -17,7 +17,9 @@ const useSettingsStore = defineStore(
|
|||||||
tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
|
tagsView: storageSetting.tagsView === undefined ? tagsView : storageSetting.tagsView,
|
||||||
fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
|
fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
|
||||||
sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,
|
sidebarLogo: storageSetting.sidebarLogo === undefined ? sidebarLogo : storageSetting.sidebarLogo,
|
||||||
dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle
|
dynamicTitle: storageSetting.dynamicTitle === undefined ? dynamicTitle : storageSetting.dynamicTitle,
|
||||||
|
footerVisible: storageSetting.footerVisible === undefined ? footerVisible : storageSetting.footerVisible,
|
||||||
|
footerContent: storageSetting.footerContent === undefined ? footerContent : storageSetting.footerContent
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
// 修改布局设置
|
// 修改布局设置
|
||||||
|
|||||||
@@ -1,3 +1,26 @@
|
|||||||
|
import cache from '@/plugins/cache'
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
|
const PERSIST_KEY = 'tags-view-visited'
|
||||||
|
|
||||||
|
function isPersistEnabled() {
|
||||||
|
return useSettingsStore().tagsViewPersist
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVisitedViews(views) {
|
||||||
|
if (!isPersistEnabled()) return
|
||||||
|
const toSave = views.filter(v => !(v.meta && v.meta.affix)).map(v => ({ path: v.path, fullPath: v.fullPath, name: v.name, title: v.title, query: v.query, meta: v.meta }))
|
||||||
|
cache.local.setJSON(PERSIST_KEY, toSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVisitedViews() {
|
||||||
|
return cache.local.getJSON(PERSIST_KEY) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVisitedViews() {
|
||||||
|
cache.local.remove(PERSIST_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
const useTagsViewStore = defineStore(
|
const useTagsViewStore = defineStore(
|
||||||
'tags-view',
|
'tags-view',
|
||||||
{
|
{
|
||||||
@@ -26,14 +49,15 @@ const useTagsViewStore = defineStore(
|
|||||||
title: view.meta.title || 'no-name'
|
title: view.meta.title || 'no-name'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
if(this.visitedViews.length==2){
|
saveVisitedViews(this.visitedViews)
|
||||||
sessionStorage.setItem('visitedViews',this.visitedViews[1].name)
|
},
|
||||||
if(this.visitedViews[1].query.supplyBusNo){ // 编辑
|
addAffixView(view) {
|
||||||
sessionStorage.setItem('visitedViewsQuery',this.visitedViews[1].query.supplyBusNo)
|
if (this.visitedViews.some(v => v.path === view.path)) return
|
||||||
}else{
|
this.visitedViews.unshift(
|
||||||
sessionStorage.setItem('visitedViewsQuery',"")
|
Object.assign({}, view, {
|
||||||
}
|
title: view.meta.title || 'no-name'
|
||||||
}
|
})
|
||||||
|
)
|
||||||
},
|
},
|
||||||
addCachedView(view) {
|
addCachedView(view) {
|
||||||
if (this.cachedViews.includes(view.name)) return
|
if (this.cachedViews.includes(view.name)) return
|
||||||
@@ -60,6 +84,7 @@ const useTagsViewStore = defineStore(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
|
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
|
||||||
|
saveVisitedViews(this.visitedViews)
|
||||||
resolve([...this.visitedViews])
|
resolve([...this.visitedViews])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -92,6 +117,7 @@ const useTagsViewStore = defineStore(
|
|||||||
return v.meta.affix || v.path === view.path
|
return v.meta.affix || v.path === view.path
|
||||||
})
|
})
|
||||||
this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
|
this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
|
||||||
|
saveVisitedViews(this.visitedViews)
|
||||||
resolve([...this.visitedViews])
|
resolve([...this.visitedViews])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -121,6 +147,7 @@ const useTagsViewStore = defineStore(
|
|||||||
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
|
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
|
||||||
this.visitedViews = affixTags
|
this.visitedViews = affixTags
|
||||||
this.iframeViews = []
|
this.iframeViews = []
|
||||||
|
clearVisitedViews()
|
||||||
resolve([...this.visitedViews])
|
resolve([...this.visitedViews])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -158,6 +185,7 @@ const useTagsViewStore = defineStore(
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
saveVisitedViews(this.visitedViews)
|
||||||
resolve([...this.visitedViews])
|
resolve([...this.visitedViews])
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -181,8 +209,16 @@ const useTagsViewStore = defineStore(
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
saveVisitedViews(this.visitedViews)
|
||||||
resolve([...this.visitedViews])
|
resolve([...this.visitedViews])
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
// 恢复持久化的 tags
|
||||||
|
loadPersistedViews() {
|
||||||
|
const views = loadVisitedViews()
|
||||||
|
views.forEach(view => {
|
||||||
|
this.addVisitedView(view)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
73
openhis-ui-vue3/src/utils/passwordRule.js
Normal file
73
openhis-ui-vue3/src/utils/passwordRule.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 密码强度规则
|
||||||
|
* 根据参数 chrtype 动态生成校验规则
|
||||||
|
*
|
||||||
|
* chrtype 说明:
|
||||||
|
* 0 - 任意字符(默认)
|
||||||
|
* 1 - 纯数字(0-9)
|
||||||
|
* 2 - 纯字母(a-z / A-Z)
|
||||||
|
* 3 - 字母 + 数字(必须同时包含)
|
||||||
|
* 4 - 字母 + 数字 + 特殊字符(必须同时包含,特殊字符:~!@#$%^&*()-=_+)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import cache from '@/plugins/cache'
|
||||||
|
|
||||||
|
// 密码限制类型
|
||||||
|
const pwdChrType = ref(cache.session.get('pwrChrtype') || '0')
|
||||||
|
|
||||||
|
// 各类型对应的正则、错误提示
|
||||||
|
const PWD_RULES = {
|
||||||
|
'0': { pattern: /^[^<>"'|\\]+$/, message: '密码不能包含非法字符:< > " \' \\ |' },
|
||||||
|
'1': { pattern: /^[0-9]+$/, message: '密码只能为数字(0-9)' },
|
||||||
|
'2': { pattern: /^[a-zA-Z]+$/, message: '密码只能为英文字母(a-z、A-Z)' },
|
||||||
|
'3': { pattern: /^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]+$/, message: '密码必须同时包含字母和数字' },
|
||||||
|
'4': { pattern: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[~!@#$%^&*()\-=_+])[A-Za-z\d~!@#$%^&*()\-=_+]+$/, message: '密码必须同时包含字母、数字和特殊字符(~!@#$%^&*()-=_+)' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePasswordRule() {
|
||||||
|
// 默认密码校验
|
||||||
|
const pwdValidator = computed(() => {
|
||||||
|
const rule = PWD_RULES[pwdChrType.value] || PWD_RULES['0']
|
||||||
|
return [
|
||||||
|
{ required: true, message: '密码不能为空', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 20, message: '密码长度必须介于 6 和 20 之间', trigger: 'blur' },
|
||||||
|
{ pattern: rule.pattern, message: rule.message, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
// 校验prompt的inputValidator函数
|
||||||
|
const pwdPromptValidator = (value) => {
|
||||||
|
const rule = PWD_RULES['0']
|
||||||
|
if (!value || value.length < 6 || value.length > 20) {
|
||||||
|
return '密码长度必须介于 6 和 20 之间'
|
||||||
|
}
|
||||||
|
if (!rule.pattern.test(value)) {
|
||||||
|
return rule.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 个人中心密码校验
|
||||||
|
const infoPwdValidator = computed(() => {
|
||||||
|
const rule = PWD_RULES[pwdChrType.value] || PWD_RULES['0']
|
||||||
|
return [
|
||||||
|
{ required: true, message: '新密码不能为空', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 20, message: '新密码长度必须介于 6 和 20 之间', trigger: 'blur' },
|
||||||
|
{ pattern: rule.pattern, message: rule.message, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
// 注册页面密码校验
|
||||||
|
const registerPwdValidator = computed(() => {
|
||||||
|
const rule = PWD_RULES['0']
|
||||||
|
return [
|
||||||
|
{ required: true, message: '请输入您的密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 20, message: '用户密码长度必须介于 6 和 20 之间', trigger: 'blur' },
|
||||||
|
{ pattern: rule.pattern, message: rule.message, trigger: 'blur' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
pwdChrType,
|
||||||
|
pwdValidator,
|
||||||
|
infoPwdValidator,
|
||||||
|
pwdPromptValidator,
|
||||||
|
registerPwdValidator
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,3 +125,32 @@ export function getGenderAndAge(idCard) {
|
|||||||
const gender = idCard.charAt(16) % 2 === 0 ? 2 : 1;
|
const gender = idCard.charAt(16) % 2 === 0 ? 2 : 1;
|
||||||
return { age, gender };
|
return { age, gender };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路径匹配器(支持通配符 * 和 **)
|
||||||
|
* @param {string} pattern 匹配模式,如 /user/* 或 /api/**
|
||||||
|
* @param {string} path 实际路径
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
export function isPathMatch(pattern, path) {
|
||||||
|
const regexPattern = pattern
|
||||||
|
.replace(/([.+^${}()|\[\]\\])/g, '\\$1')
|
||||||
|
.replace(/\*\*/g, '__DOUBLE_STAR__')
|
||||||
|
.replace(/\*/g, '[^/]*')
|
||||||
|
.replace(/__DOUBLE_STAR__/g, '.*')
|
||||||
|
.replace(/\?/g, '[^/]')
|
||||||
|
const regex = new RegExp(`^${regexPattern}$`)
|
||||||
|
return regex.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断value字符串是否为空
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {Boolean}
|
||||||
|
*/
|
||||||
|
export function isEmpty(value) {
|
||||||
|
if (value == null || value == "" || value == undefined || value == "undefined") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
374
openhis-ui-vue3/src/views/lock.vue
Normal file
374
openhis-ui-vue3/src/views/lock.vue
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lock-container">
|
||||||
|
<!-- 动态粒子背景 -->
|
||||||
|
<canvas ref="particleCanvas" class="particle-bg"></canvas>
|
||||||
|
|
||||||
|
<!-- 时钟 -->
|
||||||
|
<div class="lock-time">{{ currentTime }}</div>
|
||||||
|
<div class="lock-date">{{ currentDate }}</div>
|
||||||
|
|
||||||
|
<!-- 锁屏卡片 -->
|
||||||
|
<div class="lock-card">
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<img :src="userStore.avatar" class="lock-avatar" @error="onAvatarError" />
|
||||||
|
<div class="lock-icon">🔒</div>
|
||||||
|
</div>
|
||||||
|
<div class="lock-username">{{ userStore.nickName }}</div>
|
||||||
|
<div class="lock-hint">系统已锁定,请输入密码解锁</div>
|
||||||
|
|
||||||
|
<div class="input-wrap" :class="{ shake: isShaking }">
|
||||||
|
<input ref="passwordInput" v-model="password" type="password" placeholder="请输入登录密码" class="lock-input" @keydown.enter="handleUnlock" autocomplete="off" />
|
||||||
|
<button class="unlock-btn" @click="handleUnlock" :disabled="loading">
|
||||||
|
<span v-if="!loading">→</span>
|
||||||
|
<span v-else class="loading-dot">···</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<div class="lock-footer">
|
||||||
|
<a href="javascript:;" @click="goLogin">退出重新登录</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import useUserStore from '@/store/modules/user'
|
||||||
|
import useLockStore from '@/store/modules/lock'
|
||||||
|
import { unlockScreen } from '@/api/login'
|
||||||
|
import defAva from '@/assets/images/profile.jpg'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const lockStore = useLockStore()
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const isShaking = ref(false)
|
||||||
|
const currentTime = ref('')
|
||||||
|
const currentDate = ref('')
|
||||||
|
const passwordInput = ref(null)
|
||||||
|
const particleCanvas = ref(null)
|
||||||
|
|
||||||
|
let timer = null
|
||||||
|
let animationId = null
|
||||||
|
let particles = []
|
||||||
|
|
||||||
|
const onAvatarError = (e) => {
|
||||||
|
e.target.src = defAva
|
||||||
|
}
|
||||||
|
|
||||||
|
const startClock = () => {
|
||||||
|
const update = () => {
|
||||||
|
const now = new Date()
|
||||||
|
const pad = n => String(n).padStart(2, '0')
|
||||||
|
currentTime.value = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||||
|
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
|
currentDate.value = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日 ${days[now.getDay()]}`
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
timer = setInterval(update, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnlock = async () => {
|
||||||
|
if (!password.value) {
|
||||||
|
showError('请输入密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
await unlockScreen(password.value)
|
||||||
|
const lockPath = lockStore.lockPath
|
||||||
|
lockStore.unlockScreen()
|
||||||
|
router.replace(lockPath)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.message || err.toString()
|
||||||
|
showError(msg)
|
||||||
|
password.value = ''
|
||||||
|
nextTick(() => passwordInput.value?.focus())
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showError = (msg) => {
|
||||||
|
errorMsg.value = msg
|
||||||
|
isShaking.value = true
|
||||||
|
setTimeout(() => { isShaking.value = false }, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goLogin = () => {
|
||||||
|
lockStore.unlockScreen()
|
||||||
|
userStore.logOut().then(() => {
|
||||||
|
router.push('/login')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initParticles = () => {
|
||||||
|
const canvas = particleCanvas.value
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = window.innerWidth
|
||||||
|
canvas.height = window.innerHeight
|
||||||
|
}
|
||||||
|
resize()
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
|
||||||
|
particles = Array.from({ length: 80 }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
r: Math.random() * 2 + 1,
|
||||||
|
dx: (Math.random() - 0.5) * 0.6,
|
||||||
|
dy: (Math.random() - 0.5) * 0.6,
|
||||||
|
alpha: Math.random() * 0.5 + 0.2
|
||||||
|
}))
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
particles.forEach(p => {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${p.alpha})`
|
||||||
|
ctx.fill()
|
||||||
|
p.x += p.dx
|
||||||
|
p.y += p.dy
|
||||||
|
if (p.x < 0 || p.x > canvas.width) p.dx *= -1
|
||||||
|
if (p.y < 0 || p.y > canvas.height) p.dy *= -1
|
||||||
|
})
|
||||||
|
for (let i = 0; i < particles.length; i++) {
|
||||||
|
for (let j = i + 1; j < particles.length; j++) {
|
||||||
|
const a = particles[i], b = particles[j]
|
||||||
|
const dist = Math.hypot(a.x - b.x, a.y - b.y)
|
||||||
|
if (dist < 120) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(a.x, a.y)
|
||||||
|
ctx.lineTo(b.x, b.y)
|
||||||
|
ctx.strokeStyle = `rgba(255,255,255,${0.15 * (1 - dist / 120)})`
|
||||||
|
ctx.lineWidth = 0.5
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationId = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startClock()
|
||||||
|
initParticles()
|
||||||
|
nextTick(() => passwordInput.value?.focus())
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timer)
|
||||||
|
cancelAnimationFrame(animationId)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 样式与原文件完全一致,无需改动 */
|
||||||
|
.lock-container {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-time {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 72px;
|
||||||
|
font-weight: 200;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-shadow: 0 0 40px rgba(255,255,255,0.3);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-date {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
margin-bottom: 48px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 40px 48px;
|
||||||
|
width: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 25px 60px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-avatar {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255,255,255,0.3);
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-icon {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -4px;
|
||||||
|
right: -4px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-username {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-hint {
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
border-radius: 50px;
|
||||||
|
padding: 4px 4px 4px 20px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap:focus-within {
|
||||||
|
border-color: rgba(255,255,255,0.6);
|
||||||
|
background: rgba(255,255,255,0.13);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrap.shake {
|
||||||
|
animation: shake 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
20% { transform: translateX(-8px); }
|
||||||
|
40% { transform: translateX(8px); }
|
||||||
|
60% { transform: translateX(-6px); }
|
||||||
|
80% { transform: translateX(6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-input::placeholder {
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:hover:not(:disabled) {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unlock-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #ff7675;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
animation: fadeIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-footer a {
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-footer a:hover {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user