【前端】新增a-card一样的高度自适应组件、更新左右分离组件、自定义授权取消联动

This commit is contained in:
俞宝山 2025-12-09 18:19:10 +08:00
parent f585b8dc6d
commit 02d1198a96
5 changed files with 417 additions and 16 deletions

View File

@ -0,0 +1,122 @@
XnPanel 面板容器
====
封装说明
----
> 与 Ant Design Vue 的 `a-card` 类似,用于承载页面内容的通用面板容器。
>
> 特性:支持系统暗黑主题、默认主体 `padding: 24px`、高度自适应父容器、内容溢出内部滚动且隐藏滚动条、可控阴影/圆角/分割线(默认右对齐的 footer以及容器外部底部留白避免贴底圆角可跟随系统设置自动调整。
例子1
----
(基础使用)
```vue
<template>
<xn-panel title="标题">
内容区域
</xn-panel>
</template>
<script setup>
import XnPanel from '@/components/XnPanel/'
</script>
```
例子2
----
(标题扩展与底部操作)
```vue
<template>
<xn-panel title="项目概览">
<template #extra>
<a-space>
<a-button type="link">刷新</a-button>
<a-button type="primary">新增</a-button>
</a-space>
</template>
主体内容...
<template #footer>
<a-space>
<a-button>取消</a-button>
<a-button type="primary">提交</a-button>
</a-space>
</template>
</xn-panel>
</template>
```
例子3
----
(无阴影、圆角与分割线控制、底部留白、系统圆角)
```vue
<template>
<xn-panel
:shadow="false"
radius="system"
:headerDivider="false"
:footerDivider="true"
:bottomGap="16"
:padding="24"
>
内容较多时将内部滚动,滚动条隐藏。
</xn-panel>
</template>
```
内置属性
----
| 属性 | 说明 | 类型 | 默认值 |
|----------------|--------------------------------------------|------------------|-------------|
| title | 标题文本(也可用 `title` 插槽替代) | String | '' |
| bordered | 是否显示外边框 | Boolean | true |
| padding | 主体内边距Number 自动转 px或 String | Number/String | 24 |
| headerPadding | 头部内边距 | Number/String | 0 |
| footerPadding | 底部内边距 | Number/String | 10 |
| shadow | 是否显示阴影 | Boolean | false |
| radius | 圆角Number/String`system` 跟随系统) | Number/String | 'system' |
| headerDivider | 头部分割线开关 | Boolean | true |
| footerDivider | 底部分割线开关 | Boolean | true |
| bottomGap | 容器外部底部留白(避免卡片贴底) | Number/String | 10 |
插槽
----
| 插槽名 | 说明 |
|---------|------------------------|
| title | 标题区域(替代 `title` 属性) |
| extra | 标题右侧扩展区 |
| 默认 | 主体内容 |
| footer | 底部区域(操作/信息) |
注意事项
----
> 1. `bottomGap` 通过在容器外部设置 `margin-bottom` 实现,并同步减少容器高度,确保不与父容器尺寸冲突。
>
> 2. 若希望面板内部滚动生效,请确保父容器有明确的高度约束(例如页面布局使用 `flex` 或固定高度)。
>
> 3. 暗黑主题依赖项目的 CSS 变量:`--snowy-background-color``--component-background``--border-color-split``--card-shadow-soft``--heading-color` 等。
>
> 4. 圆角跟随系统:当系统的“圆角风格”开启时,组件圆角为 `8px`,关闭时为 `2px`;也可通过 `radius` 指定具体值进行覆盖。
>
> 5. footer 默认右对齐,且背景与分割线使用与整体保持一致的主题变量(背景:`--snowy-background-color`,分割线:`--border-color-split`)。
更新时间
----
该文档最后更新于: 2025-11-30

View File

