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:
@@ -96,4 +96,12 @@ export function sign(practitionerId, mac, ip) {
|
||||
url: `/yb-request/sign?practitionerId=${practitionerId}&mac=${mac}&ip=${ip}`,
|
||||
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" />
|
||||
<!-- 公告弹窗组件 -->
|
||||
<notice-popup ref="noticePopupRef" />
|
||||
<!-- 底部版权 -->
|
||||
<Copyright />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,6 +42,7 @@ import {useWindowSize} from '@vueuse/core';
|
||||
import Sidebar from './components/Sidebar/index.vue';
|
||||
import {AppMain, Settings, TagsView, Navbar} from './components';
|
||||
import NoticePopup from '@/components/NoticePopup/index.vue';
|
||||
import Copyright from './components/Copyright/index.vue';
|
||||
|
||||
import useAppStore from '@/store/modules/app';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
|
||||
@@ -1,80 +1,84 @@
|
||||
import router from './router'
|
||||
import {ElMessage} from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import {getToken} from '@/utils/auth'
|
||||
import {isHttp} from '@/utils/validate'
|
||||
import {isRelogin} from '@/utils/request'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { isHttp, isPathMatch } from '@/utils/validate'
|
||||
import { isRelogin } from '@/utils/request'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
import useLockStore from '@/store/modules/lock'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import usePermissionStore from '@/store/modules/permission'
|
||||
|
||||
// 全局变量,用于控制公告弹窗只显示一次
|
||||
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()
|
||||
if (getToken()) {
|
||||
to.meta.title && useSettingsStore().setTitle(to.meta.title)
|
||||
/* has token*/
|
||||
const isLock = useLockStore().isLock
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
NProgress.done()
|
||||
} else if (whiteList.indexOf(to.path) !== -1) {
|
||||
next()
|
||||
} else {
|
||||
if (useUserStore().roles.length === 0) {
|
||||
isRelogin.show = true
|
||||
// 判断当前用户是否已拉取完user_info信息
|
||||
useUserStore().getInfo().then(() => {
|
||||
isRelogin.show = false
|
||||
usePermissionStore().generateRoutes().then(accessRoutes => {
|
||||
// 根据roles权限生成可访问的路由表
|
||||
accessRoutes.forEach(route => {
|
||||
if (!isHttp(route.path)) {
|
||||
// 检查是否已经存在同名路由
|
||||
if (!router.hasRoute(route.name)) {
|
||||
router.addRoute(route) // 动态添加可访问路由表
|
||||
}
|
||||
}
|
||||
})
|
||||
next({ ...to, replace: true }) // hack方法 确保addRoutes已完成
|
||||
})
|
||||
}).catch(err => {
|
||||
useUserStore().logOut().then(() => {
|
||||
ElMessage.error(err)
|
||||
next({ path: '/' })
|
||||
})
|
||||
return { path: '/' }
|
||||
}
|
||||
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) {
|
||||
isRelogin.show = true
|
||||
try {
|
||||
await useUserStore().getInfo()
|
||||
isRelogin.show = false
|
||||
const accessRoutes = await usePermissionStore().generateRoutes()
|
||||
accessRoutes.forEach(route => {
|
||||
if (!isHttp(route.path)) {
|
||||
if (!router.hasRoute(route.name)) {
|
||||
router.addRoute(route)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
next()
|
||||
return { ...to, replace: true }
|
||||
} catch (err) {
|
||||
await useUserStore().logOut()
|
||||
ElMessage.error(err)
|
||||
return { path: '/' }
|
||||
}
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
// 没有token
|
||||
if (whiteList.indexOf(to.path) !== -1) {
|
||||
// 在免登录白名单,直接进入
|
||||
next()
|
||||
} else {
|
||||
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
|
||||
NProgress.done()
|
||||
if (isWhiteList(to.path)) {
|
||||
return true
|
||||
}
|
||||
NProgress.done()
|
||||
return `/login?redirect=${to.fullPath}`
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
|
||||
|
||||
// 登录成功后显示公告弹窗(仅限非登录页面且未显示过)
|
||||
const token = getToken()
|
||||
const isLoginPage = router.currentRoute.value.path === '/login'
|
||||
|
||||
|
||||
if (token && !isLoginPage && !hasShownNoticePopup) {
|
||||
// 延迟显示,确保页面完全加载
|
||||
setTimeout(() => {
|
||||
showNoticePopupGlobally()
|
||||
hasShownNoticePopup = true
|
||||
@@ -85,7 +89,6 @@ router.afterEach(() => {
|
||||
// 全局函数:显示公告弹窗
|
||||
function showNoticePopupGlobally() {
|
||||
try {
|
||||
// 通过多种方式尝试获取并显示公告弹窗
|
||||
const layouts = document.querySelectorAll('.app-wrapper')
|
||||
for (const layout of layouts) {
|
||||
const noticePopupRef = layout.__vue_app__?.config.globalProperties.$refs?.noticePopupRef
|
||||
@@ -94,10 +97,8 @@ function showNoticePopupGlobally() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接获取失败,尝试通过事件总线方式
|
||||
window.dispatchEvent(new CustomEvent('show-notice-popup'))
|
||||
} catch (error) {
|
||||
console.error('显示公告弹窗失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ export const constantRoutes = [
|
||||
component: () => import('@/views/error/401'),
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
path: '/lock',
|
||||
component: () => import('@/views/lock'),
|
||||
hidden: true
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
component: Layout,
|
||||
|
||||
@@ -47,5 +47,15 @@ export default {
|
||||
* The default is only used in the production env
|
||||
* 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,
|
||||
fixedHeader: storageSetting.fixedHeader === undefined ? fixedHeader : storageSetting.fixedHeader,
|
||||
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: {
|
||||
// 修改布局设置
|
||||
|
||||
@@ -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(
|
||||
'tags-view',
|
||||
{
|
||||
@@ -26,14 +49,15 @@ const useTagsViewStore = defineStore(
|
||||
title: view.meta.title || 'no-name'
|
||||
})
|
||||
)
|
||||
if(this.visitedViews.length==2){
|
||||
sessionStorage.setItem('visitedViews',this.visitedViews[1].name)
|
||||
if(this.visitedViews[1].query.supplyBusNo){ // 编辑
|
||||
sessionStorage.setItem('visitedViewsQuery',this.visitedViews[1].query.supplyBusNo)
|
||||
}else{
|
||||
sessionStorage.setItem('visitedViewsQuery',"")
|
||||
}
|
||||
}
|
||||
saveVisitedViews(this.visitedViews)
|
||||
},
|
||||
addAffixView(view) {
|
||||
if (this.visitedViews.some(v => v.path === view.path)) return
|
||||
this.visitedViews.unshift(
|
||||
Object.assign({}, view, {
|
||||
title: view.meta.title || 'no-name'
|
||||
})
|
||||
)
|
||||
},
|
||||
addCachedView(view) {
|
||||
if (this.cachedViews.includes(view.name)) return
|
||||
@@ -60,6 +84,7 @@ const useTagsViewStore = defineStore(
|
||||
}
|
||||
}
|
||||
this.iframeViews = this.iframeViews.filter(item => item.path !== view.path)
|
||||
saveVisitedViews(this.visitedViews)
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
@@ -92,6 +117,7 @@ const useTagsViewStore = defineStore(
|
||||
return v.meta.affix || v.path === view.path
|
||||
})
|
||||
this.iframeViews = this.iframeViews.filter(item => item.path === view.path)
|
||||
saveVisitedViews(this.visitedViews)
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
@@ -121,6 +147,7 @@ const useTagsViewStore = defineStore(
|
||||
const affixTags = this.visitedViews.filter(tag => tag.meta.affix)
|
||||
this.visitedViews = affixTags
|
||||
this.iframeViews = []
|
||||
clearVisitedViews()
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
@@ -158,6 +185,7 @@ const useTagsViewStore = defineStore(
|
||||
}
|
||||
return false
|
||||
})
|
||||
saveVisitedViews(this.visitedViews)
|
||||
resolve([...this.visitedViews])
|
||||
})
|
||||
},
|
||||
@@ -181,8 +209,16 @@ const useTagsViewStore = defineStore(
|
||||
}
|
||||
return false
|
||||
})
|
||||
saveVisitedViews(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;
|
||||
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