Files
shrimp-admin/src/components/ShrimpTable.vue
T
wc24 e789f8a1f7 feat(filter): filterableFields 递归收集嵌套 filterable,key=点路径
ShrimpTable 把 filterableFields 从'顶层 filter'改成'递归收集 object/tab 嵌套叶子',
生成 key=点路径的合成字段。抽屉/buildFilter/URL 同步全按 field.key 走即可,无需改。
版本与 shrimp-server 对齐 0.7.5。
2026-06-30 16:01:23 +08:00

627 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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' ? { 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))
// 递归收集 filterable 字段(含 object/tab 嵌套叶子),生成 key=点路径的"合成字段"。
// 这样筛选抽屉/buildFilter/URL 同步全按 field.key 走即可——key 就是 condition.level 这种路径。
function collectFilterablePaths(list: IFieldConfig[], base: string[], out: any[]) {
for (const f of list) {
const path = [...base, f.key]
if (f.filterable) out.push({ ...f, key: path.join('.') })
if (f.type === 'tab' && Array.isArray((f as any).tabs)) {
for (const t of (f as any).tabs) collectFilterablePaths(t.fields, path, out)
} else if (f.type === 'object' && Array.isArray((f as any).fields)) {
collectFilterablePaths((f as any).fields, path, out)
}
}
}
const filterableFields = computed(() => {
const lockedKeys = new Set(Object.keys(props.baseFilter || {}))
const out: any[] = []
collectFilterablePaths(fields.value, [], out)
return out.filter(f => !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.type === 'array' || field.multiple) {
formData.value[field.key] = []
} else if (field.type === 'object') {
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
// 清空字段值为 undefined 时,JSON.stringify 会丢键 → 后端收不到、看似"提交无效"。
// 归一成 null:既保证清空能提交(后端置空),又让"两边都空"不误判为有改动。
const newValue = formData.value[field.key] ?? null
const oldValue = originalData.value[field.key] ?? null
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)
// 后端只发原始对象;xlsx 单元格放不下对象/数组(会变 [object Object]),这里通用地 JSON 序列化
// (覆盖 tab 等结构化字段)。import 时这些字符串由后端各类型 reqFilter 还原。
const rows = list.map((row: Record<string, any>) => {
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(row)) {
out[k] = (v !== null && typeof v === 'object') ? JSON.stringify(v) : v
}
return out
})
const ws = XLSX.utils.json_to_sheet(rows)
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>