@ -0,0 +1,154 @@
<template>
<div class="xn-panel" :class="{ 'xn-panel-bordered': bordered }" :style="wrapperStyle">
<!-- 头部支持标题与右侧扩展区 -->
<div v-if="hasHeader" class="xn-panel-header" :style="headerStyle">
<div class="xn-panel-title">
<slot name="title">{{ title }}</slot>
</div>
<div class="xn-panel-extra">
<slot name="extra"></slot>
</div>
</div>
<!-- 主体自动占满剩余空间超出时内部滚动且隐藏滚动条 -->
<div class="xn-panel-body" :style="bodyStyle">
<slot></slot>
</div>
<!-- 底部常用于操作区或信息展示 -->
<div v-if="hasFooter" class="xn-panel-footer" :style="footerStyle">
<slot name="footer"></slot>
</div>
</div>
</template>
<script setup name="xnPanel">
import { computed, useSlots } from 'vue'
import { globalStore } from '@/store'
// XnPanel
const props = defineProps({
// title
title: { type: String, default: '' },
//
bordered: { type: Boolean, default: false },
// Number pxString 使
padding: { type: [Number, String], default: 24 },
//
headerPadding: { type: [Number, String], default: 0 },
//
footerPadding: { type: [Number, String], default: 10 },
//
shadow: { type: Boolean, default: true },
// Number/px
radius: { type: [Number, String], default: 'system' },
// 线
headerDivider: { type: Boolean, default: true },
// 线
footerDivider: { type: Boolean, default: true },
//
bottomGap: { type: [Number, String], default: 10 }
})
const slots = useSlots()
const store = globalStore()
const systemRadius = computed(() => (store.roundedCornerStyleOpen ? 8 : 2))
// title title/extra
const hasHeader = computed(() => !!props.title || !!slots?.title || !!slots?.extra)
// footer
const hasFooter = computed(() => !!slots?.footer)
// px
const toPx = (val) => (typeof val === 'number' ? `${val}px` : val)
const headerStyle = computed(() => ({
padding: toPx(props.headerPadding),
borderBottom: props.headerDivider ? '1px solid var(--border-color-base)' : 'none'
}))
const footerStyle = computed(() => ({
padding: toPx(props.footerPadding),
borderTop: props.footerDivider ? '1px solid var(--border-color-split)' : 'none',
background: 'var(--snowy-background-color)'
}))
const bodyStyle = computed(() => ({
// padding
padding: toPx(props.padding)
}))
// bottomGap
const wrapperStyle = computed(() => {
const gap = toPx(props.bottomGap)
return {
borderRadius: toPx(props.radius === 'system' ? systemRadius.value : props.radius),
boxShadow: props.shadow ? 'var(--card-shadow-soft, 0 1px 6px rgba(0, 0, 0, 0.06))' : 'none',
//
marginBottom: gap,
//
height: `calc(100% - ${gap})`
}
})
</script>
<style scoped>
.xn-panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
/* 背景颜色跟随系统暗黑主题变量 */
background: var(--snowy-background-color);
overflow: hidden;
}
.xn-panel-bordered {
/* 外边框颜色跟随系统变量 */
border: 1px solid var(--border-color-split);
}
.xn-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 48px;
padding: 0;
/* 分割线受 headerDivider 控制,默认展示 */
border-bottom: 1px solid var(--border-color-base);
}
.xn-panel-title {
padding: 0 24px;
color: var(--heading-color);
font-size: 16px;
font-weight: 500;
}
.xn-panel-extra {
padding: 0 24px;
display: flex;
align-items: center;
gap: 8px;
}
.xn-panel-body {
flex: 1;
overflow: auto;
/* 主体背景:更浅的背景层 */
background: var(--snowy-background-color);
scrollbar-width: none;
-ms-overflow-style: none;
}
.xn-panel-body::-webkit-scrollbar {
/* 隐藏滚动条但保留滚动行为 */
display: none;
}
.xn-panel-footer {
/* 分割线受 footerDivider 控制,默认展示 */
border-top: 1px solid var(--border-color-split);
display: flex;
justify-content: flex-end;
align-items: center;
background: var(--snowy-background-color);
}
</style>

View File

