feat: GrTable 移动端表格组件 + manager/Projects 试点

业务背景
- 项目里 17+ 列的列表页 (manager/Projects, Meetings, AdminUsers 等) 在手机端只能
  横向滚动, 操作体验差.
- refer/gr-table.vue 是 Vue2 + Vant 组件, Vue3 不能直接用 (componentOptions
  私有 API 已被 Vue3 移除).
- 新建 Vue3 + EP 兼容版本 GrTable, 吸收"主列显示 / 次要列折叠到展开行"思想.

组件设计
- props: data, mainCols (默认 = 第一个 prop + 最后一个 prop), mobileBreakpoint
- 通过 slots.default() 取 vnode, 读 vnode.props.prop/label (Vue3 思路, 替代
  Vue2 componentOptions 私有 API)
- v-bind="\$attrs" 全透传 el-table 原生 props/events
- el-table 引用方法 (clearSelection / toggleRowSelection 等) defineExpose
  暴露

manager/Projects 试点
- 引入 GrTable, :main-cols="['projectNo', 'projectName']"
- 手机端: 只显示项目编号 + 项目名称 + 操作列, 其他 13 列折叠到展开行
- 桌面端: 与原 el-table 完全一致 (所有 17 列)
- 操作列 (label=操作, 无 prop) 自动保留 (因为 shouldRenderColumn 跳过无 prop 列)
This commit is contained in:
guoju0808
2026-08-26 11:32:53 +00:00
parent 02715a1385
commit 0644e26120
5 changed files with 560 additions and 2 deletions
+178
View File
@@ -0,0 +1,178 @@
<!--
GrTable el-table 移动端友好封装 (Vue 3 + Element Plus)
-----------------------------------------------------------------
背景: 通用 el-table 在窄屏 (768px) 会横向溢出, 用户需滑动才能看到所有列.
本组件吸收 refer/gr-table.vue "主列显示 / 次要列折叠到展开行"思想, 但用
Vue 3 + EP 思路重写:
- 通过 slots.default() 拿到所有 vnode (Vue2 componentOptions 私有 API
废弃), vnode.props.prop / props.label
- mainCols: 移动端显示的主列名 (prop), 未指定时 = 第一个有 prop 的列 +
后一个 prop (一般是项目编号 + 操作)
- 操作列识别: 没有 prop 但有自定义 slot 内容 (label="操作") 始终保留
- 非主列在移动端移到 type="expand" el-table-column , 通过 el-descriptions
展示 (1 列垂直堆叠)
所有 el-table 原生 prop/event 通过 v-bind="$attrs" 全透传, 调用方用法几乎不变
(只在 el-table 外包一层 + mainCols 可选属性).
Vue 版本: Vue 3 + Vite + Element Plus (script setup, Composition API).
弃用 refer/gr-table.vue Vue 2 Options API + componentOptions 私有 API.
-->
<template>
<el-table
ref="tableRef"
v-bind="$attrs"
:data="data"
>
<!-- 移动端展开行: 只在 breakpoint 渲染, 显示非主列 -->
<el-table-column v-if="isMobile && collapsedCols.length" type="expand">
<template #default="{ row }">
<el-descriptions :column="1" size="small" class="gr-mobile-desc">
<el-descriptions-item
v-for="col in collapsedCols"
:key="col.prop"
:label="col.label"
>
<span v-if="col.formatter">{{ col.formatter(row, col.prop, row[col.prop]) }}</span>
<span v-else>{{ row[col.prop] ?? '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</template>
</el-table-column>
<!--
桌面: 透传所有列 (含非主列)
移动端: 只透传主列 + 操作列 (type="selection" 也保留, 它没 prop)
-->
<template v-for="(child, idx) in slotColumns" :key="idx">
<component
:is="child"
v-if="shouldRenderColumn(child, idx)"
/>
</template>
</el-table>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
import { ElTable, ElTableColumn, ElDescriptions, ElDescriptionsItem } from 'element-plus'
defineOptions({ inheritAttrs: false })
const props = defineProps({
data: { type: Array, default: () => [] },
/** 移动端保留的主列 prop 列表; 未指定时取第一个有 prop 的列 + 最后一个 */
mainCols: { type: Array, default: null },
/** 移动端断点 (≤此宽度启用折叠模式) */
mobileBreakpoint: { type: Number, default: 768 }
})
const tableRef = ref(null)
const isMobile = ref(false)
let mq = null
function updateMq(e) {
isMobile.value = e.matches
}
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
mq = window.matchMedia(`(max-width: ${props.mobileBreakpoint}px)`)
isMobile.value = mq.matches
if (mq.addEventListener) mq.addEventListener('change', updateMq)
else mq.addListener(updateMq)
}
})
onBeforeUnmount(() => {
if (!mq) return
if (mq.removeEventListener) mq.removeEventListener('change', updateMq)
else mq.removeListener(updateMq)
})
// 拿到调用方传入的 el-table-column 子节点 (Vue 3: slots.default 是函数)
const slots = useSlots()
const attrs = useAttrs()
const slotColumns = computed(() => {
const nodes = slots.default ? slots.default() : []
// 拍平: 处理 fragment 包裹 (用户在 template 里直接写多列会触发)
const flat = []
for (const n of nodes) {
if (Array.isArray(n.children)) {
for (const c of n.children) if (c && typeof c === 'object') flat.push(c)
} else if (n && typeof n === 'object' && n.type) {
flat.push(n)
}
}
return flat
})
// 抽取所有有 prop 的列 (排除 type=expand / type=selection / type=index, 它们用
// 自身 type 标识, 不通过 prop)
const dataCols = computed(() => {
return slotColumns.value
.map((n, idx) => ({
node: n,
originalIndex: idx,
type: n.props?.type,
prop: n.props?.prop,
label: n.props?.label || (n.props?.type === 'selection' ? '#' : (n.props?.type === 'index' ? '#' : '')),
formatter: n.props?.formatter
}))
.filter((c) => c.prop) // 只要有 prop 的列
})
// 计算主列列表 (Vue3 不用 componentOptions, 直接读 vnode.props)
const mainColsResolved = computed(() => {
if (props.mainCols && props.mainCols.length) return props.mainCols
const cols = dataCols.value
if (!cols.length) return []
// 默认: 第一个 + 最后一个 (一般是项目编号 + 操作前的"项目结束时间"列)
// 用户的 "操作" 列通常没 prop, 所以"最后一个有 prop 的列"会自然避开
return [cols[0].prop, cols[cols.length - 1].prop]
})
const collapsedCols = computed(() => {
return dataCols.value.filter((c) => !mainColsResolved.value.includes(c.prop))
})
function shouldRenderColumn(node, idx) {
if (!isMobile.value) return true // 桌面全显示
const meta = dataCols.value.find((c) => c.originalIndex === idx)
if (!meta) return true // 非数据列 (expand/selection/index/自定义 slot 列), 保留
if (!meta.prop) return true // 无 prop, 视为操作列或特殊列, 保留
return mainColsResolved.value.includes(meta.prop) // 只渲染主列
}
// 透传 el-table 引用方法 (clearSelection / toggleRowSelection 等)
defineExpose({
clearSelection: (...args) => tableRef.value?.clearSelection?.(...args),
toggleRowSelection: (...args) => tableRef.value?.toggleRowSelection?.(...args),
toggleAllSelection: (...args) => tableRef.value?.toggleAllSelection?.(...args),
toggleRowExpansion: (...args) => tableRef.value?.toggleRowExpansion?.(...args),
setCurrentRow: (...args) => tableRef.value?.setCurrentRow?.(...args),
clearSort: (...args) => tableRef.value?.clearSort?.(...args),
clearFilter: (...args) => tableRef.value?.clearFilter?.(...args),
doLayout: (...args) => tableRef.value?.doLayout?.(...args),
sort: (...args) => tableRef.value?.sort?.(...args),
/** 暴露内部 ref 供高级用法 */
tableRef
})
</script>
<style scoped>
.gr-mobile-desc {
margin: 0 8px;
background: #fff;
border-radius: 4px;
}
.gr-mobile-desc :deep(.el-descriptions__label) {
width: 90px;
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--brand-slate-50);
}
.gr-mobile-desc :deep(.el-descriptions__content) {
font-size: 13px;
color: var(--el-text-color-primary);
}
</style>
+10 -2
View File
@@ -81,7 +81,14 @@
批量删除<span v-if="selection.length" class="badge">{{ selection.length }}</span>
</el-button>
</div>
<el-table :data="rows" v-loading="loading" stripe border @selection-change="onSel">
<GrTable
:data="rows"
v-loading="loading"
stripe
border
:main-cols="['projectNo', 'projectName']"
@selection-change="onSel"
>
<el-table-column type="selection" width="44" />
<el-table-column prop="projectNo" label="项目编号" width="140" fixed="left" />
<el-table-column prop="projectName" label="项目名称" min-width="200" show-overflow-tooltip />
@@ -140,7 +147,7 @@
</div>
</div></template>
</el-table-column>
</el-table>
</GrTable>
<el-pagination v-model:current-page="page.pageNum" v-model:page-size="page.pageSize" :total="page.total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next, jumper" @current-change="load" @size-change="load" />
</div>
@@ -245,6 +252,7 @@ import request from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown } from '@element-plus/icons-vue'
import Preview from '@/components/Preview.vue'
import GrTable from '@/components/GrTable.vue'
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const router = useRouter()