跳转用 ?filter={_id} 锁单条,但 _id 不在筛选抽屉里、且抽屉 confirm 会把它原样带回,
导致用户再搜索/筛选时被看不见的 _id 一直 AND 着、结果莫名为空。
onSearch + handleFilterConfirm 现在都剥掉 _id(重置本就清空)。
599 lines
21 KiB
Vue
599 lines
21 KiB
Vue
<template>
|
||
<el-container class="shrimp-table" direction="vertical">
|
||
<!-- 顶部区域:左侧操作按钮 + 右侧搜索 -->
|
||
<div class="aside" v-if="hasActions || searchableFields.length > 0 || filterableFields.length > 0">
|
||
<div class="aside-left">
|
||
<el-button v-if="can('add')" type="primary" :icon="Plus" circle size="large" @click="handleAdd" />
|
||
<el-button v-if="can('clear')" :icon="DeleteIcon" circle size="large" @click="handleClear" />
|
||
<el-button v-if="can('export')" :icon="Download" circle size="large" @click="handleExport" />
|
||
<el-button v-if="can('import')" :icon="Upload" circle size="large" @click="handleImportClick" />
|
||
<input ref="fileInput" type="file" accept=".xlsx,.xls,.json" style="display: none" @change="handleFileChange" />
|
||
<slot name="actions"></slot>
|
||
</div>
|
||
<div class="aside-right">
|
||
<ShrimpSearchInput v-if="searchableFields.length > 0" v-model:keyword="keyword"
|
||
v-model:visible="searchVisible" :active="!!searchKeyword" @search="onSearch" />
|
||
<ShrimpFilterTrigger v-if="filterableFields.length > 0" :active="hasFilter"
|
||
@click="filterDrawerVisible = true" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 表格主体(可滚动) -->
|
||
<el-main>
|
||
<el-table :data="data" :loading="loading" stripe border style="width: 100%" v-bind="$attrs"
|
||
@sort-change="onSortChange">
|
||
<template v-for="field in visibleFields" :key="field.key">
|
||
<el-table-column :prop="field.key" :label="vueAdmin.t(field.label)"
|
||
:sortable="field.sortable ? 'custom' : false">
|
||
<template #default="{ row }">
|
||
<slot :name="`column-${field.key}`" :row="row" :field="field">
|
||
<component :is="vueAdmin.getFieldComponent(moduleKey, field.type, field.key)" :field="field"
|
||
:model-value="row[field.key]" mode="display"
|
||
v-bind="field.type === 'textarea' || field.type === 'tab' ? { displayLabel: row[field.key + '_label'] } : {}" />
|
||
</slot>
|
||
</template>
|
||
</el-table-column>
|
||
</template>
|
||
|
||
<el-table-column :label="$t('base.operation')" :width="rowActionsWidth" v-if="hasRowActions">
|
||
<template #default="{ row }">
|
||
<el-button v-if="hasActiveField && can('edit')" link :type="row.active ? 'warning' : 'success'" :icon="row.active ? VideoPause : VideoPlay" size="large" @click="handleToggleActive(row)" />
|
||
<el-button v-if="can('edit')" link type="primary" :icon="Edit" size="large" @click="handleEdit(row)" />
|
||
<el-button v-if="can('delete')" link type="danger" :icon="DeleteIcon" size="large" @click="handleDelete(row)" />
|
||
<el-tooltip v-for="sub in subModules" :key="sub.moduleKey" :content="vueAdmin.t(sub.label)">
|
||
<el-button link type="primary" :icon="resolveIcon(sub.icon)" size="large"
|
||
@click="handleSubJump(sub, row)" />
|
||
</el-tooltip>
|
||
<slot name="row-actions" :row="row"></slot>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-main>
|
||
|
||
<!-- 底部区域:分页 + 扩展(固定在容器底部) -->
|
||
<el-footer height="auto" v-if="total > 0 || $slots.footer">
|
||
<el-pagination v-if="total > 0" v-model:current-page="currentPage" v-model:page-size="currentPageSize"
|
||
:total="total" @current-change="refresh" />
|
||
<slot name="footer"></slot>
|
||
</el-footer>
|
||
|
||
<!-- 新增/编辑抽屉 -->
|
||
<ShrimpEditDrawer ref="drawerRef" v-model:visible="dialogVisible" :title="isEdit ? $t('base.edit') : $t('base.add')"
|
||
:moduleKey="props.moduleKey" :fields="formFields" v-model:formData="formData" :isEdit="isEdit"
|
||
:canSave="canSave" @save="handleSave">
|
||
<template v-for="name in formSlotNames" :key="name" #[name]="slotProps">
|
||
<slot :name="name" v-bind="slotProps" />
|
||
</template>
|
||
</ShrimpEditDrawer>
|
||
|
||
<!-- 筛选抽屉 -->
|
||
<ShrimpFilterDrawer v-model:visible="filterDrawerVisible" :title="$t('base.filter')"
|
||
:moduleKey="props.moduleKey" :filterableFields="filterableFields" :filterData="filterData"
|
||
@confirm="handleFilterConfirm" @reset="handleFilterReset">
|
||
<template v-for="name in filterSlotNames" :key="name" #[name]="slotProps">
|
||
<slot :name="name" v-bind="slotProps" />
|
||
</template>
|
||
</ShrimpFilterDrawer>
|
||
</el-container>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import { ref, computed, useSlots, onMounted, provide, type Component } from 'vue'
|
||
import { ITableCrudProto, IFieldConfig, IModuleInfo, IPageQuery } from '../IShrimpType'
|
||
import { vueAdmin } from '../VueAdmin'
|
||
import { Plus, Delete as DeleteIcon, Download, Upload, Edit, VideoPlay, VideoPause, Operation } from '@element-plus/icons-vue'
|
||
import * as ElIcons from '@element-plus/icons-vue'
|
||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { useTableUrlSync } from '../composables/useTableUrlSync'
|
||
import ShrimpEditDrawer from './ShrimpEditDrawer.vue'
|
||
import ShrimpFilterDrawer from './ShrimpFilterDrawer.vue'
|
||
import ShrimpSearchInput from './ShrimpSearchInput.vue'
|
||
import ShrimpFilterTrigger from './ShrimpFilterTrigger.vue'
|
||
import * as XLSX from 'xlsx'
|
||
|
||
const { t } = useI18n()
|
||
|
||
interface Props {
|
||
moduleKey: string
|
||
pageSize?: number
|
||
/** 显式锁定的筛选条件(覆盖自动 ownerKey 推断);用户无法修改,会合并进 filter 并在筛选抽屉中隐藏 */
|
||
baseFilter?: Record<string, any>
|
||
}
|
||
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
pageSize: 20,
|
||
baseFilter: () => ({})
|
||
})
|
||
|
||
const emit = defineEmits<{
|
||
export: []
|
||
import: []
|
||
}>()
|
||
|
||
const api = vueAdmin.getModule<ITableCrudProto<any>>(props.moduleKey)
|
||
const slots = useSlots()
|
||
|
||
// ---- URL 同步 ----
|
||
|
||
const {
|
||
currentPage,
|
||
currentPageSize,
|
||
keyword,
|
||
searchKeyword,
|
||
currentSortBy,
|
||
currentSortOrder,
|
||
filterData,
|
||
restore,
|
||
} = useTableUrlSync({ pageSize: props.pageSize })
|
||
|
||
// ---- 搜索 ----
|
||
|
||
const searchVisible = ref(false)
|
||
|
||
// relation 跳转用 ?filter={_id} 锁定单条;_id 是隐形 pin(不在筛选抽屉里),
|
||
// 用户一旦搜索/改筛选就清掉它,避免被看不见的 _id 一直 AND 着导致结果莫名为空
|
||
function stripJumpPin() {
|
||
if ('_id' in filterData.value) {
|
||
const { _id, ...rest } = filterData.value
|
||
filterData.value = rest
|
||
}
|
||
}
|
||
|
||
function onSearch() {
|
||
searchKeyword.value = keyword.value
|
||
stripJumpPin()
|
||
currentPage.value = 1
|
||
refresh()
|
||
}
|
||
|
||
// ---- 筛选 ----
|
||
|
||
const filterDrawerVisible = ref(false)
|
||
|
||
const hasFilter = computed(() => {
|
||
return Object.values(filterData.value).some(val => {
|
||
if (val === '' || val === null || val === undefined) return false
|
||
if (Array.isArray(val) && val.length === 0) return false
|
||
return true
|
||
})
|
||
})
|
||
|
||
async function handleFilterConfirm(newFilterData: Record<string, any>) {
|
||
const { _id, ...rest } = newFilterData // 确认筛选 → 丢弃跳转 pin
|
||
filterData.value = rest
|
||
currentPage.value = 1
|
||
await refresh()
|
||
}
|
||
|
||
async function handleFilterReset() {
|
||
filterData.value = {}
|
||
currentPage.value = 1
|
||
await refresh()
|
||
}
|
||
|
||
// ---- 远程排序 ----
|
||
|
||
function onSortChange({ prop, order }: { prop: string; order: string | null }) {
|
||
if (order) {
|
||
currentSortBy.value = prop
|
||
currentSortOrder.value = order === 'ascending' ? 'asc' : 'desc'
|
||
} else {
|
||
currentSortBy.value = ''
|
||
}
|
||
refresh()
|
||
}
|
||
|
||
// ---- 字段配置 ----
|
||
|
||
const moduleInfo = computed<IModuleInfo | undefined>(() =>
|
||
vueAdmin.moduleInfo.find(m => m.moduleKey === props.moduleKey)
|
||
)
|
||
|
||
const fields = computed<IFieldConfig[]>(() => moduleInfo.value?.fields || [])
|
||
|
||
// ---- 子表 ownerId 推断 ----
|
||
// 子模块约定: moduleInfo.parent 存在 → 从路由 ?id= 取父记录 id,作为 ownerId 顶层参数发给后端
|
||
// 后端按自己的 ownerKey 配置翻译为 filter 注入(前端不构造 {ownerKey: id} 这种业务字段名 payload)
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const ownerId = computed<string | undefined>(() => {
|
||
if (!moduleInfo.value?.parent) return undefined
|
||
return (route.query.id as string | undefined) || undefined
|
||
})
|
||
|
||
const visibleFields = computed(() => fields.value.filter(f => !f.hidden && !f.tableHide))
|
||
const formFields = computed(() => {
|
||
const list = fields.value.filter(f => !f.hidden)
|
||
if (isEdit.value) {
|
||
return list.filter(f => f.editable !== 'readonly')
|
||
}
|
||
return list.filter(f => f.editable !== 'readonly')
|
||
})
|
||
const searchableFields = computed(() => fields.value.filter(f => f.searchable))
|
||
const filterableFields = computed(() => {
|
||
const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
|
||
return fields.value.filter(f => f.filterable && !lockedKeys.has(f.key))
|
||
})
|
||
|
||
const formSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('form-')))
|
||
const filterSlotNames = computed(() => Object.keys(slots).filter(name => name.startsWith('filter-')))
|
||
|
||
// ---- 表格状态 ----
|
||
|
||
const data = ref<any[]>([])
|
||
const loading = ref(false)
|
||
const total = ref(0)
|
||
// relation 显示名字典 { moduleKey: { id: 名 } },由 list 响应带回,provide 给表格里的字段组件查名
|
||
const refs = ref<Record<string, Record<string, any>>>({})
|
||
provide('shrimpRefs', refs)
|
||
|
||
// ---- 对话框状态 ----
|
||
|
||
const dialogVisible = ref(false)
|
||
const isEdit = ref(false)
|
||
const currentId = ref('')
|
||
const formData = ref<Record<string, any>>({})
|
||
const originalData = ref<Record<string, any>>({})
|
||
const drawerRef = ref<any>(null)
|
||
|
||
// ---- 权限 ----
|
||
|
||
const can = vueAdmin.can(props.moduleKey)
|
||
|
||
const hasActions = computed(() => can('add') || can('clear') || can('export') || can('import') || !!slots.actions)
|
||
// ---- 是否有 active 字段 ----
|
||
const hasActiveField = computed(() => {
|
||
return fields.value.some(f => f.key === 'active')
|
||
})
|
||
|
||
// ---- 子模块跳转 ----
|
||
|
||
const subModules = computed<IModuleInfo[]>(() =>
|
||
vueAdmin.moduleInfo.filter(m => m.parent === props.moduleKey)
|
||
)
|
||
|
||
function resolveIcon(name?: string): Component {
|
||
if (name && (ElIcons as any)[name]) return (ElIcons as any)[name]
|
||
return Operation
|
||
}
|
||
|
||
function handleSubJump(sub: IModuleInfo, row: any) {
|
||
// 跳子模块: 把当前 ?id 追加进 pids 链, row._id 作为新的 ?id
|
||
// 这样多层嵌套下面包屑/返回回到上层时能恢复对应父记录 id
|
||
const currentPids = ((route.query.pids as string) || '').split(',').filter(Boolean)
|
||
const currentId = route.query.id as string | undefined
|
||
const newPids = currentId ? [...currentPids, currentId] : currentPids
|
||
const query: Record<string, string> = { id: row._id }
|
||
if (newPids.length > 0) query.pids = newPids.join(',')
|
||
router.push({ path: `${route.path}/${sub.moduleKey}`, query })
|
||
}
|
||
|
||
const hasRowActions = computed(() => can('edit') || can('delete') || hasActiveField.value || subModules.value.length > 0 || !!slots['row-actions'])
|
||
|
||
const rowActionsWidth = computed(() => {
|
||
let n = 0
|
||
if (hasActiveField.value && can('edit')) n++
|
||
if (can('edit')) n++
|
||
if (can('delete')) n++
|
||
n += subModules.value.length
|
||
return Math.max(120, 40 + n * 40)
|
||
})
|
||
|
||
// ---- 编辑时是否有改动 ----
|
||
const canSave = computed(() => {
|
||
if (!isEdit.value) return true
|
||
return formFields.value.some(field => {
|
||
if (field.editable === 'readonly') return false
|
||
if (field.editable === 'addOnly') return false
|
||
return formData.value[field.key] !== originalData.value[field.key]
|
||
})
|
||
})
|
||
|
||
// ---- CRUD ----
|
||
|
||
async function handleAdd() {
|
||
isEdit.value = false
|
||
currentId.value = ''
|
||
formData.value = {}
|
||
const baseKeys = new Set(Object.keys(props.baseFilter || {}))
|
||
formFields.value.forEach(field => {
|
||
if (baseKeys.has(field.key)) {
|
||
formData.value[field.key] = props.baseFilter![field.key]
|
||
} else if (field.defaultValue !== undefined) {
|
||
formData.value[field.key] = field.defaultValue
|
||
} else if (field.type === 'boolean') {
|
||
formData.value[field.key] = false
|
||
} else if (field.type === 'number') {
|
||
formData.value[field.key] = 0
|
||
} else if (field.multiple) {
|
||
formData.value[field.key] = []
|
||
} else {
|
||
formData.value[field.key] = ''
|
||
}
|
||
})
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleEdit(row: any) {
|
||
isEdit.value = true
|
||
currentId.value = row._id
|
||
originalData.value = { ...row }
|
||
formData.value = { ...row }
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
async function handleSave() {
|
||
const valid = await drawerRef.value?.validate()
|
||
if (!valid) return
|
||
try {
|
||
const submitData: Record<string, any> = {}
|
||
if (isEdit.value) {
|
||
// 编辑:只提交修改过的字段
|
||
formFields.value.forEach(field => {
|
||
if (field.editable === 'readonly') return
|
||
if (field.editable === 'addOnly') return
|
||
const newValue = formData.value[field.key]
|
||
const oldValue = originalData.value[field.key]
|
||
if (newValue === oldValue) return
|
||
submitData[field.key] = newValue
|
||
})
|
||
} else {
|
||
// 新增:提交所有非 readonly 字段
|
||
formFields.value.forEach(field => {
|
||
if (field.editable === 'readonly') return
|
||
submitData[field.key] = formData.value[field.key]
|
||
})
|
||
}
|
||
if (isEdit.value) {
|
||
if (Object.keys(submitData).length === 0) {
|
||
dialogVisible.value = false
|
||
return
|
||
}
|
||
await api.edit(currentId.value, submitData)
|
||
} else {
|
||
await api.add(submitData, ownerId.value)
|
||
}
|
||
dialogVisible.value = false
|
||
ElMessage.success(isEdit.value ? t('table.editSuccess') : t('table.addSuccess'))
|
||
await refresh()
|
||
} catch (e: any) {
|
||
console.warn(`[ShrimpTable]`, e.message)
|
||
}
|
||
}
|
||
|
||
async function handleDelete(row: any) {
|
||
try {
|
||
await ElMessageBox.confirm(t('table.confirmDelete'), t('base.confirm'), {
|
||
confirmButtonText: t('base.confirm'),
|
||
cancelButtonText: t('base.cancel'),
|
||
type: 'warning',
|
||
})
|
||
await api.delete(row._id)
|
||
await refresh()
|
||
} catch {
|
||
// 用户取消
|
||
}
|
||
}
|
||
|
||
async function handleToggleActive(row: any) {
|
||
try {
|
||
await api.edit(row._id, { active: !row.active })
|
||
await refresh()
|
||
ElMessage.success(t('base.success'))
|
||
} catch (e: any) {
|
||
console.warn(`[ShrimpTable]`, e.message)
|
||
}
|
||
}
|
||
|
||
async function handleClear() {
|
||
try {
|
||
await ElMessageBox.confirm(t('table.confirmClear'), t('base.delete'), {
|
||
confirmButtonText: t('base.confirm'),
|
||
cancelButtonText: t('base.cancel'),
|
||
type: 'warning',
|
||
})
|
||
const query: Record<string, any> = {}
|
||
if (searchKeyword.value) query.keyword = searchKeyword.value
|
||
const filter = buildFilter()
|
||
if (filter) query.filter = filter
|
||
if (ownerId.value) query.ownerId = ownerId.value
|
||
await api.clear(query)
|
||
await refresh()
|
||
} catch {
|
||
// 用户取消
|
||
}
|
||
}
|
||
|
||
// ---- 导出 ----
|
||
|
||
function buildFilter(): Record<string, any> | undefined {
|
||
const filter: Record<string, any> = {}
|
||
for (const [key, val] of Object.entries(filterData.value)) {
|
||
if (val === '' || val === null || val === undefined) continue
|
||
if (Array.isArray(val) && val.length === 0) continue
|
||
if (typeof val === 'object' && !Array.isArray(val)) {
|
||
const range: Record<string, any> = {}
|
||
if (val.min !== undefined && val.min !== null) range.min = val.min
|
||
if (val.max !== undefined && val.max !== null) range.max = val.max
|
||
if (Object.keys(range).length > 0) filter[key] = range
|
||
} else {
|
||
filter[key] = val
|
||
}
|
||
}
|
||
// baseFilter 优先级最高,覆盖用户选择
|
||
Object.assign(filter, props.baseFilter || {})
|
||
return Object.keys(filter).length > 0 ? filter : undefined
|
||
}
|
||
|
||
async function handleExport() {
|
||
emit('export')
|
||
try {
|
||
const query: Record<string, any> = {}
|
||
if (searchKeyword.value) query.keyword = searchKeyword.value
|
||
const filter = buildFilter()
|
||
if (filter) query.filter = filter
|
||
if (currentSortBy.value) {
|
||
query.sortBy = currentSortBy.value
|
||
query.sortOrder = currentSortOrder.value
|
||
}
|
||
if (ownerId.value) query.ownerId = ownerId.value
|
||
const list = await api.export(query)
|
||
const ws = XLSX.utils.json_to_sheet(list)
|
||
const wb = XLSX.utils.book_new()
|
||
XLSX.utils.book_append_sheet(wb, ws, props.moduleKey)
|
||
XLSX.writeFile(wb, `${props.moduleKey}_export_${Date.now()}.xlsx`)
|
||
ElMessage.success(t('base.success'))
|
||
} catch (e: any) {
|
||
console.warn(`[ShrimpTable] export failed`, e.message)
|
||
}
|
||
}
|
||
|
||
// ---- 导入 ----
|
||
|
||
const fileInput = ref<HTMLInputElement | null>(null)
|
||
|
||
function handleImportClick() {
|
||
emit('import')
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
async function handleFileChange(e: Event) {
|
||
const target = e.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (!file) return
|
||
try {
|
||
let data: any[]
|
||
if (file.name.endsWith('.json')) {
|
||
const text = await file.text()
|
||
data = JSON.parse(text)
|
||
} else {
|
||
const buf = await file.arrayBuffer()
|
||
const wb = XLSX.read(buf, { type: 'array' })
|
||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||
data = XLSX.utils.sheet_to_json(ws)
|
||
}
|
||
await api.import(data, ownerId.value)
|
||
ElMessage.success(t('base.success'))
|
||
await refresh()
|
||
} catch (e: any) {
|
||
// if (!e.handled) {
|
||
// ElMessage.error(t('base.fail'))
|
||
// }
|
||
// console.warn(`[ShrimpTable] import failed`, e.message)
|
||
} finally {
|
||
target.value = ''
|
||
}
|
||
}
|
||
|
||
// ---- 数据加载 ----
|
||
|
||
async function refresh(query?: Record<string, any>) {
|
||
loading.value = true
|
||
try {
|
||
const pageQuery: IPageQuery & Record<string, any> = {
|
||
page: currentPage.value,
|
||
pageSize: currentPageSize.value,
|
||
}
|
||
if (searchKeyword.value) {
|
||
pageQuery.keyword = searchKeyword.value
|
||
}
|
||
if (currentSortBy.value) {
|
||
pageQuery.sortBy = currentSortBy.value
|
||
pageQuery.sortOrder = currentSortOrder.value
|
||
}
|
||
// 筛选条件:仅传有值的字段,放到 filter 对象中
|
||
const filter = buildFilter()
|
||
if (filter) {
|
||
pageQuery.filter = filter
|
||
}
|
||
// 子表场景: 顶层 ownerId 由后端按 ownerKey 配置翻译
|
||
if (ownerId.value) {
|
||
pageQuery.ownerId = ownerId.value
|
||
}
|
||
Object.assign(pageQuery, query)
|
||
const res = await api.list(pageQuery)
|
||
data.value = res.list || []
|
||
refs.value = res.refs || {}
|
||
total.value = res.total
|
||
currentPage.value = res.page
|
||
currentPageSize.value = res.pageSize
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// ---- 暴露 ----
|
||
|
||
defineExpose({
|
||
data,
|
||
loading,
|
||
refresh,
|
||
can,
|
||
api,
|
||
keyword,
|
||
})
|
||
|
||
onMounted(() => {
|
||
restore()
|
||
if (keyword.value) {
|
||
searchVisible.value = true
|
||
}
|
||
refresh()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.shrimp-table {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 顶部区域:左右分布 */
|
||
.aside {
|
||
padding: 4px 10px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.aside-left {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.aside-right {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 表格主体:内部纵向滚动(分页固定在 footer,不参与滚动) */
|
||
.shrimp-table :deep(.el-main) {
|
||
padding: 0;
|
||
overflow: auto;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 底部分页:固定在容器底,不随内容滚动 */
|
||
.shrimp-table :deep(.el-footer) {
|
||
padding: 12px 0;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 强制放大操作栏图标 */
|
||
.shrimp-table :deep(.el-button .el-icon) {
|
||
font-size: 20px;
|
||
width: 20px;
|
||
height: 20px;
|
||
}
|
||
</style>
|