@ -1,11 +1,12 @@
<template>
<div class="resizable-panel" :style="{ display: 'flex', flexDirection: direction }">
<div class="resizable-panel" :style="wrapperStyle" ref="rootRef">
<div
class="panel-left"
:style="{
[sizeProperty]: leftSize + 'px',
minWidth: direction === 'row' ? minSize + 'px' : 'auto',
minHeight: direction === 'column' ? minSize + 'px' : 'auto'
minHeight: direction === 'column' ? minSize + 'px' : 'auto',
padding: toPx(leftPadding)
}"
v-if="!shouldHideLeft"
>
@ -19,7 +20,7 @@
>
<div class="resizer-handle"></div>
</div>
<div class="panel-right" :style="{ flex: 1 }">
<div class="panel-right" :style="{ flex: 1, padding: toPx(rightPadding) }">
<slot name="right"></slot>
</div>
</div>
@ -27,6 +28,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { globalStore } from '@/store'
const props = defineProps({
//
@ -54,20 +56,33 @@
md: {
type: Number,
default: null
}
},
bottomGap: {
type: [Number, String],
default: 10
},
//
shadow: { type: Boolean, default: true },
// Number/px
radius: { type: [Number, String], default: 'system' },
//
hideLeft: { type: Boolean, default: false },
leftPadding: { type: [Number, String], default: 24 },
rightPadding: { type: [Number, String], default: 24 }
})
const emit = defineEmits(['resize'])
const leftSize = ref(props.initialSize)
const isResizing = ref(false)
const rootRef = ref(null)
let activeContainer = null
//
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
const shouldHideLeft = computed(() => {
// md 0 <768
return props.md === 0 && windowWidth.value < 768
return props.hideLeft || (props.md === 0 && windowWidth.value < 768)
})
// 使CSS
@ -78,6 +93,7 @@
const startResize = (e) => {
if (shouldHideLeft.value) return
isResizing.value = true
activeContainer = rootRef.value || e.target.closest('.resizable-panel')
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', stopResize)
e.preventDefault()
@ -86,7 +102,7 @@
const handleResize = (e) => {
if (!isResizing.value) return
const container = e.currentTarget?.closest?.('.resizable-panel') || document.querySelector('.resizable-panel')
const container = activeContainer
if (!container) return
const rect = container.getBoundingClientRect()
@ -107,6 +123,7 @@
const stopResize = () => {
isResizing.value = false
activeContainer = null
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
}
@ -127,6 +144,24 @@
if (resizeHandler) window.removeEventListener('resize', resizeHandler)
})
const store = globalStore()
const systemRadius = computed(() => (store.roundedCornerStyleOpen ? 8 : 2))
// px
const toPx = (val) => (typeof val === 'number' ? `${val}px` : val)
const wrapperStyle = computed(() => {
const gap = toPx(props.bottomGap)
return {
display: 'flex',
flexDirection: props.direction,
borderRadius: toPx(props.radius === 'system' ? systemRadius.value : props.radius),
boxShadow: props.shadow ? 'var(--card-shadow-soft, 0 1px 6px rgba(0, 0, 0, 0.06))' : 'none',
//
marginBottom: gap,
//
height: `calc(100% - ${gap})`
}
})
//
defineExpose({
setSize: (size) => {

View File

@ -2,12 +2,18 @@
<a-modal
v-model:open="visible"
title="选择组织"
:width="400"
:width="500"
:mask-closable="false"
:destroy-on-close="true"
@ok="handleOk"
@cancel="onClose"
>
<div class="scopeDefineOrgActions">
<a-space size="small">
<a-button size="small" @click="checkAll">全选</a-button>
<a-button size="small" @click="invertCheck">反选</a-button>
</a-space>
</div>
<div class="scopeDefineOrgTreeDiv">
<a-tree
v-model:expandedKeys="defaultExpandedKeys"
@ -26,12 +32,26 @@
<script setup="props, context" name="scopeDefineOrg">
import roleApi from '@/api/sys/roleApi'
import { checkOrUnCheckChildren } from '@/utils/treeHandler'
const visible = ref(false)
let defaultExpandedKeys = ref([])
let checkedKeys = ref([])
const treeData = ref([])
const getAllIds = (nodes) => {
const ids = []
const stack = [...nodes]
while (stack.length) {
const n = stack.pop()
if (n && n.id) ids.push(n.id)
if (n && n.children && n.children.length) {
for (let i = 0; i < n.children.length; i++) {
stack.push(n.children[i])
}
}
}
return ids
}
const resultDataModel = {
dataScopeId: '',
defineOrgIdData: {
@ -80,13 +100,35 @@
checkedKeys.value.push(key)
})
}
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = checkedKeys.value
}
// treeNode title,key,children
const treeFieldNames = { children: 'children', title: 'name', key: 'id' }
//
const treeCheck = (checkedKeys, { checked, node }) => {
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = checkOrUnCheckChildren(checked, node, checkedKeys)
const treeCheck = (keysObj) => {
checkedKeys.value = keysObj.checked
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = keysObj.checked
}
const getCurrentCheckedIds = () => {
if (Array.isArray(checkedKeys.value)) return checkedKeys.value
if (checkedKeys.value && Array.isArray(checkedKeys.value.checked)) return checkedKeys.value.checked
return []
}
const checkAll = () => {
const all = getAllIds(treeData.value)
checkedKeys.value = all
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = all
}
const invertCheck = () => {
const all = getAllIds(treeData.value)
const current = getCurrentCheckedIds()
const set = new Set(current)
const next = all.filter((id) => !set.has(id))
checkedKeys.value = next
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = next
}
// emit
const emit = defineEmits({
@ -111,4 +153,7 @@
max-height: 450px;
overflow: auto;
}
.scopeDefineOrgActions {
margin-bottom: 8px;
}
</style>

View File

@ -8,6 +8,12 @@
@ok="handleOk"
@cancel="onClose"
>
<div class="scopeDefineOrgActions">
<a-space size="small">
<a-button size="small" @click="checkAll">全选</a-button>
<a-button size="small" @click="invertCheck">反选</a-button>
</a-space>
</div>
<div class="scopeDefineOrgTreeDiv">
<a-tree
v-model:expandedKeys="defaultExpandedKeys"
@ -26,12 +32,26 @@
<script setup="props, context" name="userScopeDefineOrg">
import userApi from '@/api/sys/userApi'
import { checkOrUnCheckChildren } from '@/utils/treeHandler'
const visible = ref(false)
let defaultExpandedKeys = ref([])
let checkedKeys = ref([])
const treeData = ref([])
const getAllIds = (nodes) => {
const ids = []
const stack = [...nodes]
while (stack.length) {
const n = stack.pop()
if (n && n.id) ids.push(n.id)
if (n && n.children && n.children.length) {
for (let i = 0; i < n.children.length; i++) {
stack.push(n.children[i])
}
}
}
return ids
}
const resultDataModel = {
dataScopeId: '',
defineOrgIdData: {
@ -80,13 +100,35 @@
checkedKeys.value.push(key)
})
}
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = checkedKeys.value
}
// treeNode title,key,children
const treeFieldNames = { children: 'children', title: 'name', key: 'id' }
//
const treeCheck = (checkedKeys, { checked, node }) => {
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = checkOrUnCheckChildren(checked, node, checkedKeys)
const treeCheck = (keysObj) => {
checkedKeys.value = keysObj.checked
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = keysObj.checked
}
const getCurrentCheckedIds = () => {
if (Array.isArray(checkedKeys.value)) return checkedKeys.value
if (checkedKeys.value && Array.isArray(checkedKeys.value.checked)) return checkedKeys.value.checked
return []
}
const checkAll = () => {
const all = getAllIds(treeData.value)
checkedKeys.value = all
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = all
}
const invertCheck = () => {
const all = getAllIds(treeData.value)
const current = getCurrentCheckedIds()
const set = new Set(current)
const next = all.filter((id) => !set.has(id))
checkedKeys.value = next
resultDataModel.defineOrgIdData.scopeDefineOrgIdList = next
}
// emit
const emit = defineEmits({
@ -111,4 +153,7 @@
max-height: 450px;
overflow: auto;
}
.scopeDefineOrgActions {
margin-bottom: 8px;
}
</style>