mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-04 17:14:21 +00:00
feat(CURD): integrate EnhancedDialog and improve modal components
- Added EnhancedDialog component to replace el-dialog in ExportModal.vue, ImportModal.vue, and PageModal.vue for better customization. - Updated styles in PageContent.vue to enhance layout and toolbar functionality. - Refactored PageSearch.vue to improve query handling and layout responsiveness. - Introduced new properties in types.ts for better configuration options in search and content components. - Updated package.json to include plop for improved project scaffolding.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
<!-- 列表页左侧工具栏:1) configButtons 与 PageContent 配置驱动一致 2) perm 预设「新增/批删/更多」 3) 默认插槽完全自定义 -->
|
||||
<template>
|
||||
<div class="data-table__toolbar--left">
|
||||
<template v-if="configButtons && configButtons.length">
|
||||
<template v-for="(btn, index) in configButtons" :key="index">
|
||||
<el-button
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
:disabled="btn.name === 'delete' && removeIds.length === 0"
|
||||
@click="$emit('toolbar', btn.name)"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<slot v-else>
|
||||
<el-button
|
||||
v-if="permCreate"
|
||||
v-hasPerm="permCreate"
|
||||
type="success"
|
||||
icon="plus"
|
||||
@click="$emit('add')"
|
||||
>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="permDelete"
|
||||
v-hasPerm="permDelete"
|
||||
type="danger"
|
||||
icon="delete"
|
||||
:loading="deleteLoading"
|
||||
:disabled="removeIds.length === 0"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
批量删除
|
||||
</el-button>
|
||||
<el-dropdown v-if="permPatch" v-hasPerm="permPatch" trigger="click">
|
||||
<el-button
|
||||
type="default"
|
||||
:disabled="removeIds.length === 0 || moreDisabled"
|
||||
icon="ArrowDown"
|
||||
>
|
||||
更多
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item icon="Check" @click="$emit('more', '0')">批量启用</el-dropdown-item>
|
||||
<el-dropdown-item icon="CircleClose" @click="$emit('more', '1')">
|
||||
批量停用
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { CrudToolbarConfigButton } from "./types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 与 PageContent `toolbarLeftBtn` 一致时走配置驱动(与 handleToolbar 对齐) */
|
||||
configButtons?: CrudToolbarConfigButton[];
|
||||
/** 勾选行主键,用于禁用批删 / 更多(插槽完全自定义时可不传) */
|
||||
removeIds?: Array<string | number>;
|
||||
/** 新增按钮权限,不传则不显示(configButtons 未传时) */
|
||||
permCreate?: string | string[];
|
||||
/** 批量删除权限,不传则不显示 */
|
||||
permDelete?: string | string[];
|
||||
/** 「更多」下拉权限,不传则不显示 */
|
||||
permPatch?: string | string[];
|
||||
/** 批量删除中(按钮 loading,并禁用「更多」) */
|
||||
deleteLoading?: boolean;
|
||||
}>(),
|
||||
{
|
||||
removeIds: () => [],
|
||||
deleteLoading: false,
|
||||
}
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
/** 配置模式:与 PageContent handleToolbar 一致 */
|
||||
toolbar: [name: string];
|
||||
add: [];
|
||||
delete: [];
|
||||
more: [value: string];
|
||||
}>();
|
||||
|
||||
const moreDisabled = computed(() => props.removeIds.length === 0 || props.deleteLoading);
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!-- PageContent 右侧圆形工具条:与业务页 #toolbar 中重复的 v-for 逻辑一致,含 Tooltip / 列筛选 -->
|
||||
<template>
|
||||
<slot name="prepend" />
|
||||
<template v-for="(btn, index) in buttons" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<el-button v-bind="btn.attrs" />
|
||||
</template>
|
||||
<el-scrollbar max-height="350px">
|
||||
<template v-for="c in cols" :key="c.prop">
|
||||
<el-checkbox v-if="c.prop" v-model="c.show" :label="c.label" />
|
||||
</template>
|
||||
</el-scrollbar>
|
||||
</el-popover>
|
||||
<el-tooltip v-else-if="tooltipContent(btn.name)" :content="tooltipContent(btn.name)!">
|
||||
<el-button v-hasPerm="btn.perm ?? '*:*:*'" v-bind="btn.attrs" @click="onToolbar(btn.name)" />
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
v-else
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="onToolbar(btn.name)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ButtonProps } from "element-plus";
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
/** 与 PageContent createToolbar 产出的右侧按钮结构一致 */
|
||||
export type CrudToolbarRightButton = {
|
||||
name: string;
|
||||
text?: string;
|
||||
attrs?: Partial<ButtonProps> & { style?: CSSProperties };
|
||||
perm?: string | string[] | null;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
buttons: CrudToolbarRightButton[];
|
||||
cols: Array<{ prop?: string; label?: string; show?: boolean }>;
|
||||
onToolbar: (name: string) => void;
|
||||
}>();
|
||||
|
||||
const TOOLTIPS: Record<string, string> = {
|
||||
import: "导入",
|
||||
imports: "批量导入",
|
||||
export: "导出",
|
||||
exports: "导出",
|
||||
search: "搜索显示/隐藏",
|
||||
refresh: "刷新",
|
||||
};
|
||||
|
||||
function tooltipContent(name: string): string | undefined {
|
||||
return TOOLTIPS[name];
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:width="width"
|
||||
:draggable="draggable"
|
||||
:fullscreen="fullscreen"
|
||||
:show-close="false"
|
||||
:class="dialogClass"
|
||||
:modal-class="modalClass"
|
||||
align-center
|
||||
destroy-on-close
|
||||
v-bind="dialogAttrs"
|
||||
@close="emit('close')"
|
||||
@opened="emit('opened')"
|
||||
>
|
||||
<template #header="{ titleId, titleClass, close }">
|
||||
<div class="curd-dialog-header">
|
||||
<span :id="titleId" :class="titleClass">{{ title }}</span>
|
||||
<div class="curd-dialog-header__actions">
|
||||
<el-tooltip :content="fullscreen ? '还原' : '全屏'" placement="top">
|
||||
<el-button text circle type="primary" @click="fullscreen = !fullscreen">
|
||||
<el-icon>
|
||||
<Fold v-if="fullscreen" />
|
||||
<FullScreen v-else />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="关闭" placement="top">
|
||||
<el-button text circle @click="close">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Close, Fold, FullScreen } from "@element-plus/icons-vue";
|
||||
import type { DialogProps } from "element-plus";
|
||||
import { computed, ref, useAttrs } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
title?: string;
|
||||
width?: string | number;
|
||||
/** 默认可拖拽;全屏时 Element Plus 会限制拖拽 */
|
||||
draggable?: boolean;
|
||||
/** 透传到 el-dialog 的 class */
|
||||
dialogClass?: string;
|
||||
/** 遮罩层自定义 class */
|
||||
modalClass?: string;
|
||||
}>(),
|
||||
{
|
||||
draggable: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [v: boolean];
|
||||
close: [];
|
||||
opened: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
const fullscreen = ref(false);
|
||||
|
||||
const dialogClass = computed(() => {
|
||||
const a = attrs.class;
|
||||
return [props.dialogClass, a].filter(Boolean);
|
||||
});
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
/** 透传除 modelValue 外的 el-dialog 属性(如 top、modal-class、append-to-body 等) */
|
||||
const dialogAttrs = computed(() => {
|
||||
const a = { ...attrs } as Record<string, unknown>;
|
||||
delete a.class;
|
||||
return a as Partial<Omit<DialogProps, "modelValue">>;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.curd-dialog-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.curd-dialog-header__actions {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:size="size"
|
||||
:direction="direction"
|
||||
:show-close="false"
|
||||
:class="drawerClassMerged"
|
||||
destroy-on-close
|
||||
v-bind="drawerAttrs"
|
||||
@close="emit('close')"
|
||||
@opened="emit('opened')"
|
||||
>
|
||||
<template #header>
|
||||
<div class="curd-drawer-header">
|
||||
<span class="curd-drawer-header__title">{{ title }}</span>
|
||||
<div class="curd-drawer-header__actions">
|
||||
<el-tooltip content="关闭" placement="top">
|
||||
<el-button text circle @click="visible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
<template v-if="$slots.footer" #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
import type { DrawerProps } from "element-plus";
|
||||
import { computed, useAttrs } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
title?: string;
|
||||
size?: string | number;
|
||||
direction?: "rtl" | "ltr" | "ttb" | "btt";
|
||||
/** 透传到 el-drawer 的 class */
|
||||
drawerClass?: string;
|
||||
}>(),
|
||||
{
|
||||
direction: "rtl",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [v: boolean];
|
||||
close: [];
|
||||
opened: [];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit("update:modelValue", v),
|
||||
});
|
||||
|
||||
const drawerClassMerged = computed(() => {
|
||||
const a = attrs.class;
|
||||
return [props.drawerClass, a].filter(Boolean);
|
||||
});
|
||||
|
||||
const drawerAttrs = computed(() => {
|
||||
const a = { ...attrs } as Record<string, unknown>;
|
||||
delete a.class;
|
||||
return a as Partial<Omit<DrawerProps, "modelValue">>;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.curd-drawer-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.curd-drawer-header__title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.curd-drawer-header__actions {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="curd-export-modal-host">
|
||||
<!-- 导出弹窗 -->
|
||||
<el-dialog
|
||||
<EnhancedDialog
|
||||
v-model="exportsModalVisible"
|
||||
:align-center="true"
|
||||
title="导出数据"
|
||||
width="600px"
|
||||
style="padding-right: 0"
|
||||
dialog-class="curd-embed-dialog"
|
||||
@close="handleCloseExportsModal"
|
||||
>
|
||||
<!-- 滚动 -->
|
||||
@@ -59,11 +58,12 @@
|
||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
||||
import ExcelJS from "exceljs";
|
||||
import type { IContentConfig, IObject } from "./types";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="curd-import-modal-host">
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog
|
||||
<EnhancedDialog
|
||||
v-model="importModalVisible"
|
||||
:align-center="true"
|
||||
:title="props.title"
|
||||
:width="props.width"
|
||||
style="padding-right: 0"
|
||||
dialog-class="curd-embed-dialog"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 滚动 -->
|
||||
@@ -69,11 +68,12 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</EnhancedDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
||||
import { ElMessage, type UploadUserFile } from "element-plus";
|
||||
import { ref, reactive } from "vue";
|
||||
import type { IContentConfig, IObject } from "./types";
|
||||
|
||||
@@ -1,192 +1,201 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
|
||||
<el-card
|
||||
class="data-table flex-1 min-h-0"
|
||||
:shadow="config.cardShadow ?? 'never'"
|
||||
:class="contentConfig.cardClass"
|
||||
:body-style="cardBodyStyle"
|
||||
>
|
||||
<!-- 表格工具栏 -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
|
||||
<!-- 左侧工具栏 -->
|
||||
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
|
||||
<el-button
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
:disabled="btn.name === 'delete' && removeIds.length === 0"
|
||||
@click="handleToolbar(btn.name)"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<el-button v-bind="btn.attrs"></el-button>
|
||||
</template>
|
||||
<el-scrollbar max-height="350px">
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
<el-checkbox v-if="col.prop" v-model="col.show" :label="col.label" />
|
||||
</template>
|
||||
</el-scrollbar>
|
||||
</el-popover>
|
||||
<el-button
|
||||
v-else
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="handleToolbar(btn.name)"
|
||||
></el-button>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="slots.header" #header>
|
||||
<slot name="header" />
|
||||
</template>
|
||||
<!-- 表格工具栏:#toolbar 可完全自定义;默认左右分栏 -->
|
||||
<div
|
||||
v-if="
|
||||
config.showToolbar !== false &&
|
||||
(slots.toolbar || toolbarLeftBtn.length > 0 || toolbarRightBtn.length > 0)
|
||||
"
|
||||
class="data-table__toolbar"
|
||||
>
|
||||
<slot
|
||||
name="toolbar"
|
||||
:toolbar-left="toolbarLeftBtn"
|
||||
:toolbar-right="toolbarRightBtn"
|
||||
:on-toolbar="handleToolbar"
|
||||
:remove-ids="removeIds"
|
||||
:cols="cols"
|
||||
>
|
||||
<CrudToolbarLeft
|
||||
v-if="toolbarLeftBtn.length > 0"
|
||||
:remove-ids="removeIds"
|
||||
:config-buttons="toolbarLeftBtn"
|
||||
@toolbar="handleToolbar"
|
||||
/>
|
||||
<div class="data-table__toolbar--right">
|
||||
<CrudToolbarRight :buttons="toolbarRightBtn" :cols="cols" :on-toolbar="handleToolbar" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
v-bind="contentConfig.table"
|
||||
:data="pageData"
|
||||
:row-key="pk"
|
||||
class="flex-1"
|
||||
@selection-change="handleSelectionChange"
|
||||
@filter-change="handleFilterChange"
|
||||
>
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
<el-table-column v-if="col.show" v-bind="col">
|
||||
<template #default="scope">
|
||||
<!-- 显示图片 -->
|
||||
<template v-if="col.templet === 'image'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="Array.isArray(scope.row[col.prop])">
|
||||
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="index"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
<!-- 列表:默认内置 el-table;传入 #table 插槽可完全自定义(普通表 / 树表等) -->
|
||||
<div class="data-table__content">
|
||||
<slot
|
||||
name="table"
|
||||
:data="pageData"
|
||||
:loading="loading"
|
||||
:table-ref="tableRef"
|
||||
:on-selection-change="handleSelectionChange"
|
||||
:pagination="pagination"
|
||||
>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
v-bind="contentConfig.table"
|
||||
:data="pageData"
|
||||
:row-key="pk"
|
||||
class="flex-1"
|
||||
@selection-change="handleSelectionChange"
|
||||
@filter-change="handleFilterChange"
|
||||
>
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
<el-table-column v-if="col.show" v-bind="col">
|
||||
<template #default="scope">
|
||||
<!-- 显示图片 -->
|
||||
<template v-if="col.templet === 'image'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="Array.isArray(scope.row[col.prop])">
|
||||
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="Number(index)"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-image
|
||||
:src="scope.row[col.prop]"
|
||||
:preview-src-list="[scope.row[col.prop]]"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化显示链接 -->
|
||||
<template v-else-if="col.templet === 'url'">
|
||||
<template v-if="col.prop">
|
||||
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
|
||||
{{ scope.row[col.prop] }}
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
<el-switch
|
||||
v-model="scope.row[col.prop]"
|
||||
:active-value="col.activeValue ?? 1"
|
||||
:inactive-value="col.inactiveValue ?? 0"
|
||||
:inline-prompt="true"
|
||||
:active-text="col.activeText ?? ''"
|
||||
:inactive-text="col.inactiveText ?? ''"
|
||||
:validate-event="false"
|
||||
:disabled="col.disabled"
|
||||
@change="
|
||||
pageData.length > 0 &&
|
||||
handleModify(col.prop, scope.row[col.prop], scope.row)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-image
|
||||
:src="scope.row[col.prop]"
|
||||
:preview-src-list="[scope.row[col.prop]]"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
<!-- 生成输入框组件 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
v-model="scope.row[col.prop]"
|
||||
:type="col.inputType ?? 'text'"
|
||||
:disabled="col.disabled"
|
||||
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
<template v-else-if="col.templet === 'percent'">
|
||||
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
|
||||
</template>
|
||||
<!-- 显示图标 -->
|
||||
<template v-else-if="col.templet === 'icon'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
|
||||
<el-icon>
|
||||
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化时间 -->
|
||||
<template v-else-if="col.templet === 'date'">
|
||||
<template v-if="col.prop">
|
||||
{{
|
||||
scope.row[col.prop]
|
||||
? useDateFormat(
|
||||
scope.row[col.prop],
|
||||
col.dateFormat ?? "YYYY-MM-DD HH:mm:ss"
|
||||
).value
|
||||
: ""
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 列操作栏 -->
|
||||
<template v-else-if="col.templet === 'tool'">
|
||||
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
|
||||
<el-button
|
||||
v-if="btn.render === undefined || btn.render(scope.row)"
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="
|
||||
handleOperate({
|
||||
name: btn.name,
|
||||
row: scope.row,
|
||||
column: scope.column,
|
||||
$index: scope.$index,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义 -->
|
||||
<template v-else-if="col.templet === 'custom'">
|
||||
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化显示链接 -->
|
||||
<template v-else-if="col.templet === 'url'">
|
||||
<template v-if="col.prop">
|
||||
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
|
||||
{{ scope.row[col.prop] }}
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
<el-switch
|
||||
v-model="scope.row[col.prop]"
|
||||
:active-value="col.activeValue ?? 1"
|
||||
:inactive-value="col.inactiveValue ?? 0"
|
||||
:inline-prompt="true"
|
||||
:active-text="col.activeText ?? ''"
|
||||
:inactive-text="col.inactiveText ?? ''"
|
||||
:validate-event="false"
|
||||
:disabled="col.disabled"
|
||||
@change="
|
||||
pageData.length > 0 && handleModify(col.prop, scope.row[col.prop], scope.row)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成输入框组件 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
v-model="scope.row[col.prop]"
|
||||
:type="col.inputType ?? 'text'"
|
||||
:disabled="col.disabled"
|
||||
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
<template v-else-if="col.templet === 'percent'">
|
||||
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
|
||||
</template>
|
||||
<!-- 显示图标 -->
|
||||
<template v-else-if="col.templet === 'icon'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
|
||||
<el-icon>
|
||||
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化时间 -->
|
||||
<template v-else-if="col.templet === 'date'">
|
||||
<template v-if="col.prop">
|
||||
{{
|
||||
scope.row[col.prop]
|
||||
? useDateFormat(scope.row[col.prop], col.dateFormat ?? "YYYY-MM-DD HH:mm:ss")
|
||||
.value
|
||||
: ""
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 列操作栏 -->
|
||||
<template v-else-if="col.templet === 'tool'">
|
||||
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
|
||||
<el-button
|
||||
v-if="btn.render === undefined || btn.render(scope.row)"
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="
|
||||
handleOperate({
|
||||
name: btn.name,
|
||||
row: scope.row,
|
||||
column: scope.column,
|
||||
$index: scope.$index,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义 -->
|
||||
<template v-else-if="col.templet === 'custom'">
|
||||
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
</el-table>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="showPagination" class="mt-4">
|
||||
<template v-if="showPagination" #footer>
|
||||
<el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
|
||||
<el-pagination
|
||||
v-bind="pagination"
|
||||
@@ -194,15 +203,15 @@
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 导出弹窗 -->
|
||||
<el-dialog
|
||||
<EnhancedDialog
|
||||
v-model="exportsModalVisible"
|
||||
:align-center="true"
|
||||
title="导出数据"
|
||||
width="600px"
|
||||
style="padding-right: 0"
|
||||
dialog-class="curd-embed-dialog"
|
||||
modal-class="curd-embed-dialog"
|
||||
@close="handleCloseExportsModal"
|
||||
>
|
||||
<!-- 滚动 -->
|
||||
@@ -251,14 +260,14 @@
|
||||
<el-button @click="handleCloseExportsModal">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</EnhancedDialog>
|
||||
<!-- 导入弹窗 -->
|
||||
<el-dialog
|
||||
<EnhancedDialog
|
||||
v-model="importModalVisible"
|
||||
:align-center="true"
|
||||
title="导入数据"
|
||||
width="600px"
|
||||
style="padding-right: 0"
|
||||
dialog-class="curd-embed-dialog"
|
||||
modal-class="curd-embed-dialog"
|
||||
@close="handleCloseImportModal"
|
||||
>
|
||||
<!-- 滚动 -->
|
||||
@@ -317,13 +326,14 @@
|
||||
<el-button @click="handleCloseImportModal">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</EnhancedDialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDateFormat, useThrottleFn } from "@vueuse/core";
|
||||
import {
|
||||
ElMessage,
|
||||
genFileId,
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
@@ -333,10 +343,24 @@ import {
|
||||
type TableInstance,
|
||||
} from "element-plus";
|
||||
import ExcelJS from "exceljs";
|
||||
import { reactive, ref, computed } from "vue";
|
||||
import { reactive, ref, computed, nextTick, useSlots } from "vue";
|
||||
import CrudToolbarLeft from "./CrudToolbarLeft.vue";
|
||||
import CrudToolbarRight from "./CrudToolbarRight.vue";
|
||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
||||
import type { IContentConfig, IObject, IOperateData } from "./types";
|
||||
import type { IToolsButton } from "./types";
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const cardBodyStyle = {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
flexDirection: "column" as const,
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
padding: "16px 20px",
|
||||
};
|
||||
|
||||
// 定义接收的属性
|
||||
const props = defineProps<{ contentConfig: IContentConfig }>();
|
||||
// 定义自定义事件
|
||||
@@ -355,13 +379,14 @@ const config = computed(() => props.contentConfig);
|
||||
const buttonConfig = reactive<Record<string, IObject>>({
|
||||
add: { text: "新增", attrs: { icon: "plus", type: "success" }, perm: "add" },
|
||||
delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
|
||||
import: { text: "导入", attrs: { icon: "upload", type: "default" }, perm: "import" },
|
||||
export: { text: "导出", attrs: { icon: "download", type: "default" }, perm: "export" },
|
||||
refresh: { text: "刷新", attrs: { icon: "refresh", type: "default" }, perm: "*:*:*" },
|
||||
filter: { text: "筛选列", attrs: { icon: "operation", type: "default" }, perm: "*:*:*" },
|
||||
search: { text: "搜索", attrs: { icon: "search", type: "" }, perm: "search" },
|
||||
imports: { text: "批量导入", attrs: { icon: "upload", type: "" }, perm: "imports" },
|
||||
exports: { text: "批量导出", attrs: { icon: "download", type: "" }, perm: "exports" },
|
||||
/** 右侧圆形工具条:与业务页手写工具栏(如 user、demo)语义色一致 */
|
||||
import: { text: "导入", attrs: { icon: "upload", type: "info" }, perm: "import" },
|
||||
export: { text: "导出", attrs: { icon: "download", type: "warning" }, perm: "export" },
|
||||
refresh: { text: "刷新", attrs: { icon: "refresh", type: "success" }, perm: "*:*:*" },
|
||||
filter: { text: "筛选列", attrs: { icon: "operation", type: "danger" }, perm: "*:*:*" },
|
||||
search: { text: "搜索", attrs: { icon: "search", type: "info" }, perm: "search" },
|
||||
imports: { text: "批量导入", attrs: { icon: "upload", type: "success" }, perm: "imports" },
|
||||
exports: { text: "批量导出", attrs: { icon: "download", type: "warning" }, perm: "exports" },
|
||||
view: { text: "查看", attrs: { icon: "view", type: "primary" }, perm: "view" },
|
||||
edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "edit" },
|
||||
});
|
||||
@@ -393,12 +418,15 @@ function getButtonPerm(action: string): string | null {
|
||||
function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
|
||||
return toolbar.map((item) => {
|
||||
const isString = typeof item === "string";
|
||||
const name = isString ? item : item?.name || "";
|
||||
const base = (isString ? buttonConfig[item] : buttonConfig[name]) as IObject | undefined;
|
||||
return {
|
||||
name: isString ? item : item?.name || "",
|
||||
text: isString ? buttonConfig[item].text : item?.text,
|
||||
name,
|
||||
text: isString ? buttonConfig[item].text : (item?.text ?? base?.text),
|
||||
// 对象写法(如 { name: 'refresh', perm: 'refresh' })需合并 buttonConfig 默认 attrs,否则无 icon/type
|
||||
attrs: {
|
||||
...attr,
|
||||
...(isString ? buttonConfig[item].attrs : item?.attrs),
|
||||
...(isString ? buttonConfig[item].attrs : { ...base?.attrs, ...item?.attrs }),
|
||||
},
|
||||
render: isString ? undefined : (item?.render ?? undefined),
|
||||
perm: isString
|
||||
@@ -416,15 +444,30 @@ const toolbarLeftBtn = computed(() => {
|
||||
return createToolbar(config.value.toolbar, {});
|
||||
});
|
||||
|
||||
const hideColumnFilter = computed(() => {
|
||||
if (config.value.hideColumnFilter === true) return true;
|
||||
if (config.value.hideColumnFilter === false) return false;
|
||||
return Boolean(slots.table);
|
||||
});
|
||||
|
||||
// 右侧工具栏按钮
|
||||
const toolbarRightBtn = computed(() => {
|
||||
if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
|
||||
return createToolbar(config.value.defaultToolbar, { circle: true });
|
||||
const raw = createToolbar(config.value.defaultToolbar, { circle: true });
|
||||
if (hideColumnFilter.value) {
|
||||
return raw.filter((b) => b.name !== "filter");
|
||||
}
|
||||
return raw;
|
||||
});
|
||||
|
||||
// 表格操作工具栏
|
||||
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
|
||||
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
|
||||
// 表格操作工具栏(优先使用 templet 为 tool 的列,避免仅数据列时取错最后一列)
|
||||
const toolColumn = computed(
|
||||
() => config.value.cols.find((c) => c.templet === "tool") ?? config.value.cols.at(-1)
|
||||
);
|
||||
const tableToolbar = computed(() => toolColumn.value?.operat ?? ["edit", "delete"]);
|
||||
const tableToolbarBtn = computed(() =>
|
||||
createToolbar(tableToolbar.value, { link: true, size: "small" })
|
||||
);
|
||||
|
||||
// 表格列
|
||||
const cols = ref(
|
||||
@@ -498,16 +541,22 @@ function handleRefresh(isRestart = false) {
|
||||
|
||||
// 删除
|
||||
function handleDelete(id?: number | string) {
|
||||
const ids = [id || removeIds.value].join(",");
|
||||
let ids = "";
|
||||
if (id !== undefined && id !== null && id !== "") {
|
||||
ids = String(id);
|
||||
} else if (removeIds.value.length) {
|
||||
ids = removeIds.value.map(String).join(",");
|
||||
}
|
||||
if (!ids) {
|
||||
ElMessage.warning("请勾选删除项");
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessageBox.confirm("确认删除?", "警告", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
const dc = props.contentConfig.deleteConfirm;
|
||||
ElMessageBox.confirm(dc?.message ?? "确认删除?", dc?.title ?? "警告", {
|
||||
confirmButtonText: dc?.confirmButtonText ?? "确定",
|
||||
cancelButtonText: dc?.cancelButtonText ?? "取消",
|
||||
type: dc?.type ?? "warning",
|
||||
})
|
||||
.then(function () {
|
||||
if (props.contentConfig.deleteAction) {
|
||||
@@ -849,6 +898,16 @@ function getFilterParams() {
|
||||
|
||||
// 获取分页数据
|
||||
let lastFormData = {};
|
||||
function getIndexActionErrorMessage(err: unknown): string {
|
||||
if (err && typeof err === "object" && "response" in err) {
|
||||
const d = (err as { response?: { data?: { msg?: string; message?: string } } }).response?.data;
|
||||
if (d?.msg) return String(d.msg);
|
||||
if (d?.message) return String(d.message);
|
||||
}
|
||||
if (err instanceof Error && err.message) return err.message;
|
||||
return "数据加载失败";
|
||||
}
|
||||
|
||||
function fetchPageData(formData: IObject = {}, isRestart = false) {
|
||||
loading.value = true;
|
||||
// 上一次搜索条件
|
||||
@@ -878,11 +937,17 @@ function fetchPageData(formData: IObject = {}, isRestart = false) {
|
||||
pageData.value = data;
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error(err);
|
||||
ElMessage.error(getIndexActionErrorMessage(err));
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
fetchPageData();
|
||||
if (props.contentConfig.initialFetch !== false) {
|
||||
fetchPageData();
|
||||
}
|
||||
|
||||
// 导出Excel
|
||||
function exportPageData(formData: IObject = {}) {
|
||||
@@ -919,15 +984,22 @@ function saveXlsx(fileData: any, fileName: string) {
|
||||
}
|
||||
|
||||
// 暴露的属性和方法
|
||||
defineExpose({ fetchPageData, exportPageData, getFilterParams, getSelectionData, handleRefresh });
|
||||
defineExpose({
|
||||
fetchPageData,
|
||||
exportPageData,
|
||||
getFilterParams,
|
||||
getSelectionData,
|
||||
handleRefresh,
|
||||
handleToolbar,
|
||||
handleDelete,
|
||||
pageData,
|
||||
pagination,
|
||||
tableRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
.el-button {
|
||||
margin-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
:deep(.curd-embed-dialog) {
|
||||
padding-right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,9 +66,12 @@
|
||||
</template>
|
||||
<!-- dialog -->
|
||||
<template v-else>
|
||||
<el-dialog
|
||||
<EnhancedDialog
|
||||
v-model="modalVisible"
|
||||
v-bind="{ destroyOnClose: true, alignCenter: true, ...modalConfig.dialog }"
|
||||
:title="String(modalConfig.dialog?.title ?? '')"
|
||||
:width="modalConfig.dialog?.width ?? '600px'"
|
||||
:draggable="modalConfig.dialog?.draggable !== false"
|
||||
v-bind="dialogRestAttrs"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
|
||||
@@ -127,17 +130,19 @@
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</EnhancedDialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw, onMounted, reactive, ref } from "vue";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import type { IComponentType, IModalConfig, IObject } from "./types";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
import IconSelect from "@/components/IconSelect/index.vue";
|
||||
import EnhancedDialog from "./EnhancedDialog.vue";
|
||||
|
||||
defineSlots<{ [key: string]: (_args: any) => any }>();
|
||||
// 定义接收的属性
|
||||
@@ -173,6 +178,12 @@ const childrenMap = new Map<IComponentType, any>([
|
||||
]);
|
||||
|
||||
const pk = props.modalConfig.pk ?? "id"; // 主键名,用于表单数据处理
|
||||
|
||||
const dialogRestAttrs = computed(() => {
|
||||
const d = props.modalConfig.dialog ?? {};
|
||||
const { title: _t, width: _w, draggable: _d, ...rest } = d;
|
||||
return { destroyOnClose: true, ...rest };
|
||||
});
|
||||
const modalVisible = ref(false); // 弹窗显示状态
|
||||
const formRef = ref<FormInstance>(); // 表单实例
|
||||
const formItems = reactive(props.modalConfig.formItems ?? []); // 表单配置项
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<el-card v-bind="cardAttrs">
|
||||
<!-- 搜索表单区域 -->
|
||||
<el-form
|
||||
:key="'page-search-expand-' + isExpand"
|
||||
ref="queryFormRef"
|
||||
label-suffix=":"
|
||||
v-bind="formAttrs"
|
||||
:model="queryParams"
|
||||
:class="isGrid"
|
||||
:inline="true"
|
||||
@submit.prevent="handleQuery"
|
||||
>
|
||||
<template v-for="(item, index) in formItems" :key="item.prop">
|
||||
@@ -30,14 +30,14 @@
|
||||
|
||||
<ElCascader
|
||||
v-if="item.type === 'cascader'"
|
||||
v-model.trim="queryParams[item.prop]"
|
||||
v-model="queryParams[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
v-on="item.events || {}"
|
||||
/>
|
||||
<component
|
||||
:is="getCustomComponent(item.type) || componentMap.get(item.type)"
|
||||
v-else
|
||||
v-model.trim="queryParams[item.prop]"
|
||||
v-model="queryParams[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
v-on="item.events || {}"
|
||||
>
|
||||
@@ -125,7 +125,7 @@ import type { IObject, IForm, ISearchConfig, ISearchComponent } from "./types";
|
||||
import { ArrowUp, ArrowDown, QuestionFilled } from "@element-plus/icons-vue";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
import { getCurrentInstance } from "vue";
|
||||
import { getCurrentInstance, nextTick, watch } from "vue";
|
||||
import {
|
||||
ElInput,
|
||||
ElSelect,
|
||||
@@ -199,19 +199,33 @@ const showNumber = computed(() =>
|
||||
);
|
||||
// 卡片组件自定义属性(阴影、自定义边距样式等)
|
||||
const cardAttrs = computed<IObject>(() => {
|
||||
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
|
||||
return {
|
||||
class: "search-container",
|
||||
shadow: "never",
|
||||
style: { marginBottom: "0" },
|
||||
...props.searchConfig?.cardAttrs,
|
||||
};
|
||||
});
|
||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
||||
const formAttrs = computed<IForm>(() => {
|
||||
return { inline: true, ...props.searchConfig?.form };
|
||||
const custom = props.searchConfig?.form ?? {};
|
||||
if (props.searchConfig?.grid) {
|
||||
return { ...custom, inline: false };
|
||||
}
|
||||
return { inline: true, ...custom };
|
||||
});
|
||||
// 是否使用自适应网格布局
|
||||
/** 默认 flex(scoped);grid 时见 .curd-page-search--grid */
|
||||
const isGrid = computed(() =>
|
||||
props.searchConfig?.grid
|
||||
? "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6 gap-5"
|
||||
: "flex flex-wrap gap-x-8 gap-y-4"
|
||||
props.searchConfig?.grid ? "curd-page-search--grid" : "curd-page-search--flex"
|
||||
);
|
||||
|
||||
/** 展开/收起后:表单已随 :key 重建,再触发 resize 让日期范围等组件量宽 */
|
||||
watch(isExpand, () => {
|
||||
nextTick(() => {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
});
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
@@ -222,8 +236,26 @@ const toggleVisible = () => {
|
||||
visible.value = !visible.value;
|
||||
};
|
||||
|
||||
/** 查询前对 input 做 trim;去掉空条件,避免 GET 出现 name=&created_id= 等导致后端 Optional[int] 解析失败 */
|
||||
function buildQueryPayload() {
|
||||
const q = { ...queryParams };
|
||||
for (const item of formItems) {
|
||||
if (item.type === "input" && typeof q[item.prop] === "string") {
|
||||
q[item.prop] = q[item.prop].trim();
|
||||
}
|
||||
}
|
||||
const out: IObject = {};
|
||||
for (const key of Object.keys(q)) {
|
||||
const v = q[key];
|
||||
if (v === "" || v === null || v === undefined) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[key] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 查询/重置操作
|
||||
const handleQuery = () => emit("queryClick", queryParams);
|
||||
const handleQuery = () => emit("queryClick", buildQueryPayload());
|
||||
const handleReset = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
// 重置所有时间范围组件
|
||||
@@ -232,7 +264,7 @@ const handleReset = () => {
|
||||
ref.reset();
|
||||
}
|
||||
});
|
||||
emit("resetClick", queryParams);
|
||||
emit("resetClick", buildQueryPayload());
|
||||
};
|
||||
|
||||
// 处理时间范围变化
|
||||
@@ -266,8 +298,19 @@ onMounted(() => {
|
||||
if (item?.initFn) {
|
||||
item.initFn(item);
|
||||
}
|
||||
if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
|
||||
if (getCustomComponent(item.type ?? "")) {
|
||||
queryParams[item.prop] = item.initialValue ?? null;
|
||||
} else if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
|
||||
queryParams[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
|
||||
} else if (
|
||||
item.type === "date-picker" &&
|
||||
String(item.attrs?.type ?? "")
|
||||
.toLowerCase()
|
||||
.includes("range")
|
||||
) {
|
||||
queryParams[item.prop] = item.initialValue ?? [];
|
||||
} else if (item.type === "select") {
|
||||
queryParams[item.prop] = item.initialValue !== undefined ? item.initialValue : null;
|
||||
} else if (item.type === "input-number") {
|
||||
queryParams[item.prop] = item.initialValue ?? null;
|
||||
} else {
|
||||
@@ -277,8 +320,8 @@ onMounted(() => {
|
||||
});
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
// 获取分页数据
|
||||
getQueryParams: () => queryParams,
|
||||
// 获取分页数据(与「搜索」提交时一致,含 input trim)
|
||||
getQueryParams: () => buildQueryPayload(),
|
||||
// 显示/隐藏 SearchForm
|
||||
toggleVisible: () => (visible.value = !visible.value),
|
||||
});
|
||||
@@ -288,8 +331,34 @@ defineExpose({
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 卡片参与 flex 布局链时允许高度随内容收回,避免展开后再收起仍「撑住」 */
|
||||
.search-container :deep(.el-card__body) {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 默认行内搜索:明确左起排布,避免展开宽表单项后 flex 残留 justify/end 对齐 */
|
||||
.curd-page-search--flex {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.curd-page-search--grid {
|
||||
display: grid !important;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DialogProps, DrawerProps, FormItemRule, PaginationProps } from "element-plus";
|
||||
import type { FormProps, TableProps, ColProps, ButtonProps, CardProps } from "element-plus";
|
||||
import type { FormProps, ColProps, ButtonProps, CardProps } from "element-plus";
|
||||
import type PageContent from "./PageContent.vue";
|
||||
import type PageModal from "./PageModal.vue";
|
||||
import type PageSearch from "./PageSearch.vue";
|
||||
@@ -35,14 +35,16 @@ export type ISearchComponent =
|
||||
| "checkbox"
|
||||
| "switch"
|
||||
| "rate"
|
||||
| "slider";
|
||||
| "slider"
|
||||
/** 在 customComponents 中注册的自定义搜索控件类型 */
|
||||
| "user-table-select";
|
||||
export type IComponentType = DateComponent | InputComponent | OtherComponent;
|
||||
|
||||
/**
|
||||
* 工具按钮类型定义
|
||||
*/
|
||||
type ToolbarLeft = "add" | "delete" | "import" | "export";
|
||||
type ToolbarRight = "refresh" | "filter" | "imports" | "exports" | "search";
|
||||
type ToolbarRight = "refresh" | "filter" | "import" | "imports" | "exports" | "search";
|
||||
type ToolbarTable = "edit" | "view" | "delete";
|
||||
|
||||
/**
|
||||
@@ -100,7 +102,7 @@ export interface ISearchConfig {
|
||||
cardAttrs?: Partial<CardProps> & { style?: CSSProperties };
|
||||
/** form组件属性 */
|
||||
form?: IForm;
|
||||
/** 自适应网格布局(使用时表单不要添加 style: { width: "200px" }) */
|
||||
/** 启用等宽网格布局;为 "right" 时操作按钮靠行末 */
|
||||
grid?: boolean | "left" | "right";
|
||||
/** 自定义组件映射 */
|
||||
customComponents?: Record<string, any>;
|
||||
@@ -137,8 +139,8 @@ export interface ISearchConfig {
|
||||
export interface IContentConfig<T = any> {
|
||||
/** 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验 */
|
||||
permPrefix?: string;
|
||||
/** table组件属性 */
|
||||
table?: Omit<TableProps<any>, "data">;
|
||||
/** table组件属性(用 IObject 避免与 el-table TableProps.context 等内部类型不兼容) */
|
||||
table?: IObject;
|
||||
/** 页面标题与提示(用于卡片头部显示) */
|
||||
title?: string;
|
||||
/** 提示信息 */
|
||||
@@ -156,6 +158,11 @@ export interface IContentConfig<T = any> {
|
||||
>;
|
||||
/** 列表的网络请求函数(需返回promise) */
|
||||
indexAction: (queryParams: T) => Promise<any>;
|
||||
/**
|
||||
* 是否在挂载时立即请求列表(默认 true)。
|
||||
* 若需在父组件合并 PageSearch 与额外条件后再请求,可设为 false,并在 onMounted 中自行调用 fetchPageData。
|
||||
*/
|
||||
initialFetch?: boolean;
|
||||
/** 默认的分页相关的请求参数 */
|
||||
request?: {
|
||||
page_no: string;
|
||||
@@ -175,6 +182,14 @@ export interface IContentConfig<T = any> {
|
||||
}) => Promise<any>;
|
||||
/** 删除的网络请求函数(需返回promise) */
|
||||
deleteAction?: (ids: string) => Promise<any>;
|
||||
/** 删除确认弹窗(不传则标题「警告」、内容「确认删除?」) */
|
||||
deleteConfirm?: {
|
||||
title?: string;
|
||||
message?: string;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
type?: "warning" | "info" | "success" | "error";
|
||||
};
|
||||
/** 后端导出的网络请求函数(需返回promise) */
|
||||
exportAction?: (queryParams: T) => Promise<any>;
|
||||
/** 前端全量导出的网络请求函数(需返回promise) */
|
||||
@@ -191,6 +206,14 @@ export interface IContentConfig<T = any> {
|
||||
toolbar?: Array<ToolbarLeft | IToolsButton>;
|
||||
/** 表格工具栏右侧图标(默认:refresh,filter,imports,exports,search) */
|
||||
defaultToolbar?: Array<ToolbarRight | IToolsButton>;
|
||||
/** 使用 #table 插槽自定义表格/树表时,为 true 则隐藏「列筛选」按钮(避免与自定义列不同步) */
|
||||
hideColumnFilter?: boolean;
|
||||
/** 内容区外层 el-card 额外 class */
|
||||
cardClass?: string;
|
||||
/** el-card 阴影(默认 never,与 Element Plus el-card shadow 一致) */
|
||||
cardShadow?: "always" | "hover" | "never";
|
||||
/** 是否显示表格上方工具条(默认 true;纯卡片/无按钮时可设为 false) */
|
||||
showToolbar?: boolean;
|
||||
/** 更多操作按钮配置 */
|
||||
moreButtons?: Array<IToolsButton>;
|
||||
/** table组件列属性(额外的属性templet,operat,slotName) */
|
||||
@@ -262,6 +285,16 @@ export interface IContentConfig<T = any> {
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* PageContent 左侧 `createToolbar` 产物,供 CrudToolbarLeft 的 `configButtons` 使用。
|
||||
*/
|
||||
export type CrudToolbarConfigButton = {
|
||||
name: string;
|
||||
text?: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
perm?: string | string[] | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 模态框配置接口
|
||||
*/
|
||||
@@ -274,8 +307,8 @@ export interface IModalConfig<T = any> {
|
||||
pk?: string;
|
||||
/** 组件类型(默认:dialog) */
|
||||
component?: "dialog" | "drawer";
|
||||
/** dialog组件属性 */
|
||||
dialog?: Partial<Omit<DialogProps, "modelValue">>;
|
||||
/** dialog组件属性(默认可拖拽;全屏由 EnhancedDialog 标题栏按钮切换) */
|
||||
dialog?: Partial<Omit<DialogProps, "modelValue">> & { draggable?: boolean };
|
||||
/** drawer组件属性 */
|
||||
drawer?: Partial<Omit<DrawerProps, "modelValue">>;
|
||||
/** 查看模式渲染方式(默认:form;可选:descriptions) */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ref } from "vue";
|
||||
import type { IObject, PageContentInstance, PageSearchInstance } from "./types";
|
||||
|
||||
/**
|
||||
* 仅列表查询区 + 表格区时复用:searchRef / contentRef 与查询、重置拉数逻辑。
|
||||
* 合并 `PageSearch` 查询参数与 `PageContent.getFilterParams()`(表头筛选等),与 usePage 中同名逻辑一致。
|
||||
* 业务页可替换手写 `handleQueryClick` / `handleResetClick` + 双 ref,参见 `module_example/demo`。
|
||||
*/
|
||||
export function useCrudList() {
|
||||
const searchRef = ref<PageSearchInstance>();
|
||||
const contentRef = ref<PageContentInstance>();
|
||||
|
||||
function handleQueryClick(queryParams: IObject) {
|
||||
try {
|
||||
const filterParams = contentRef.value?.getFilterParams() || {};
|
||||
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
|
||||
} catch (error) {
|
||||
console.error("查询数据失败:", error);
|
||||
ElMessage.error("查询数据失败: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetClick(queryParams: IObject) {
|
||||
try {
|
||||
const filterParams = contentRef.value?.getFilterParams() || {};
|
||||
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
|
||||
} catch (error) {
|
||||
console.error("重置数据失败:", error);
|
||||
ElMessage.error("重置数据失败: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹窗提交后等与「查询」同参刷新:合并搜索条件 + 表头筛选 */
|
||||
function refreshList() {
|
||||
try {
|
||||
const q = searchRef.value?.getQueryParams() ?? {};
|
||||
const f = contentRef.value?.getFilterParams() ?? {};
|
||||
contentRef.value?.fetchPageData({ ...q, ...f }, true);
|
||||
} catch (error) {
|
||||
console.error("刷新列表失败:", error);
|
||||
ElMessage.error("刷新列表失败: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
return { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList };
|
||||
}
|
||||
@@ -1,45 +1,17 @@
|
||||
import { ref, Ref } from "vue";
|
||||
import type { IObject, PageContentInstance, PageModalInstance, PageSearchInstance } from "./types";
|
||||
import type { IObject, PageContentInstance, PageModalInstance } from "./types";
|
||||
import { useCrudList } from "./useCrudList";
|
||||
|
||||
/**
|
||||
* CURD页面组合式函数
|
||||
* @returns 包含各种引用和处理函数的对象
|
||||
*/
|
||||
function usePage() {
|
||||
const searchRef = ref<PageSearchInstance>();
|
||||
const contentRef = ref<PageContentInstance>();
|
||||
const { searchRef, contentRef, handleQueryClick, handleResetClick } = useCrudList();
|
||||
const addModalRef = ref<PageModalInstance>();
|
||||
const editModalRef = ref<PageModalInstance>();
|
||||
const viewModalRef = ref<PageModalInstance>();
|
||||
|
||||
/**
|
||||
* 处理查询点击事件
|
||||
* @param queryParams 查询参数
|
||||
*/
|
||||
function handleQueryClick(queryParams: IObject) {
|
||||
try {
|
||||
const filterParams = contentRef.value?.getFilterParams() || {};
|
||||
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
|
||||
} catch (error) {
|
||||
console.error("查询数据失败:", error);
|
||||
ElMessage.error("查询数据失败: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重置点击事件
|
||||
* @param queryParams 查询参数
|
||||
*/
|
||||
function handleResetClick(queryParams: IObject) {
|
||||
try {
|
||||
const filterParams = contentRef.value?.getFilterParams() || {};
|
||||
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
|
||||
} catch (error) {
|
||||
console.error("重置数据失败:", error);
|
||||
ElMessage.error("重置数据失败: " + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理新增点击事件
|
||||
* @param RefImpl 可选的模态框引用
|
||||
@@ -224,3 +196,4 @@ function usePage() {
|
||||
}
|
||||
|
||||
export default usePage;
|
||||
export { useCrudList } from "./useCrudList";
|
||||
|
||||
Reference in New Issue
Block a user