first commiit

This commit is contained in:
2025-02-08 10:15:06 +08:00
parent 6815bd083b
commit 262bf41379
242 changed files with 19959 additions and 1 deletions

View File

@@ -0,0 +1,38 @@
<template>
<component :is="linkType" v-bind="linkProps(to)">
<slot />
</component>
</template>
<script setup lang="ts">
defineOptions({
name: "AppLink",
inheritAttrs: false,
});
import { isExternal } from "@/utils/index";
const props = defineProps({
to: {
type: Object,
required: true,
},
});
const isExternalLink = computed(() => {
return isExternal(props.to.path || "");
});
const linkType = computed(() => (isExternalLink.value ? "a" : "router-link"));
const linkProps = (to: any) => {
if (isExternalLink.value) {
return {
href: to.path,
target: "_blank",
rel: "noopener noreferrer",
};
}
return { to: to };
};
</script>

View File

@@ -0,0 +1,87 @@
<template>
<el-breadcrumb class="flex-y-center">
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
<span
v-if="item.redirect === 'noredirect' || index === breadcrumbs.length - 1"
class="color-gray-400"
>
{{ translateRouteTitle(item.meta.title) }}
</span>
<a v-else @click.prevent="handleLink(item)">
{{ translateRouteTitle(item.meta.title) }}
</a>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup lang="ts">
import { RouteLocationMatched } from "vue-router";
import { compile } from "path-to-regexp";
import router from "@/router";
import { translateRouteTitle } from "@/utils/i18n";
const currentRoute = useRoute();
const pathCompile = (path: string) => {
const { params } = currentRoute;
const toPath = compile(path);
return toPath(params);
};
const breadcrumbs = ref<Array<RouteLocationMatched>>([]);
function getBreadcrumb() {
console.log(currentRoute.matched);
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
const first = matched[0];
if (!isDashboard(first)) {
matched = [{ path: "/", meta: { title: "首页" } } as any].concat(matched);
}
breadcrumbs.value = matched.filter((item) => {
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
});
}
function isDashboard(route: RouteLocationMatched) {
const name = route && route.name;
if (!name) {
return false;
}
console.log(name);
return name.toString().trim().toLocaleLowerCase() === "/".toLocaleLowerCase();
}
function handleLink(item: any) {
const { redirect, path } = item;
if (redirect) {
router.push(redirect).catch((err) => {
console.warn(err);
});
return;
}
router.push(pathCompile(path)).catch((err) => {
console.warn(err);
});
}
watch(
() => currentRoute.path,
(path) => {
if (path.startsWith("/redirect/")) {
return;
}
getBreadcrumb();
}
);
onBeforeMount(() => {
getBreadcrumb();
});
</script>
<style lang="scss" scoped>
// 覆盖 element-plus 的样式
.el-breadcrumb__inner,
.el-breadcrumb__inner a {
font-weight: 400 !important;
}
</style>

View File

@@ -0,0 +1,976 @@
<template>
<el-card shadow="never">
<!-- 表格工具栏 -->
<div class="flex-x-between mb-[10px]">
<!-- 左侧工具栏 -->
<div>
<template v-for="item in toolbar" :key="item">
<template v-if="typeof item === 'string'">
<!-- 新增 -->
<template v-if="item === 'add'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="success"
icon="plus"
@click="handleToolbar(item)"
>
新增
</el-button>
</template>
<!-- 删除 -->
<template v-else-if="item === 'delete'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="danger"
icon="delete"
:disabled="removeIds.length === 0"
@click="handleToolbar(item)"
>
删除
</el-button>
</template>
<!-- 导入 -->
<template v-else-if="item === 'import'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="default"
icon="upload"
@click="handleToolbar(item)"
>
导入
</el-button>
</template>
<!-- 导出 -->
<template v-else-if="item === 'export'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
type="default"
icon="download"
@click="handleToolbar(item)"
>
导出
</el-button>
</template>
</template>
<!-- 其他 -->
<template v-else-if="typeof item === 'object'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item.auth}`]"
:icon="item.icon"
:type="item.type ?? 'default'"
@click="handleToolbar(item.name)"
>
{{ item.text }}
</el-button>
</template>
</template>
</div>
<!-- 右侧工具栏 -->
<div>
<template v-for="item in defaultToolbar" :key="item">
<template v-if="typeof item === 'string'">
<!-- 刷新 -->
<template v-if="item === 'refresh'">
<el-button icon="refresh" circle title="刷新" @click="handleToolbar(item)" />
</template>
<!-- 筛选列 -->
<template v-else-if="item === 'filter'">
<el-popover placement="bottom" trigger="click">
<template #reference>
<el-button icon="Operation" circle title="筛选列" />
</template>
<el-scrollbar max-height="350px">
<template v-for="col in cols" :key="col">
<el-checkbox v-if="col.prop" v-model="col.show" :label="col.label" />
</template>
</el-scrollbar>
</el-popover>
</template>
<!-- 导出 -->
<template v-else-if="item === 'exports'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:export`]"
icon="download"
circle
title="导出"
@click="handleToolbar(item)"
/>
</template>
<!-- 导入 -->
<template v-else-if="item === 'imports'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:import`]"
icon="upload"
circle
title="导入"
@click="handleToolbar(item)"
/>
</template>
<!-- 搜索 -->
<template v-else-if="item === 'search'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:query`]"
icon="search"
circle
title="搜索"
@click="handleToolbar(item)"
/>
</template>
</template>
<!-- 其他 -->
<template v-else-if="typeof item === 'object'">
<template v-if="item.auth">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item.auth}`]"
:icon="item.icon"
circle
:title="item.title"
@click="handleToolbar(item.name)"
/>
</template>
<template v-else>
<el-button
:icon="item.icon"
circle
:title="item.title"
@click="handleToolbar(item.name)"
/>
</template>
</template>
</template>
</div>
</div>
<!-- 列表 -->
<el-table
ref="tableRef"
v-loading="loading"
v-bind="contentConfig.table"
:data="pageData"
:row-key="pk"
@selection-change="handleSelectionChange"
@filter-change="handleFilterChange"
>
<template v-for="col in cols" :key="col">
<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`"
/>
</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="!hasAuth(`${contentConfig.pageName}:modify`)"
@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="!hasAuth(`${contentConfig.pageName}:modify`)"
@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>
<svg-icon :icon-class="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="item in col.operat ?? ['edit', 'delete']" :key="item">
<template v-if="typeof item === 'string'">
<!-- 编辑/删除 -->
<template v-if="item === 'edit' || item === 'delete'">
<el-button
v-hasPerm="[`${contentConfig.pageName}:${item}`]"
:type="item === 'edit' ? 'primary' : 'danger'"
:icon="item"
size="small"
link
@click="
handleOperat({
name: item,
row: scope.row,
column: scope.column,
$index: scope.$index,
})
"
>
{{ item === "edit" ? "编辑" : "删除" }}
</el-button>
</template>
</template>
<!-- 其他 -->
<template v-else-if="typeof item === 'object'">
<el-button
v-if="item.render === undefined || item.render(scope.row)"
v-bind="
item.auth ? { 'v-hasPerm': [`${contentConfig.pageName}:${item.auth}`] } : {}
"
:icon="item.icon"
:type="item.type ?? 'primary'"
size="small"
link
@click="
handleOperat({
name: item.name,
row: scope.row,
column: scope.column,
$index: scope.$index,
})
"
>
{{ item.text }}
</el-button>
</template>
</template>
</template>
<!-- 自定义 -->
<template v-else-if="col.templet === 'custom'">
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
</template>
</template>
</el-table-column>
</template>
</el-table>
<!-- 分页 -->
<template v-if="showPagination">
<el-scrollbar>
<div class="mt-[12px]">
<el-pagination
v-bind="pagination"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-scrollbar>
</template>
<!-- 导出弹窗 -->
<el-dialog
v-model="exportsModalVisible"
:align-center="true"
title="导出数据"
width="600px"
style="padding-right: 0"
@close="handleCloseExportsModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="exportsFormRef"
label-width="auto"
style="padding-right: var(--el-dialog-padding-primary)"
:model="exportsFormData"
:rules="exportsFormRules"
>
<el-form-item label="文件名" prop="filename">
<el-input v-model="exportsFormData.filename" clearable />
</el-form-item>
<el-form-item label="工作表名" prop="sheetname">
<el-input v-model="exportsFormData.sheetname" clearable />
</el-form-item>
<el-form-item label="数据源" prop="origin">
<el-select v-model="exportsFormData.origin">
<el-option label="当前数据 (当前页的数据)" :value="ExportsOriginEnum.CURRENT" />
<el-option
label="选中数据 (所有选中的数据)"
:value="ExportsOriginEnum.SELECTED"
:disabled="selectionData.length <= 0"
/>
<el-option
label="全量数据 (所有分页的数据)"
:value="ExportsOriginEnum.REMOTE"
:disabled="contentConfig.exportsAction === undefined"
/>
</el-select>
</el-form-item>
<el-form-item label="字段" prop="fields">
<el-checkbox-group v-model="exportsFormData.fields">
<template v-for="col in cols" :key="col">
<el-checkbox v-if="col.prop" :value="col.prop" :label="col.label" />
</template>
</el-checkbox-group>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button type="primary" @click="handleExportsSubmit">确 定</el-button>
<el-button @click="handleCloseExportsModal">取 消</el-button>
</div>
</template>
</el-dialog>
<!-- 导入弹窗 -->
<el-dialog
v-model="importModalVisible"
:align-center="true"
title="导入数据"
width="600px"
style="padding-right: 0"
@close="handleCloseImportModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="importFormRef"
label-width="auto"
style="padding-right: var(--el-dialog-padding-primary)"
:model="importFormData"
:rules="importFormRules"
>
<el-form-item label="文件名" prop="files">
<el-upload
ref="uploadRef"
v-model:file-list="importFormData.files"
class="w-full"
accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
:drag="true"
:limit="1"
:auto-upload="false"
:on-exceed="handleFileExceed"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<span>将文件拖到此处,或</span>
<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
*.xlsx / *.xls
<el-link
v-if="contentConfig.importTemplate"
type="primary"
icon="download"
:underline="false"
@click="handleDownloadTemplate"
>
下载模板
</el-link>
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button
type="primary"
:disabled="importFormData.files.length === 0"
@click="handleImportSubmit"
>
确 定
</el-button>
<el-button @click="handleCloseImportModal">取 消</el-button>
</div>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import SvgIcon from "@/components/SvgIcon/index.vue";
import { hasAuth } from "@/plugins/permission";
import { useDateFormat, useThrottleFn } from "@vueuse/core";
import {
genFileId,
type FormInstance,
type FormRules,
type UploadInstance,
type UploadRawFile,
type UploadUserFile,
type TableInstance,
} from "element-plus";
import ExcelJS from "exceljs";
import { reactive, ref } from "vue";
import type { IContentConfig, IObject, IOperatData } from "./types";
// 定义接收的属性
const props = defineProps<{
contentConfig: IContentConfig;
}>();
// 定义自定义事件
const emit = defineEmits<{
addClick: [];
exportClick: [];
searchClick: [];
toolbarClick: [name: string];
editClick: [row: IObject];
operatClick: [data: IOperatData];
filterChange: [data: IObject];
}>();
// 主键
const pk = props.contentConfig.pk ?? "id";
// 表格左侧工具栏
const toolbar = props.contentConfig.toolbar ?? ["add", "delete"];
// 表格右侧工具栏
const defaultToolbar = props.contentConfig.defaultToolbar ?? ["refresh", "filter"];
// 表格列
const cols = ref(
props.contentConfig.cols.map((col) => {
col.initFn && col.initFn(col);
if (col.show === undefined) {
col.show = true;
}
if (col.prop !== undefined && col.columnKey === undefined && col["column-key"] === undefined) {
col.columnKey = col.prop;
}
if (
col.type === "selection" &&
col.reserveSelection === undefined &&
col["reserve-selection"] === undefined
) {
// 配合表格row-key实现跨页多选
col.reserveSelection = true;
}
return col;
})
);
// 加载状态
const loading = ref(false);
// 列表数据
const pageData = ref<IObject[]>([]);
// 显示分页
const showPagination = props.contentConfig.pagination !== false;
// 分页配置
const defalutPagination = {
background: true,
layout: "total, sizes, prev, pager, next, jumper",
pageSize: 20,
pageSizes: [10, 20, 30, 50],
total: 0,
currentPage: 1,
};
const pagination = reactive(
typeof props.contentConfig.pagination === "object"
? { ...defalutPagination, ...props.contentConfig.pagination }
: defalutPagination
);
// 分页相关的请求参数
const request = props.contentConfig.request ?? {
pageName: "pageNum",
limitName: "pageSize",
};
const tableRef = ref<TableInstance>();
// 行选中
const selectionData = ref<IObject[]>([]);
// 删除ID集合 用于批量删除
const removeIds = ref<(number | string)[]>([]);
function handleSelectionChange(selection: any[]) {
selectionData.value = selection;
removeIds.value = selection.map((item) => item[pk]);
}
// 刷新
function handleRefresh(isRestart = false) {
fetchPageData(lastFormData, isRestart);
}
// 删除
function handleDelete(id?: number | string) {
const ids = [id || removeIds.value].join(",");
if (!ids) {
ElMessage.warning("请勾选删除项");
return;
}
ElMessageBox.confirm("确认删除?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(function () {
if (props.contentConfig.deleteAction) {
props.contentConfig.deleteAction(ids).then(() => {
ElMessage.success("删除成功");
removeIds.value = [];
//清空选中项
tableRef.value?.clearSelection();
handleRefresh(true);
});
} else {
ElMessage.error("未配置deleteAction");
}
});
}
// 导出表单
const fields: string[] = [];
cols.value.forEach((item) => {
if (item.prop !== undefined) {
fields.push(item.prop);
}
});
const enum ExportsOriginEnum {
CURRENT = "current",
SELECTED = "selected",
REMOTE = "remote",
}
const exportsModalVisible = ref(false);
const exportsFormRef = ref<FormInstance>();
const exportsFormData = reactive({
filename: "",
sheetname: "",
fields: fields,
origin: ExportsOriginEnum.CURRENT,
});
const exportsFormRules: FormRules = {
fields: [{ required: true, message: "请选择字段" }],
origin: [{ required: true, message: "请选择数据源" }],
};
// 打开导出弹窗
function handleOpenExportsModal() {
exportsModalVisible.value = true;
}
// 导出确认
const handleExportsSubmit = useThrottleFn(() => {
exportsFormRef.value?.validate((valid: boolean) => {
if (valid) {
handleExports();
handleCloseExportsModal();
}
});
}, 3000);
// 关闭导出弹窗
function handleCloseExportsModal() {
exportsModalVisible.value = false;
exportsFormRef.value?.resetFields();
nextTick(() => {
exportsFormRef.value?.clearValidate();
});
}
// 导出
function handleExports() {
const filename = exportsFormData.filename
? exportsFormData.filename
: props.contentConfig.pageName;
const sheetname = exportsFormData.sheetname ? exportsFormData.sheetname : "sheet";
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet(sheetname);
const columns: Partial<ExcelJS.Column>[] = [];
cols.value.forEach((col) => {
if (col.label && col.prop && exportsFormData.fields.includes(col.prop)) {
columns.push({ header: col.label, key: col.prop });
}
});
worksheet.columns = columns;
if (exportsFormData.origin === ExportsOriginEnum.REMOTE) {
if (props.contentConfig.exportsAction) {
props.contentConfig.exportsAction(lastFormData).then((res) => {
worksheet.addRows(res);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename);
})
.catch((error) => console.log(error));
});
} else {
ElMessage.error("未配置exportsAction");
}
} else {
worksheet.addRows(
exportsFormData.origin === ExportsOriginEnum.SELECTED ? selectionData.value : pageData.value
);
workbook.xlsx
.writeBuffer()
.then((buffer) => {
saveXlsx(buffer, filename);
})
.catch((error) => console.log(error));
}
}
// 导入表单
let isFileImport = false;
const uploadRef = ref<UploadInstance>();
const importModalVisible = ref(false);
const importFormRef = ref<FormInstance>();
const importFormData = reactive<{
files: UploadUserFile[];
}>({
files: [],
});
const importFormRules: FormRules = {
files: [{ required: true, message: "请选择文件" }],
};
// 打开导入弹窗
function handleOpenImportModal(isFile: boolean = false) {
importModalVisible.value = true;
isFileImport = isFile;
}
// 覆盖前一个文件
function handleFileExceed(files: File[]) {
uploadRef.value!.clearFiles();
const file = files[0] as UploadRawFile;
file.uid = genFileId();
uploadRef.value!.handleStart(file);
}
// 下载导入模板
function handleDownloadTemplate() {
const importTemplate = props.contentConfig.importTemplate;
if (typeof importTemplate === "string") {
window.open(importTemplate);
} else if (typeof importTemplate === "function") {
importTemplate().then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置importTemplate");
}
}
// 导入确认
const handleImportSubmit = useThrottleFn(() => {
importFormRef.value?.validate((valid: boolean) => {
if (valid) {
if (isFileImport) {
handleImport();
} else {
handleImports();
}
}
});
}, 3000);
// 关闭导入弹窗
function handleCloseImportModal() {
importModalVisible.value = false;
importFormRef.value?.resetFields();
nextTick(() => {
importFormRef.value?.clearValidate();
});
}
// 文件导入
function handleImport() {
const importAction = props.contentConfig.importAction;
if (importAction === undefined) {
ElMessage.error("未配置importAction");
return;
}
importAction(importFormData.files[0].raw as File).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
}
// 导入
function handleImports() {
const importsAction = props.contentConfig.importsAction;
if (importsAction === undefined) {
ElMessage.error("未配置importsAction");
return;
}
// 获取选择的文件
const file = importFormData.files[0].raw as File;
// 创建Workbook实例
const workbook = new ExcelJS.Workbook();
// 使用FileReader对象来读取文件内容
const fileReader = new FileReader();
// 二进制字符串的形式加载文件
fileReader.readAsArrayBuffer(file);
fileReader.onload = (ev) => {
if (ev.target !== null && ev.target.result !== null) {
const result = ev.target.result as ArrayBuffer;
// 从 buffer中加载数据解析
workbook.xlsx
.load(result)
.then((workbook) => {
// 解析后的数据
const data = [];
// 获取第一个worksheet内容
const worksheet = workbook.getWorksheet(1);
if (worksheet) {
// 获取第一行的标题
const fields: any[] = [];
worksheet.getRow(1).eachCell((cell) => {
fields.push(cell.value);
});
// 遍历工作表的每一行(从第二行开始,因为第一行通常是标题行)
for (let rowNumber = 2; rowNumber <= worksheet.rowCount; rowNumber++) {
const rowData: IObject = {};
const row = worksheet.getRow(rowNumber);
// 遍历当前行的每个单元格
row.eachCell((cell, colNumber) => {
// 获取标题对应的键,并将当前单元格的值存储到相应的属性名中
rowData[fields[colNumber - 1]] = cell.value;
});
// 将当前行的数据对象添加到数组中
data.push(rowData);
}
}
if (data.length === 0) {
ElMessage.error("未解析到数据");
return;
}
importsAction(data).then(() => {
ElMessage.success("导入数据成功");
handleCloseImportModal();
handleRefresh(true);
});
})
.catch((error) => console.log(error));
} else {
ElMessage.error("读取文件失败");
}
};
}
// 操作栏
function handleToolbar(name: string) {
switch (name) {
case "refresh":
handleRefresh();
break;
case "exports":
handleOpenExportsModal();
break;
case "imports":
handleOpenImportModal();
break;
case "search":
emit("searchClick");
break;
case "add":
emit("addClick");
break;
case "delete":
handleDelete();
break;
case "import":
handleOpenImportModal(true);
break;
case "export":
emit("exportClick");
break;
default:
emit("toolbarClick", name);
break;
}
}
// 操作列
function handleOperat(data: IOperatData) {
switch (data.name) {
case "edit":
emit("editClick", data.row);
break;
case "delete":
handleDelete(data.row[pk]);
break;
default:
emit("operatClick", data);
break;
}
}
// 属性修改
function handleModify(field: string, value: boolean | string | number, row: Record<string, any>) {
if (props.contentConfig.modifyAction) {
props.contentConfig.modifyAction({
[pk]: row[pk],
field: field,
value: value,
});
} else {
ElMessage.error("未配置modifyAction");
}
}
// 分页切换
function handleSizeChange(value: number) {
pagination.pageSize = value;
handleRefresh();
}
function handleCurrentChange(value: number) {
pagination.currentPage = value;
handleRefresh();
}
// 远程数据筛选
let filterParams: IObject = {};
function handleFilterChange(newFilters: any) {
const filters: IObject = {};
for (const key in newFilters) {
const col = cols.value.find((col) => {
return col.columnKey === key || col["column-key"] === key;
});
if (col && col.filterJoin !== undefined) {
filters[key] = newFilters[key].join(col.filterJoin);
} else {
filters[key] = newFilters[key];
}
}
filterParams = { ...filterParams, ...filters };
emit("filterChange", filterParams);
}
// 获取筛选条件
function getFilterParams() {
return filterParams;
}
// 获取分页数据
let lastFormData = {};
function fetchPageData(formData: IObject = {}, isRestart = false) {
loading.value = true;
// 上一次搜索条件
lastFormData = formData;
// 重置页码
if (isRestart) {
pagination.currentPage = 1;
}
props.contentConfig
.indexAction(
showPagination
? {
[request.pageName]: pagination.currentPage,
[request.limitName]: pagination.pageSize,
...formData,
}
: formData
)
.then((data) => {
if (showPagination) {
if (props.contentConfig.parseData) {
data = props.contentConfig.parseData(data);
}
pagination.total = data.total;
pageData.value = data.list;
} else {
pageData.value = data;
}
})
.finally(() => {
loading.value = false;
});
}
fetchPageData();
// 导出Excel
function exportPageData(formData: IObject = {}) {
if (props.contentConfig.exportAction) {
props.contentConfig.exportAction(formData).then((response) => {
const fileData = response.data;
const fileName = decodeURI(
response.headers["content-disposition"].split(";")[1].split("=")[1]
);
saveXlsx(fileData, fileName);
});
} else {
ElMessage.error("未配置exportAction");
}
}
// 浏览器保存文件
function saveXlsx(fileData: BlobPart, fileName: string) {
const fileType =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8";
const blob = new Blob([fileData], { type: fileType });
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = fileName;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
window.URL.revokeObjectURL(downloadUrl);
}
// 暴露的属性和方法
defineExpose({ fetchPageData, exportPageData, getFilterParams });
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,156 @@
<template>
<el-form ref="formRef" label-width="auto" v-bind="form" :model="formData" :rules="formRules">
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Label -->
<template v-if="item.tips" #label>
<span>
{{ item.label }}
<el-tooltip
placement="bottom"
effect="light"
:content="item.tips"
:raw-content="true"
>
<el-icon style="vertical-align: -0.15em" size="16">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<!-- Input 输入框 -->
<template v-if="item.type === 'input' || item.type === undefined">
<el-input v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option v-bind="option" />
</template>
</el-select>
</template>
<!-- Radio 单选框 -->
<template v-else-if="item.type === 'radio'">
<el-radio-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-radio v-bind="option" />
</template>
</el-radio-group>
</template>
<!-- Checkbox 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<el-checkbox-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-checkbox v-bind="option" />
</template>
</el-checkbox-group>
</template>
<!-- Input Number 数字输入框 -->
<template v-else-if="item.type === 'input-number'">
<el-input-number v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Text 文本 -->
<template v-else-if="item.type === 'text'">
<el-text v-bind="item.attrs">{{ formData[item.prop] }}</el-text>
</template>
<!-- 自定义 -->
<template v-else-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:formData="formData"
:attrs="item.attrs"
/>
</template>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from "element-plus";
import { reactive, ref, watch, watchEffect } from "vue";
import { IObject, IPageForm } from "./types";
// 定义接收的属性
const props = withDefaults(defineProps<IPageForm>(), {
pk: "id",
});
const formRef = ref<FormInstance>();
const formItems = reactive(props.formItems);
const formData = reactive<IObject>({});
const formRules: FormRules = {};
const prepareFuncs = [];
for (const item of formItems) {
item.initFn && item.initFn(item);
formData[item.prop] = item.initialValue ?? "";
formRules[item.prop] = item.rules ?? [];
if (item.watch !== undefined) {
prepareFuncs.push(() => {
watch(
() => formData[item.prop],
(newValue, oldValue) => {
item.watch && item.watch(newValue, oldValue, formData, formItems);
}
);
});
}
if (item.computed !== undefined) {
prepareFuncs.push(() => {
watchEffect(() => {
item.computed && (formData[item.prop] = item.computed(formData));
});
});
}
if (item.watchEffect !== undefined) {
prepareFuncs.push(() => {
watchEffect(() => {
item.watchEffect && item.watchEffect(formData);
});
});
}
}
prepareFuncs.forEach((func) => func());
// 获取表单数据
function getFormData(key?: string) {
return key === undefined ? formData : (formData[key] ?? undefined);
}
// 设置表单值
function setFormData(data: IObject) {
for (const key in formData) {
if (formData.hasOwnProperty(key) && key in data) {
formData[key] = data[key];
}
}
if (data?.hasOwnProperty(props.pk)) {
formData[props.pk] = data[props.pk];
}
}
// 设置表单项值
function setFormItemData(key: string, value: any) {
formData[key] = value;
}
// 暴露的属性和方法
defineExpose({ formRef, getFormData, setFormData, setFormItemData });
</script>

View File

@@ -0,0 +1,377 @@
<template>
<!-- drawer -->
<template v-if="modalConfig.component === 'drawer'">
<el-drawer
v-model="modalVisible"
:append-to-body="true"
v-bind="modalConfig.drawer"
@close="handleCloseModal"
>
<!-- 表单 -->
<el-form
ref="formRef"
label-width="auto"
v-bind="modalConfig.form"
:model="formData"
:rules="formRules"
>
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Label -->
<template v-if="item.tips" #label>
<span>
{{ item.label }}
<el-tooltip
placement="bottom"
effect="light"
:content="item.tips"
:raw-content="true"
>
<el-icon style="vertical-align: -0.15em" size="16">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<!-- Input 输入框 -->
<template v-if="item.type === 'input' || item.type === undefined">
<el-input v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option v-bind="option" />
</template>
</el-select>
</template>
<!-- Radio 单选框 -->
<template v-else-if="item.type === 'radio'">
<el-radio-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-radio v-bind="option" />
</template>
</el-radio-group>
</template>
<!-- switch 开关 -->
<template v-else-if="item.type === 'switch'">
<el-switch v-model="formData[item.prop]" inline-prompt v-bind="item.attrs" />
</template>
<!-- Checkbox 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<el-checkbox-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-checkbox v-bind="option" />
</template>
</el-checkbox-group>
</template>
<!-- Input Number 数字输入框 -->
<template v-else-if="item.type === 'input-number'">
<el-input-number v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Text 文本 -->
<template v-else-if="item.type === 'text'">
<el-text v-bind="item.attrs">
{{ formData[item.prop] }}
</el-text>
</template>
<!-- 自定义 -->
<template v-else-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:formData="formData"
:attrs="item.attrs"
/>
</template>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div v-if="!formDisable">
<el-button type="primary" @click="handleSubmit"> </el-button>
<el-button @click="handleClose"> </el-button>
</div>
<div v-else>
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-drawer>
</template>
<!-- dialog -->
<template v-else>
<el-dialog
v-model="modalVisible"
:align-center="true"
:append-to-body="true"
width="70vw"
v-bind="modalConfig.dialog"
style="padding-right: 0"
@close="handleCloseModal"
>
<!-- 滚动 -->
<el-scrollbar max-height="60vh">
<!-- 表单 -->
<el-form
ref="formRef"
label-width="auto"
v-bind="modalConfig.form"
style="padding-right: var(--el-dialog-padding-primary)"
:model="formData"
:rules="formRules"
>
<el-row :gutter="20">
<template v-for="item in formItems" :key="item.prop">
<el-col v-show="!item.hidden" v-bind="item.col">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Label -->
<template v-if="item.tips" #label>
<span>
{{ item.label }}
<el-tooltip
placement="bottom"
effect="light"
:content="item.tips"
:raw-content="true"
>
<el-icon style="vertical-align: -0.15em" size="16">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<!-- Input 输入框 -->
<template v-if="item.type === 'input' || item.type === undefined">
<el-input v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option v-bind="option" />
</template>
</el-select>
</template>
<!-- Radio 单选框 -->
<template v-else-if="item.type === 'radio'">
<el-radio-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-radio v-bind="option" />
</template>
</el-radio-group>
</template>
<!-- switch 开关 -->
<template v-else-if="item.type === 'switch'">
<el-switch v-model="formData[item.prop]" inline-prompt v-bind="item.attrs" />
</template>
<!-- Checkbox 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<el-checkbox-group v-model="formData[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-checkbox v-bind="option" />
</template>
</el-checkbox-group>
</template>
<!-- Input Number 数字输入框 -->
<template v-else-if="item.type === 'input-number'">
<el-input-number v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="formData[item.prop]" v-bind="item.attrs" />
</template>
<!-- Text 文本 -->
<template v-else-if="item.type === 'text'">
<el-text v-bind="item.attrs">
{{ formData[item.prop] }}
</el-text>
</template>
<!-- 自定义 -->
<template v-else-if="item.type === 'custom'">
<slot
:name="item.slotName ?? item.prop"
:prop="item.prop"
:formData="formData"
:attrs="item.attrs"
/>
</template>
</el-form-item>
</el-col>
</template>
</el-row>
</el-form>
</el-scrollbar>
<!-- 弹窗底部操作按钮 -->
<template #footer>
<div style="padding-right: var(--el-dialog-padding-primary)">
<el-button type="primary" @click="handleSubmit"> </el-button>
<el-button @click="handleClose"> </el-button>
</div>
</template>
</el-dialog>
</template>
</template>
<script setup lang="ts">
import { useThrottleFn } from "@vueuse/core";
import type { FormInstance, FormRules } from "element-plus";
import { nextTick, reactive, ref, watch, watchEffect } from "vue";
import type { IModalConfig, IObject } from "./types";
// 定义接收的属性
const props = defineProps<{
modalConfig: IModalConfig;
}>();
// 自定义事件
const emit = defineEmits<{
submitClick: [];
}>();
const pk = props.modalConfig.pk ?? "id";
const modalVisible = ref(false);
const formRef = ref<FormInstance>();
const formItems = reactive(props.modalConfig.formItems);
const formData = reactive<IObject>({});
const formRules: FormRules = {};
const formDisable = ref(false);
const prepareFuncs = [];
for (const item of formItems) {
item.initFn && item.initFn(item);
formData[item.prop] = item.initialValue ?? "";
formRules[item.prop] = item.rules ?? [];
if (item.watch !== undefined) {
prepareFuncs.push(() => {
watch(
() => formData[item.prop],
(newValue, oldValue) => {
item.watch && item.watch(newValue, oldValue, formData, formItems);
}
);
});
}
if (item.computed !== undefined) {
prepareFuncs.push(() => {
watchEffect(() => {
item.computed && (formData[item.prop] = item.computed(formData));
});
});
}
if (item.watchEffect !== undefined) {
prepareFuncs.push(() => {
watchEffect(() => {
item.watchEffect && item.watchEffect(formData);
});
});
}
}
prepareFuncs.forEach((func) => func());
// 获取表单数据
function getFormData(key?: string) {
return key === undefined ? formData : (formData[key] ?? undefined);
}
// 设置表单值
function setFormData(data: IObject) {
for (const key in formData) {
if (formData.hasOwnProperty(key) && key in data) {
formData[key] = data[key];
}
}
if (data?.hasOwnProperty(pk)) {
formData[pk] = data[pk];
}
}
// 设置表单项值
function setFormItemData(key: string, value: any) {
formData[key] = value;
}
// 显示modal
function setModalVisible(data: IObject = {}) {
modalVisible.value = true;
// nextTick解决赋值后重置表单无效问题
nextTick(() => {
Object.values(data).length > 0 && setFormData(data);
});
}
// 表单提交
const handleSubmit = useThrottleFn(() => {
formRef.value?.validate((valid: boolean) => {
if (valid) {
if (typeof props.modalConfig.beforeSubmit === "function") {
props.modalConfig.beforeSubmit(formData);
}
props.modalConfig.formAction(formData).then(() => {
let msg = "操作成功";
if (props.modalConfig.component === "drawer") {
if (props.modalConfig.drawer?.title) {
msg = `${props.modalConfig.drawer?.title}成功`;
}
} else {
if (props.modalConfig.dialog?.title) {
msg = `${props.modalConfig.dialog?.title}成功`;
}
}
ElMessage.success(msg);
emit("submitClick");
handleClose();
});
}
});
}, 3000);
// 隐藏弹窗
function handleClose() {
modalVisible.value = false;
}
// 关闭弹窗
function handleCloseModal() {
formRef.value?.resetFields();
nextTick(() => {
formRef.value?.clearValidate();
});
}
// 禁用表单--用于详情时候用
function handleDisabled(disable: boolean) {
formDisable.value = disable;
props.modalConfig.formItems.forEach((item) => {
if (item) {
if (item.attrs) {
item.attrs.disabled = disable;
} else {
item.attrs = { disabled: disable };
}
}
});
}
// 暴露的属性和方法
defineExpose({ setModalVisible, getFormData, setFormData, setFormItemData, handleDisabled });
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,239 @@
<template>
<el-card
v-show="visible"
v-hasPerm="[`${searchConfig.pageName}:query`]"
shadow="never"
class="mb-[10px]"
>
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
<template v-for="(item, index) in formItems" :key="item.prop">
<el-form-item
v-show="isExpand ? true : index < showNumber"
:label="item.label"
:prop="item.prop"
>
<!-- Label -->
<template v-if="item.tips" #label>
<span>
{{ item.label }}
<el-tooltip
placement="bottom"
effect="light"
:content="item.tips"
:raw-content="true"
>
<el-icon style="vertical-align: -0.15em" size="16">
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<!-- Input 输入框 -->
<template v-if="item.type === 'input' || item.type === undefined">
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<!-- InputTag 标签输入框 -->
<template v-if="item.type === 'input-tag'">
<div class="flex-center">
<el-tag
v-for="tag in inputTagMap[item.prop].data"
:key="tag"
class="mr-2"
:closable="true"
v-bind="inputTagMap[item.prop].tagAttrs"
@close="handleCloseTag(item.prop, tag)"
>
{{ tag }}
</el-tag>
<template v-if="inputTagMap[item.prop].inputVisible">
<el-input
:ref="(el: HTMLElement) => (inputTagMap[item.prop].inputRef = el)"
v-model="inputTagMap[item.prop].inputValue"
v-bind="inputTagMap[item.prop].inputAttrs"
@keyup.enter="handleInputConfirm(item.prop)"
@blur="handleInputConfirm(item.prop)"
/>
</template>
<template v-else>
<el-button
v-bind="inputTagMap[item.prop].buttonAttrs"
@click="handleShowInput(item.prop)"
>
{{ inputTagMap[item.prop].buttonAttrs.btnText }}
</el-button>
</template>
</div>
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
</template>
</el-select>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
<!-- 展开/收起 -->
<el-link
v-if="isExpandable && formItems.length > showNumber"
class="ml-2"
type="primary"
:underline="false"
@click="isExpand = !isExpand"
>
<template v-if="isExpand">
收起
<el-icon>
<ArrowUp />
</el-icon>
</template>
<template v-else>
展开
<el-icon>
<ArrowDown />
</el-icon>
</template>
</el-link>
</el-form-item>
</el-form>
</el-card>
</template>
<script setup lang="ts">
import type { FormInstance } from "element-plus";
import { reactive, ref } from "vue";
import type { IObject, ISearchConfig } from "./types";
// 定义接收的属性
const props = defineProps<{
searchConfig: ISearchConfig;
}>();
// 自定义事件
const emit = defineEmits<{
queryClick: [queryParams: IObject];
resetClick: [queryParams: IObject];
}>();
const queryFormRef = ref<FormInstance>();
// 是否显示
const visible = ref(true);
// 响应式的formItems
const formItems = reactive(props.searchConfig.formItems);
// 是否可展开/收缩
const isExpandable = ref(props.searchConfig.isExpandable ?? true);
// 是否已展开
const isExpand = ref(false);
// 表单项展示数量,若可展开,超出展示数量的表单项隐藏
const showNumber = computed(() => {
if (isExpandable.value === true) {
return props.searchConfig.showNumber ?? 3;
} else {
return formItems.length;
}
});
// 搜索表单数据
const queryParams = reactive<IObject>({});
const inputTagMap = reactive<IObject>({});
for (const item of formItems) {
item.initFn && item.initFn(item);
if (item.type === "input-tag") {
inputTagMap[item.prop] = {
data: Array.isArray(item.initialValue) ? item.initialValue : [],
inputVisible: false,
inputValue: "",
inputRef: null,
buttonAttrs: {
size: item.attrs?.size ?? "default",
btnText: item.attrs?.btnText ?? "+ New Tag",
style: "color: #b0b2b7",
},
inputAttrs: {
size: item.attrs?.size ?? "default",
clearable: item.attrs?.clearable ?? false,
style: "width: 150px",
},
tagAttrs: {
size: item.attrs?.size ?? "default",
},
};
queryParams[item.prop] = computed({
get() {
return typeof item.attrs?.join === "string"
? inputTagMap[item.prop].data.join(item.attrs.join)
: inputTagMap[item.prop].data;
},
set(value) {
// resetFields时会被调用
inputTagMap[item.prop].data =
typeof item.attrs?.join === "string"
? value.split(item.attrs.join).filter((item: any) => item !== "")
: value;
},
});
} else {
queryParams[item.prop] = item.initialValue ?? "";
}
}
// 重置操作
function handleReset() {
queryFormRef.value?.resetFields();
emit("resetClick", queryParams);
}
// 查询操作
function handleQuery() {
emit("queryClick", queryParams);
}
// 获取分页数据
function getQueryParams() {
return queryParams;
}
// 显示/隐藏 SearchForm
function toggleVisible() {
visible.value = !visible.value;
}
// 关闭标签
function handleCloseTag(prop: string, tag: string) {
inputTagMap[prop].data.splice(inputTagMap[prop].data.indexOf(tag), 1);
}
// 添加标签
function handleInputConfirm(prop: string) {
if (inputTagMap[prop].inputValue) {
inputTagMap[prop].data.push(inputTagMap[prop].inputValue);
}
inputTagMap[prop].inputVisible = false;
inputTagMap[prop].inputValue = "";
}
// 显示标签输入框
function handleShowInput(prop: string) {
inputTagMap[prop].inputVisible = true;
nextTick(() => {
inputTagMap[prop].inputRef.focus();
});
}
// 暴露的属性和方法
defineExpose({ getQueryParams, toggleVisible });
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,272 @@
import type {
DialogProps,
DrawerProps,
FormItemRule,
FormProps,
PaginationProps,
TableProps,
ColProps,
} from "element-plus";
import type PageContent from "./PageContent.vue";
import type PageForm from "./PageForm.vue";
import type PageModal from "./PageModal.vue";
import type PageSearch from "./PageSearch.vue";
export type PageSearchInstance = InstanceType<typeof PageSearch>;
export type PageContentInstance = InstanceType<typeof PageContent>;
export type PageModalInstance = InstanceType<typeof PageModal>;
export type PageFormInstance = InstanceType<typeof PageForm>;
export type IObject = Record<string, any>;
export interface IOperatData {
name: string;
row: IObject;
column: IObject;
$index: number;
}
export interface ISearchConfig {
// 页面名称(参与组成权限标识,如sys:user:xxx)
pageName: string;
// 表单项
formItems: Array<{
// 组件类型(如input,select等)
type?: "input" | "select" | "tree-select" | "date-picker" | "input-tag";
// 标签文本
label: string;
// 标签提示
tips?: string;
// 键名
prop: string;
// 组件属性(input-tag组件支持join,btnText,size属性)
attrs?: IObject;
// 初始值
initialValue?: any;
// 可选项(适用于select组件)
options?: { label: string; value: any }[];
// 初始化数据函数扩展
initFn?: (formItem: IObject) => void;
}>;
// 是否开启展开和收缩
isExpandable?: boolean;
// 默认展示的表单项数量
showNumber?: number;
}
export interface IContentConfig<T = any> {
// 页面名称(参与组成权限标识,如sys:user:xxx)
pageName: string;
// table组件属性
table?: Omit<TableProps<any>, "data">;
// pagination组件属性
pagination?:
| boolean
| Partial<
Omit<
PaginationProps,
"v-model:page-size" | "v-model:current-page" | "total" | "currentPage"
>
>;
// 列表的网络请求函数(需返回promise)
indexAction: (queryParams: T) => Promise<any>;
// 默认的分页相关的请求参数
request?: {
pageName: string;
limitName: string;
};
// 数据格式解析的回调函数
parseData?: (res: any) => {
total: number;
list: IObject[];
[key: string]: any;
};
// 修改属性的网络请求函数(需返回promise)
modifyAction?: (data: {
[key: string]: any;
field: string;
value: boolean | string | number;
}) => Promise<any>;
// 删除的网络请求函数(需返回promise)
deleteAction?: (ids: string) => Promise<any>;
// 后端导出的网络请求函数(需返回promise)
exportAction?: (queryParams: T) => Promise<any>;
// 前端全量导出的网络请求函数(需返回promise)
exportsAction?: (queryParams: T) => Promise<IObject[]>;
// 导入模板
importTemplate?: string | (() => Promise<any>);
// 后端导入的网络请求函数(需返回promise)
importAction?: (file: File) => Promise<any>;
// 前端导入的网络请求函数(需返回promise)
importsAction?: (data: IObject[]) => Promise<any>;
// 主键名(默认为id)
pk?: string;
// 表格工具栏(默认支持add,delete,export,也可自定义)
toolbar?: Array<
| "add"
| "delete"
| "import"
| "export"
| {
auth: string;
icon?: string;
name: string;
text: string;
type?: "primary" | "success" | "warning" | "danger" | "info";
}
>;
// 表格工具栏右侧图标
defaultToolbar?: Array<
| "refresh"
| "filter"
| "imports"
| "exports"
| "search"
| {
name: string;
icon: string;
title?: string;
auth?: string;
}
>;
// table组件列属性(额外的属性templet,operat,slotName)
cols: Array<{
type?: "default" | "selection" | "index" | "expand";
label?: string;
prop?: string;
width?: string | number;
align?: "left" | "center" | "right";
columnKey?: string;
reserveSelection?: boolean;
// 列是否显示
show?: boolean;
// 模板
templet?:
| "image"
| "list"
| "url"
| "switch"
| "input"
| "price"
| "percent"
| "icon"
| "date"
| "tool"
| "custom";
// image模板相关参数
imageWidth?: number;
imageHeight?: number;
// list模板相关参数
selectList?: IObject;
// switch模板相关参数
activeValue?: boolean | string | number;
inactiveValue?: boolean | string | number;
activeText?: string;
inactiveText?: string;
// input模板相关参数
inputType?: string;
// price模板相关参数
priceFormat?: string;
// date模板相关参数
dateFormat?: string;
// tool模板相关参数
operat?: Array<
| "edit"
| "delete"
| {
auth?: string;
icon?: string;
name: string;
text: string;
type?: "primary" | "success" | "warning" | "danger" | "info";
render?: (row: IObject) => boolean;
}
>;
// filter值拼接符
filterJoin?: string;
[key: string]: any;
// 初始化数据函数
initFn?: (item: IObject) => void;
}>;
}
export interface IModalConfig<T = any> {
// 页面名称
pageName?: string;
// 主键名(主要用于编辑数据,默认为id)
pk?: string;
// 组件类型
component?: "dialog" | "drawer";
// dialog组件属性
dialog?: Partial<Omit<DialogProps, "modelValue">>;
// drawer组件属性
drawer?: Partial<Omit<DrawerProps, "modelValue">>;
// form组件属性
form?: IForm;
// 表单项
formItems: IFormItems<T>;
// 提交之前处理
beforeSubmit?: (data: T) => void;
// 提交的网络请求函数(需返回promise)
formAction: (data: T) => Promise<any>;
}
export type IForm = Partial<Omit<FormProps, "model" | "rules">>;
// 表单项
export type IFormItems<T = any> = Array<{
// 组件类型(如input,select,radio,custom等默认input)
type?:
| "input"
| "select"
| "radio"
| "switch"
| "checkbox"
| "tree-select"
| "date-picker"
| "input-number"
| "text"
| "custom";
// 组件属性
attrs?: IObject;
// 组件可选项(适用于select,radio,checkbox组件)
options?: Array<{
label: string;
value: any;
disabled?: boolean;
[key: string]: any;
}>;
// 插槽名(适用于组件类型为custom)
slotName?: string;
// 标签文本
label: string;
// 标签提示
tips?: string;
// 键名
prop: string;
// 验证规则
rules?: FormItemRule[];
// 初始值
initialValue?: any;
// 是否隐藏
hidden?: boolean;
// layout组件Col属性
col?: Partial<ColProps>;
// 监听函数
watch?: (newValue: any, oldValue: any, data: T, items: IObject[]) => void;
// 计算属性函数
computed?: (data: T) => any;
// 监听收集函数
watchEffect?: (data: T) => void;
// 初始化数据函数扩展
initFn?: (item: IObject) => void;
}>;
export interface IPageForm {
// 主键名(主要用于编辑数据,默认为id)
pk?: string;
// form组件属性
form?: IForm;
// 表单项
formItems: IFormItems;
}

View File

@@ -0,0 +1,68 @@
import { ref } from "vue";
import type { IObject, PageContentInstance, PageModalInstance, PageSearchInstance } from "./types";
function usePage() {
const searchRef = ref<PageSearchInstance>();
const contentRef = ref<PageContentInstance>();
const addModalRef = ref<PageModalInstance>();
const editModalRef = ref<PageModalInstance>();
// 搜索
function handleQueryClick(queryParams: IObject) {
const filterParams = contentRef.value?.getFilterParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
// 重置
function handleResetClick(queryParams: IObject) {
const filterParams = contentRef.value?.getFilterParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
// 新增
function handleAddClick() {
//显示添加表单
addModalRef.value?.setModalVisible();
}
// 编辑
function handleEditClick(row: IObject) {
//显示编辑表单 根据数据进行填充
editModalRef.value?.setModalVisible(row);
}
// 表单提交
function handleSubmitClick() {
//根据检索条件刷新列表数据
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.fetchPageData(queryParams, true);
}
// 导出
function handleExportClick() {
// 根据检索条件导出数据
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.exportPageData(queryParams);
}
// 搜索显隐
function handleSearchClick() {
searchRef.value?.toggleVisible();
}
// 涮选数据
function handleFilterChange(filterParams: IObject) {
const queryParams = searchRef.value?.getQueryParams();
contentRef.value?.fetchPageData({ ...queryParams, ...filterParams }, true);
}
return {
searchRef,
contentRef,
addModalRef,
editModalRef,
handleQueryClick,
handleResetClick,
handleAddClick,
handleEditClick,
handleSubmitClick,
handleExportClick,
handleSearchClick,
handleFilterChange,
};
}
export default usePage;

View File

@@ -0,0 +1,62 @@
<!-- 复制组件 -->
<template>
<el-button link :style="style" @click="handleClipboard">
<slot>
<el-icon><DocumentCopy color="var(--el-color-primary)" /></el-icon>
</slot>
</el-button>
</template>
<script setup lang="ts">
defineOptions({
name: "CopyButton",
inheritAttrs: false,
});
const props = defineProps({
text: {
type: String,
default: "",
},
style: {
type: Object,
default: () => ({}),
},
});
function handleClipboard() {
if (navigator.clipboard && navigator.clipboard.writeText) {
// 使用 Clipboard API
navigator.clipboard
.writeText(props.text)
.then(() => {
ElMessage.success("Copy successfully");
})
.catch((error) => {
ElMessage.warning("Copy failed");
console.log("[CopyButton] Copy failed", error);
});
} else {
// 兼容性处理useClipboard 有兼容性问题)
const input = document.createElement("input");
input.style.position = "absolute";
input.style.left = "-9999px";
input.setAttribute("value", props.text);
document.body.appendChild(input);
input.select();
try {
const successful = document.execCommand("copy");
if (successful) {
ElMessage.success("Copy successfully!");
} else {
ElMessage.warning("Copy failed!");
}
} catch (err) {
ElMessage.error("Copy failed.");
console.log("[CopyButton] Copy failed.", err);
} finally {
document.body.removeChild(input);
}
}
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<template v-if="tagType">
<el-tag :type="tagType" :size="tagSize">{{ label }}</el-tag>
</template>
<template v-else>
<span>{{ label }}</span>
</template>
</template>
<script setup lang="ts">
import { useDictStore } from "@/store";
const dictStore = useDictStore();
const props = defineProps({
code: String,
modelValue: [String, Number],
size: {
type: String,
default: "default",
},
});
const label = ref("");
const tagType = ref<"success" | "warning" | "info" | "primary" | "danger" | undefined>();
const tagSize = ref(props.size as "default" | "large" | "small");
const getLabelAndTagByValue = async (dictCode: string, value: any) => {
// 先从本地缓存中获取字典数据
const dictData = dictStore.getDictionary(dictCode);
// 查找对应的字典项
const dictEntry = dictData.find((item: any) => item.value == value);
return {
label: dictEntry ? dictEntry.label : "",
tag: dictEntry ? dictEntry.tagType : undefined,
};
};
// 监听 props 的变化,获取并更新 label 和 tag
const fetchLabelAndTag = async () => {
const result = await getLabelAndTagByValue(props.code as string, props.modelValue);
label.value = result.label;
tagType.value = result.tag as "success" | "warning" | "info" | "primary" | "danger" | undefined;
};
// 首次挂载时获取字典数据
onMounted(fetchLabelAndTag);
// 当 modelValue 发生变化时重新获取
watch(() => props.modelValue, fetchLabelAndTag);
</script>

View File

@@ -0,0 +1,140 @@
<template>
<el-select
v-if="type === 'select'"
v-model="selectedValue"
:placeholder="placeholder"
:disabled="disabled"
clearable
:style="style"
@change="handleChange"
>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-radio-group
v-else-if="type === 'radio'"
v-model="selectedValue"
:disabled="disabled"
:style="style"
@change="handleChange"
>
<el-radio
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
>
{{ option.label }}
</el-radio>
</el-radio-group>
<el-checkbox-group
v-else-if="type === 'checkbox'"
v-model="selectedValue"
:disabled="disabled"
:style="style"
@change="handleChange"
>
<el-checkbox
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
>
{{ option.label }}
</el-checkbox>
</el-checkbox-group>
</template>
<script setup lang="ts">
import { useDictStore } from "@/store";
const dictStore = useDictStore();
const props = defineProps({
code: {
type: String,
required: true,
},
modelValue: {
type: [String, Number, Array],
required: false,
},
type: {
type: String,
default: "select",
validator: (value: string) => ["select", "radio", "checkbox"].includes(value),
},
placeholder: {
type: String,
default: "请选择",
},
disabled: {
type: Boolean,
default: false,
},
style: {
type: Object,
default: () => {
return {
width: "300px",
};
},
},
});
const emit = defineEmits(["update:modelValue"]);
const options = ref<Array<{ label: string; value: string | number }>>([]);
const selectedValue = ref<any>(
typeof props.modelValue === "string" || typeof props.modelValue === "number"
? props.modelValue
: Array.isArray(props.modelValue)
? props.modelValue
: undefined
);
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newValue) => {
if (props.type === "checkbox") {
selectedValue.value = Array.isArray(newValue) ? newValue : [];
} else {
selectedValue.value = newValue?.toString() || "";
}
},
{ immediate: true }
);
// 监听 options 变化并重新匹配 selectedValue
watch(
() => options.value,
(newOptions) => {
// options 加载后,确保 selectedValue 可以正确匹配到 options
if (newOptions.length > 0 && selectedValue.value !== undefined) {
const matchedOption = newOptions.find((option) => option.value === selectedValue.value);
if (!matchedOption && props.type !== "checkbox") {
// 如果找不到匹配项,清空选中
selectedValue.value = "";
}
}
}
);
// 监听 selectedValue 的变化并触发 update:modelValue
function handleChange(val: any) {
emit("update:modelValue", val);
}
// 获取字典数据
onMounted(() => {
options.value = dictStore.getDictionary(props.code);
});
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div @click="toggle">
<svg-icon :icon-class="isFullscreen ? 'fullscreen-exit' : 'fullscreen'" />
</div>
</template>
<script setup lang="ts">
const { isFullscreen, toggle } = useFullscreen();
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,62 @@
<template>
<a
href="https://github.com/youlaitech"
target="_blank"
class="github-corner"
aria-label="View source on Github"
>
<svg
width="80"
height="80"
viewBox="0 0 250 250"
style="color: #fff; fill: #40c9c6"
aria-hidden="true"
>
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z" />
<path
d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
fill="currentColor"
style="transform-origin: 130px 106px"
class="octo-arm"
/>
<path
d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
fill="currentColor"
class="octo-body"
/>
</svg>
</a>
</template>
<style scoped>
.github-corner:hover .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
@keyframes octocat-wave {
0%,
100% {
transform: rotate(0);
}
20%,
60% {
transform: rotate(-25deg);
}
40%,
80% {
transform: rotate(10deg);
}
}
@media (width <= 500px) {
.github-corner .octo-arm {
animation: octocat-wave 560ms ease-in-out;
}
.github-corner:hover .octo-arm {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<!-- 汉堡按钮组件展开/收缩菜单 -->
<template>
<div
class="px-[15px] flex items-center justify-center color-[var(--el-text-color-regular)]"
@click="toggleClick"
>
<svg-icon icon-class="collapse" :class="{ hamburger: true, 'is-active': isActive }" />
</div>
</template>
<script setup lang="ts">
defineProps({
isActive: {
required: true,
type: Boolean,
default: false,
},
});
const emit = defineEmits(["toggleClick"]);
function toggleClick() {
emit("toggleClick");
}
</script>
<style scoped lang="scss">
.hamburger {
vertical-align: middle;
cursor: pointer;
transform: scaleX(-1);
}
.hamburger.is-active {
transform: scaleX(1);
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<div ref="iconSelectRef" :style="{ width: props.width }">
<el-popover :visible="popoverVisible" :width="props.width" placement="bottom-end">
<template #reference>
<div @click="popoverVisible = !popoverVisible">
<slot>
<el-input v-model="selectedIcon" readonly placeholder="点击选择图标" class="reference">
<template #prepend>
<!-- 根据图标类型展示 -->
<el-icon v-if="isElementIcon">
<component :is="selectedIcon.replace('el-icon-', '')" />
</el-icon>
<template v-else>
<svg-icon :icon-class="selectedIcon" />
</template>
</template>
<template #suffix>
<!-- 清空按钮 -->
<el-icon
v-if="selectedIcon"
style="margin-right: 8px"
@click.stop="clearSelectedIcon"
>
<CircleClose />
</el-icon>
<el-icon
:style="{
transform: popoverVisible ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform .5s',
}"
>
<ArrowDown @click.stop="togglePopover" />
</el-icon>
</template>
</el-input>
</slot>
</div>
</template>
<!-- 图标选择弹窗 -->
<div ref="popoverContentRef">
<el-input v-model="filterText" placeholder="搜索图标" clearable @input="filterIcons" />
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="SVG 图标" name="svg">
<el-scrollbar height="300px">
<ul class="icon-grid">
<li
v-for="icon in filteredSvgIcons"
:key="'svg-' + icon"
class="icon-grid-item"
@click="selectIcon(icon)"
>
<el-tooltip :content="icon" placement="bottom" effect="light">
<svg-icon :icon-class="icon" />
</el-tooltip>
</li>
</ul>
</el-scrollbar>
</el-tab-pane>
<el-tab-pane label="Element 图标" name="element">
<el-scrollbar height="300px">
<ul class="icon-grid">
<li
v-for="icon in filteredElementIcons"
:key="icon"
class="icon-grid-item"
@click="selectIcon(icon)"
>
<el-icon>
<component :is="icon" />
</el-icon>
</li>
</ul>
</el-scrollbar>
</el-tab-pane>
</el-tabs>
</div>
</el-popover>
</div>
</template>
<script setup lang="ts">
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
const props = defineProps({
modelValue: {
type: String,
default: "",
},
width: {
type: String,
default: "500px",
},
});
const emit = defineEmits(["update:modelValue"]);
const iconSelectRef = ref();
const popoverContentRef = ref();
const popoverVisible = ref(false);
const activeTab = ref("svg");
const svgIcons = ref<string[]>([]);
const elementIcons = ref<string[]>(Object.keys(ElementPlusIconsVue));
const selectedIcon = defineModel("modelValue", {
type: String,
required: true,
default: "",
});
const filterText = ref("");
const filteredSvgIcons = ref<string[]>([]);
const filteredElementIcons = ref<string[]>(elementIcons.value);
const isElementIcon = computed(() => {
return selectedIcon.value && selectedIcon.value.startsWith("el-icon");
});
function loadIcons() {
const icons = import.meta.glob("../../assets/icons/*.svg");
for (const path in icons) {
const iconName = path.replace(/.*\/(.*)\.svg$/, "$1");
svgIcons.value.push(iconName);
}
filteredSvgIcons.value = svgIcons.value;
}
function handleTabClick(tabPane: any) {
activeTab.value = tabPane.props.name;
filterIcons();
}
function filterIcons() {
if (activeTab.value === "svg") {
filteredSvgIcons.value = filterText.value
? svgIcons.value.filter((icon) => icon.toLowerCase().includes(filterText.value.toLowerCase()))
: svgIcons.value;
} else {
filteredElementIcons.value = filterText.value
? elementIcons.value.filter((icon) =>
icon.toLowerCase().includes(filterText.value.toLowerCase())
)
: elementIcons.value;
}
}
function selectIcon(icon: string) {
const iconName = activeTab.value === "element" ? "el-icon-" + icon : icon;
emit("update:modelValue", iconName);
popoverVisible.value = false;
}
function togglePopover() {
popoverVisible.value = !popoverVisible.value;
}
onClickOutside(iconSelectRef, () => (popoverVisible.value = false), {
ignore: [popoverContentRef],
});
/**
* 清空已选图标
*/
function clearSelectedIcon() {
selectedIcon.value = "";
}
onMounted(() => {
loadIcons();
if (selectedIcon.value) {
if (elementIcons.value.includes(selectedIcon.value.replace("el-icon-", ""))) {
activeTab.value = "element";
} else {
activeTab.value = "svg";
}
}
});
</script>
<style scoped lang="scss">
.reference :deep(.el-input__wrapper),
.reference :deep(.el-input__inner) {
cursor: pointer;
}
.icon-grid {
display: flex;
flex-wrap: wrap;
}
.icon-grid-item {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
margin: 4px;
cursor: pointer;
border: 1px solid #dcdfe6;
border-radius: 4px;
transition: all 0.3s;
}
.icon-grid-item:hover {
border-color: #4080ff;
transform: scale(1.2);
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<el-dropdown trigger="click" @command="handleLanguageChange">
<div>
<svg-icon icon-class="language" :size="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item in langOptions"
:key="item.value"
:disabled="appStore.language === item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useAppStore } from "@/store/modules/app";
import { LanguageEnum } from "@/enums/LanguageEnum";
defineProps({
size: {
type: String,
required: false,
},
});
const langOptions = [
{ label: "中文", value: LanguageEnum.ZH_CN },
{ label: "English", value: LanguageEnum.EN },
];
const appStore = useAppStore();
const { locale, t } = useI18n();
function handleLanguageChange(lang: string) {
locale.value = lang;
appStore.changeLanguage(lang);
ElMessage.success(t("langSelect.message.success"));
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div @click="openSearchModal">
<svg-icon icon-class="search" />
<el-dialog
v-model="isModalVisible"
width="30%"
:append-to-body="true"
:show-close="false"
@close="closeSearchModal"
>
<template #header>
<el-input
ref="searchInputRef"
v-model="searchKeyword"
size="large"
placeholder="输入菜单名称关键字搜索"
clearable
@keyup.enter="selectActiveResult"
@input="updateSearchResults"
@keydown.up.prevent="navigateResults('up')"
@keydown.down.prevent="navigateResults('down')"
@keydown.esc="closeSearchModal"
>
<template #prepend>
<el-button icon="Search" />
</template>
</el-input>
</template>
<div class="search-result">
<ul v-if="displayResults.length > 0">
<li
v-for="(item, index) in displayResults"
:key="item.path"
:class="{ active: index === activeIndex }"
@click="navigateToRoute(item)"
>
<el-icon v-if="item.icon && item.icon.startsWith('el-icon')">
<component :is="item.icon.replace('el-icon-', '')" />
</el-icon>
<svg-icon v-else-if="item.icon" :icon-class="item.icon" />
<svg-icon v-else icon-class="menu" />
{{ item.title }}
</li>
</ul>
<el-empty v-else description="暂无数据" />
</div>
<template #footer>
<div class="dialog-footer">
<svg-icon icon-class="enter" size="20px" />
<span>选择</span>
<svg-icon icon-class="down" size="20px" class="ml-5" />
<svg-icon icon-class="up" size="20px" class="ml-1" />
<span>切换</span>
<svg-icon icon-class="esc" size="20px" class="ml-5" />
<span>退出</span>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import router from "@/router";
import { usePermissionStore } from "@/store";
import { isExternal } from "@/utils";
import { RouteRecordRaw } from "vue-router";
const permissionStore = usePermissionStore();
const isModalVisible = ref(false);
const searchKeyword = ref("");
const searchInputRef = ref();
const excludedRoutes = ref(["/redirect", "/login", "/401", "/404"]);
const menuItems = ref<SearchItem[]>([]);
const searchResults = ref<SearchItem[]>([]);
const activeIndex = ref(-1);
interface SearchItem {
title: string;
path: string;
name?: string;
icon?: string;
redirect?: string;
}
// 打开搜索模态框
function openSearchModal() {
searchKeyword.value = "";
activeIndex.value = -1;
isModalVisible.value = true;
setTimeout(() => {
searchInputRef.value.focus();
}, 100);
}
// 关闭搜索模态框
function closeSearchModal() {
isModalVisible.value = false;
}
// 更新搜索结果
function updateSearchResults() {
activeIndex.value = -1;
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase();
searchResults.value = menuItems.value.filter((item) =>
item.title.toLowerCase().includes(keyword)
);
} else {
searchResults.value = [];
}
}
// 显示搜索结果
const displayResults = computed(() => searchResults.value);
// 执行搜索
function selectActiveResult() {
if (displayResults.value.length > 0 && activeIndex.value >= 0) {
navigateToRoute(displayResults.value[activeIndex.value]);
}
}
// 导航搜索结果
function navigateResults(direction: string) {
if (displayResults.value.length === 0) return;
if (direction === "up") {
activeIndex.value =
activeIndex.value <= 0 ? displayResults.value.length - 1 : activeIndex.value - 1;
} else if (direction === "down") {
activeIndex.value =
activeIndex.value >= displayResults.value.length - 1 ? 0 : activeIndex.value + 1;
}
}
// 跳转到
function navigateToRoute(item: SearchItem) {
closeSearchModal();
if (isExternal(item.path)) {
window.open(item.path, "_blank");
} else {
router.push(item.path);
}
}
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
routes.forEach((route) => {
const path = route.path.startsWith("/") ? route.path : `${parentPath}/${route.path}`;
if (excludedRoutes.value.includes(route.path) || isExternal(route.path)) return;
if (route.children) {
loadRoutes(route.children, path);
} else if (route.meta?.title) {
const title = route.meta.title === "dashboard" ? "首页" : route.meta.title;
menuItems.value.push({
title,
path,
name: typeof route.name === "string" ? route.name : undefined,
icon: route.meta.icon,
redirect: typeof route.redirect === "string" ? route.redirect : undefined,
});
}
});
}
// 初始化路由数据
onMounted(() => {
loadRoutes(permissionStore.routes);
});
</script>
<style scoped>
.search-result {
max-height: 400px;
overflow-y: auto;
}
.search-result ul li {
padding: 10px;
line-height: 40px;
text-align: left;
cursor: pointer;
}
.search-result ul li.active {
background-color: #e6f7ff;
}
.search-result ul li:hover {
background-color: #f5f5f5;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: start;
svg {
display: flex;
align-items: center;
justify-content: center;
padding: 0 2px;
margin-right: 0.4em;
color: #909399;
background: rgb(125 125 125 / 10%);
border: 0;
border-radius: 2px;
box-shadow:
inset 0 -2px 0 0 #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px rgb(30 35 90 / 40%);
}
span {
font-size: 12px;
color: #909399;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<el-scrollbar>
<div :class="{ hidden: hidden }" class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-scrollbar>
</template>
<script setup lang="ts">
const props = defineProps({
total: {
required: true,
type: Number as PropType<number>,
default: 0,
},
pageSizes: {
type: Array as PropType<number[]>,
default() {
return [10, 20, 30, 50];
},
},
layout: {
type: String,
default: "total, sizes, prev, pager, next, jumper",
},
background: {
type: Boolean,
default: true,
},
autoScroll: {
type: Boolean,
default: true,
},
hidden: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["pagination"]);
const currentPage = defineModel("page", {
type: Number,
required: true,
default: 1,
});
const pageSize = defineModel("limit", {
type: Number,
required: true,
default: 10,
});
watch(
() => props.total,
(newVal: number) => {
const lastPage = Math.ceil(newVal / pageSize.value);
if (newVal > 0 && currentPage.value > lastPage) {
currentPage.value = lastPage;
emit("pagination", { page: currentPage.value, limit: pageSize.value });
}
}
);
function handleSizeChange(val: number) {
currentPage.value = 1;
emit("pagination", { page: currentPage.value, limit: val });
}
function handleCurrentChange(val: number) {
emit("pagination", { page: val, limit: pageSize.value });
}
</script>
<style lang="scss" scoped>
.pagination {
padding: 12px;
&.hidden {
display: none;
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<!-- 布局大小 -->
<el-tooltip :content="$t('sizeSelect.tooltip')" effect="dark" placement="bottom">
<el-dropdown trigger="click" @command="handleSizeChange">
<div>
<svg-icon icon-class="size" />
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="item of sizeOptions"
:key="item.value"
:disabled="appStore.size == item.value"
:command="item.value"
>
{{ item.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-tooltip>
</template>
<script setup lang="ts">
import { SizeEnum } from "@/enums/SizeEnum";
import { useAppStore } from "@/store/modules/app";
const { t } = useI18n();
const sizeOptions = computed(() => {
return [
{ label: t("sizeSelect.default"), value: SizeEnum.DEFAULT },
{ label: t("sizeSelect.large"), value: SizeEnum.LARGE },
{ label: t("sizeSelect.small"), value: SizeEnum.SMALL },
];
});
const appStore = useAppStore();
function handleSizeChange(size: string) {
appStore.changeSize(size);
ElMessage.success(t("sizeSelect.message.success"));
}
</script>

View File

@@ -0,0 +1,41 @@
<template>
<svg aria-hidden="true" class="svg-icon" :style="'width:' + size + ';height:' + size">
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>
<script setup lang="ts">
const props = defineProps({
prefix: {
type: String,
default: "icon",
},
iconClass: {
type: String,
required: false,
default: "",
},
color: {
type: String,
default: "",
},
size: {
type: String,
default: "1em",
},
});
const symbolId = computed(() => `#${props.prefix}-${props.iconClass}`);
</script>
<style scoped>
.svg-icon {
display: inline-block;
width: 1em;
height: 1em;
overflow: hidden;
vertical-align: -0.15em; /* 因icon大小被设置为和字体大小一致而span等标签的下边缘会和字体的基线对齐故需设置一个往下的偏移比例来纠正视觉上的未对齐效果 */
outline: none;
fill: currentcolor; /* 定义元素的颜色currentColor是一个变量这个变量的值就表示当前元素的color值如果当前元素未设置color值则从父元素继承 */
}
</style>

View File

@@ -0,0 +1,357 @@
<template>
<div ref="tableSelectRef" :style="'width:' + width">
<el-popover
:visible="popoverVisible"
:width="popoverWidth"
placement="bottom-end"
v-bind="selectConfig.popover"
@show="handleShow"
>
<template #reference>
<div @click="popoverVisible = !popoverVisible">
<slot>
<el-input
class="reference"
:model-value="text"
:readonly="true"
:placeholder="placeholder"
>
<template #suffix>
<el-icon
:style="{
transform: popoverVisible ? 'rotate(180deg)' : 'rotate(0)',
transition: 'transform .5s',
}"
>
<ArrowDown />
</el-icon>
</template>
</el-input>
</slot>
</div>
</template>
<!-- 弹出框内容 -->
<div ref="popoverContentRef">
<!-- 表单 -->
<el-form ref="formRef" :model="queryParams" :inline="true">
<template v-for="item in selectConfig.formItems" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop">
<!-- Input 输入框 -->
<template v-if="item.type === 'input'">
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
<!-- Select 选择器 -->
<template v-else-if="item.type === 'select'">
<el-select v-model="queryParams[item.prop]" v-bind="item.attrs">
<template v-for="option in item.options" :key="option.value">
<el-option :label="option.label" :value="option.value" />
</template>
</el-select>
</template>
<!-- TreeSelect 树形选择 -->
<template v-else-if="item.type === 'tree-select'">
<el-tree-select v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
<!-- DatePicker 日期选择器 -->
<template v-else-if="item.type === 'date-picker'">
<el-date-picker v-model="queryParams[item.prop]" v-bind="item.attrs" />
</template>
<!-- Input 输入框 -->
<template v-else>
<template v-if="item.attrs?.type === 'number'">
<el-input
v-model.number="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
<template v-else>
<el-input
v-model="queryParams[item.prop]"
v-bind="item.attrs"
@keyup.enter="handleQuery"
/>
</template>
</template>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="handleQuery">搜索</el-button>
<el-button icon="refresh" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="pageData"
:border="true"
:max-height="250"
:row-key="pk"
:highlight-current-row="true"
:class="{ radio: !isMultiple }"
@select="handleSelect"
@select-all="handleSelectAll"
>
<template v-for="col in selectConfig.tableColumns" :key="col.prop">
<!-- 自定义 -->
<template v-if="col.templet === 'custom'">
<el-table-column v-bind="col">
<template #default="scope">
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
</template>
</el-table-column>
</template>
<!-- 其他 -->
<template v-else>
<el-table-column v-bind="col" />
</template>
</template>
</el-table>
<!-- 分页 -->
<pagination
v-if="total > 0"
v-model:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="handlePagination"
/>
<div class="feedback">
<el-button type="primary" size="small" @click="handleConfirm">
{{ confirmText }}
</el-button>
<el-button size="small" @click="handleClear"> </el-button>
<el-button size="small" @click="handleClose"> </el-button>
</div>
</div>
</el-popover>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from "vue";
import { onClickOutside, useResizeObserver } from "@vueuse/core";
import type { FormInstance, PopoverProps, TableInstance } from "element-plus";
// 对象类型
export type IObject = Record<string, any>;
// 定义接收的属性
export interface ISelectConfig<T = any> {
// 宽度
width?: string;
// 占位符
placeholder?: string;
// popover组件属性
popover?: Partial<Omit<PopoverProps, "visible" | "v-model:visible">>;
// 列表的网络请求函数(需返回promise)
indexAction: (queryParams: T) => Promise<any>;
// 主键名(跨页选择必填,默认为id)
pk?: string;
// 多选
multiple?: boolean;
// 表单项
formItems: Array<{
// 组件类型(如input,select等)
type?: "input" | "select" | "tree-select" | "date-picker";
// 标签文本
label: string;
// 键名
prop: string;
// 组件属性
attrs?: IObject;
// 初始值
initialValue?: any;
// 可选项(适用于select组件)
options?: { label: string; value: any }[];
}>;
// 列选项
tableColumns: Array<{
type?: "default" | "selection" | "index" | "expand";
label?: string;
prop?: string;
width?: string | number;
[key: string]: any;
}>;
}
const props = withDefaults(
defineProps<{
selectConfig: ISelectConfig;
text?: string;
}>(),
{
text: "",
}
);
// 自定义事件
const emit = defineEmits<{
confirmClick: [selection: any[]];
}>();
// 主键
const pk = props.selectConfig.pk ?? "id";
// 是否多选
const isMultiple = props.selectConfig.multiple === true;
// 宽度
const width = props.selectConfig.width ?? "100%";
// 占位符
const placeholder = props.selectConfig.placeholder ?? "请选择";
// 是否显示弹出框
const popoverVisible = ref(false);
// 加载状态
const loading = ref(false);
// 数据总数
const total = ref(0);
// 列表数据
const pageData = ref<IObject[]>([]);
// 每页条数
const pageSize = 10;
// 搜索参数
const queryParams = reactive<{
pageNum: number;
pageSize: number;
[key: string]: any;
}>({
pageNum: 1,
pageSize: pageSize,
});
// 计算popover的宽度
const tableSelectRef = ref();
const popoverWidth = ref(width);
useResizeObserver(tableSelectRef, (entries) => {
popoverWidth.value = `${entries[0].contentRect.width}px`;
});
// 表单操作
const formRef = ref<FormInstance>();
// 初始化搜索条件
for (const item of props.selectConfig.formItems) {
queryParams[item.prop] = item.initialValue ?? "";
}
// 重置操作
function handleReset() {
formRef.value?.resetFields();
fetchPageData(true);
}
// 查询操作
function handleQuery() {
fetchPageData(true);
}
// 获取分页数据
function fetchPageData(isRestart = false) {
loading.value = true;
if (isRestart) {
queryParams.pageNum = 1;
queryParams.pageSize = pageSize;
}
props.selectConfig
.indexAction(queryParams)
.then((data) => {
total.value = data.total;
pageData.value = data.list;
})
.finally(() => {
loading.value = false;
});
}
// 列表操作
const tableRef = ref<TableInstance>();
// 数据刷新后是否保留选项
for (const item of props.selectConfig.tableColumns) {
if (item.type === "selection") {
item.reserveSelection = true;
break;
}
}
// 选择
const selectedItems = ref<IObject[]>([]);
const confirmText = computed(() => {
return selectedItems.value.length > 0 ? `已选(${selectedItems.value.length})` : "确 定";
});
function handleSelect(selection: any[], row: any) {
if (isMultiple || selection.length === 0) {
// 多选
selectedItems.value = selection;
} else {
// 单选
selectedItems.value = [selection[selection.length - 1]];
tableRef.value?.clearSelection();
tableRef.value?.toggleRowSelection(selectedItems.value[0], true);
tableRef.value?.setCurrentRow(selectedItems.value[0]);
}
}
function handleSelectAll(selection: any[]) {
if (isMultiple) {
selectedItems.value = selection;
}
}
// 分页
function handlePagination() {
fetchPageData();
}
// 弹出框
const isInit = ref(false);
// 显示
function handleShow() {
if (isInit.value === false) {
isInit.value = true;
fetchPageData();
}
}
// 确定
function handleConfirm() {
if (selectedItems.value.length === 0) {
ElMessage.error("请选择数据");
return;
}
popoverVisible.value = false;
emit("confirmClick", selectedItems.value);
}
// 清空
function handleClear() {
tableRef.value?.clearSelection();
selectedItems.value = [];
}
// 关闭
function handleClose() {
popoverVisible.value = false;
}
const popoverContentRef = ref();
/* onClickOutside(tableSelectRef, () => (popoverVisible.value = false), {
ignore: [popoverContentRef],
}); */
</script>
<style scoped lang="scss">
.reference :deep(.el-input__wrapper),
.reference :deep(.el-input__inner) {
cursor: pointer;
}
.feedback {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
// 隐藏全选按钮
.radio :deep(.el-table__header th.el-table__cell:nth-child(1) .el-checkbox) {
visibility: hidden;
}
</style>

View File

@@ -0,0 +1,237 @@
<!-- 文件上传组件 -->
<template>
<div>
<el-upload
v-model:file-list="fileList"
:style="props.style"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-error="handleError"
:accept="props.accept"
:limit="props.limit"
multiple
>
<!-- 上传文件按钮 -->
<el-button type="primary" :disabled="fileList.length >= props.limit">
{{ props.uploadBtnText }}
</el-button>
<!-- 文件列表 -->
<template #file="{ file }">
<div class="el-upload-list__item-info">
<a class="el-upload-list__item-name" @click="handleDownload(file)">
<el-icon><Document /></el-icon>
<span class="el-upload-list__item-file-name">{{ file.name }}</span>
<span class="el-icon--close" @click="handleRemove(file.url!)">
<el-icon><Close /></el-icon>
</span>
</a>
</div>
</template>
</el-upload>
<el-progress
:style="{
display: showProgress ? 'inline-flex' : 'none',
width: '100%',
}"
:percentage="progressPercent"
/>
</div>
</template>
<script lang="ts" setup>
import {
UploadRawFile,
UploadUserFile,
UploadProgressEvent,
UploadRequestOptions,
} from "element-plus";
import FileAPI, { FileInfo } from "@/api/file";
const props = defineProps({
/**
* 请求携带的额外参数
*/
data: {
type: Object,
default: () => {
return {};
},
},
/**
* 上传文件的参数名
*/
name: {
type: String,
default: "file",
},
/**
* 文件上传数量限制
*/
limit: {
type: Number,
default: 10,
},
/**
* 单个文件上传大小限制(单位MB)
*/
maxFileSize: {
type: Number,
default: 10,
},
/**
* 上传文件类型
*/
accept: {
type: String,
default: "*",
},
/**
* 上传按钮文本
*/
uploadBtnText: {
type: String,
default: "上传文件",
},
/**
* 样式
*/
style: {
type: Object,
default: () => {
return {
width: "300px",
};
},
},
});
const modelValue = defineModel("modelValue", {
type: [Array] as PropType<string[]>,
required: true,
default: () => [],
});
const fileList = ref([] as UploadUserFile[]);
const showProgress = ref(false);
const progressPercent = ref(0);
// 监听 modelValue 转换用于显示的 fileList
watch(
modelValue,
(value) => {
fileList.value = value.map((url) => {
const name = url.substring(url.lastIndexOf("/") + 1);
return {
name: name,
url: url,
} as UploadUserFile;
});
},
{
immediate: true,
}
);
/**
* 上传前校验
*/
function handleBeforeUpload(file: UploadRawFile) {
// 限制文件大小
if (file.size > props.maxFileSize * 1024 * 1024) {
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
return false;
}
return true;
}
/*
* 上传文件
*/
function handleUpload(options: UploadRequestOptions) {
return new Promise((resolve, reject) => {
const file = options.file;
const formData = new FormData();
formData.append(props.name, file);
// 处理附加参数
Object.keys(props.data).forEach((key) => {
formData.append(key, props.data[key]);
});
FileAPI.upload(formData)
.then((data) => {
resolve(data);
})
.catch((error) => {
reject(error);
});
});
}
/**
* 上传进度
*
* @param event
*/
const handleProgress = (event: UploadProgressEvent) => {
progressPercent.value = event.percent;
};
/**
* 上传成功
*/
const handleSuccess = (fileInfo: FileInfo) => {
ElMessage.success("上传成功");
modelValue.value = [...modelValue.value, fileInfo.url];
};
const handleError = (error: any) => {
ElMessage.error("上传失败");
};
/**
* 删除文件
*/
function handleRemove(fileUrl: string) {
FileAPI.delete(fileUrl).then(() => {
modelValue.value = modelValue.value.filter((url) => url !== fileUrl);
});
}
/**
* 下载文件
*/
function handleDownload(file: UploadUserFile) {
const { url, name } = file;
if (url) {
FileAPI.download(url, name);
}
}
</script>
<style lang="scss" scoped>
.el-upload-list__item .el-icon--close {
position: absolute;
top: 50%;
right: 5px;
color: var(--el-text-color-regular);
cursor: pointer;
opacity: 0.75;
transition: opacity var(--el-transition-duration);
transform: translateY(-50%);
}
:deep(.el-upload-list) {
margin: 0;
}
:deep(.el-upload-list__item) {
margin: 0;
}
</style>

View File

@@ -0,0 +1,216 @@
<!-- 图片上传组件 -->
<template>
<el-upload
v-model:file-list="fileList"
list-type="picture-card"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:on-success="handleSuccess"
:on-error="handleError"
:on-exceed="handleExceed"
:accept="props.accept"
:limit="props.limit"
multiple
>
<el-icon><Plus /></el-icon>
<template #file="{ file }">
<div style="width: 100%">
<img class="el-upload-list__item-thumbnail" :src="file.url" />
<span class="el-upload-list__item-actions">
<!-- 预览 -->
<span @click="handlePreviewImage(file.url!)">
<el-icon><zoom-in /></el-icon>
</span>
<!-- 删除 -->
<span @click="handleRemove(file.url!)">
<el-icon><Delete /></el-icon>
</span>
</span>
</div>
</template>
</el-upload>
<el-image-viewer
v-if="previewVisible"
:zoom-rate="1.2"
:initial-index="previewImageIndex"
:url-list="modelValue"
@close="handlePreviewClose"
/>
</template>
<script setup lang="ts">
import { UploadRawFile, UploadRequestOptions, UploadUserFile } from "element-plus";
import FileAPI, { FileInfo } from "@/api/file";
const props = defineProps({
/**
* 请求携带的额外参数
*/
data: {
type: Object,
default: () => {
return {};
},
},
/**
* 上传文件的参数名
*/
name: {
type: String,
default: "file",
},
/**
* 文件上传数量限制
*/
limit: {
type: Number,
default: 10,
},
/**
* 单个文件的最大允许大小
*/
maxFileSize: {
type: Number,
default: 10,
},
/**
* 上传文件类型
*/
accept: {
type: String,
default: "image/*", // 默认支持所有图片格式 ,如果需要指定格式,格式如下:'.png,.jpg,.jpeg,.gif,.bmp'
},
});
const previewVisible = ref(false); // 是否显示预览
const previewImageIndex = ref(0); // 预览图片的索引
const modelValue = defineModel("modelValue", {
type: [Array] as PropType<string[]>,
required: true,
default: () => [],
});
const fileList = ref<UploadUserFile[]>([]);
/**
* 删除图片
*/
function handleRemove(imageUrl: string) {
FileAPI.delete(imageUrl).then(() => {
const index = modelValue.value.indexOf(imageUrl);
if (index !== -1) {
// 直接修改数组避免触发整体更新
modelValue.value.splice(index, 1);
fileList.value.splice(index, 1); // 同步更新 fileList
}
});
}
/**
* 上传前校验
*/
function handleBeforeUpload(file: UploadRawFile) {
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
const acceptTypes = props.accept.split(",").map((type) => type.trim());
// 检查文件格式是否符合 accept
const isValidType = acceptTypes.some((type) => {
if (type === "image/*") {
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
return file.type.startsWith("image/");
} else if (type.startsWith(".")) {
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
return file.name.toLowerCase().endsWith(type);
} else {
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
return file.type === type;
}
});
if (!isValidType) {
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
return false;
}
// 限制文件大小
if (file.size > props.maxFileSize * 1024 * 1024) {
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
return false;
}
return true;
}
/*
* 上传文件
*/
function handleUpload(options: UploadRequestOptions) {
return new Promise((resolve, reject) => {
const file = options.file;
const formData = new FormData();
formData.append(props.name, file);
// 处理附加参数
Object.keys(props.data).forEach((key) => {
formData.append(key, props.data[key]);
});
FileAPI.upload(formData)
.then((data) => {
resolve(data);
})
.catch((error) => {
reject(error);
});
});
}
/**
* 上传文件超出限制
*/
function handleExceed(files: File[], uploadFiles: UploadUserFile[]) {
ElMessage.warning("最多只能上传" + props.limit + "张图片");
}
/**
* 上传成功回调
*/
const handleSuccess = (fileInfo: FileInfo, uploadFile: UploadUserFile) => {
ElMessage.success("上传成功");
const index = fileList.value.findIndex((file) => file.uid === uploadFile.uid);
if (index !== -1) {
fileList.value[index].url = fileInfo.url;
fileList.value[index].status = "success";
modelValue.value[index] = fileInfo.url;
}
};
/**
* 上传失败回调
*/
const handleError = (error: any) => {
console.log("handleError");
ElMessage.error("上传失败: " + error.message);
};
/**
* 预览图片
*/
const handlePreviewImage = (imageUrl: string) => {
previewImageIndex.value = modelValue.value.findIndex((url) => url === imageUrl);
previewVisible.value = true;
};
/**
* 关闭预览
*/
const handlePreviewClose = () => {
previewVisible.value = false;
};
onMounted(() => {
fileList.value = modelValue.value.map((url) => ({ url }) as UploadUserFile);
});
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,203 @@
<!-- 单图上传组件 -->
<template>
<el-upload
v-model="modelValue"
class="single-upload"
list-type="picture-card"
:show-file-list="false"
:accept="props.accept"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:on-success="onSuccess"
:on-error="onError"
multiple
>
<template #default>
<el-image v-if="modelValue" :src="modelValue" />
<el-icon v-if="modelValue" class="single-upload__delete-btn" @click.stop="handleDelete">
<CircleCloseFilled />
</el-icon>
<el-icon v-else class="single-upload__add-btn">
<Plus />
</el-icon>
</template>
</el-upload>
</template>
<script setup lang="ts">
import { UploadRawFile, UploadRequestOptions } from "element-plus";
import FileAPI, { FileInfo } from "@/api/file";
const props = defineProps({
/**
* 请求携带的额外参数
*/
data: {
type: Object,
default: () => {
return {};
},
},
/**
* 上传文件的参数名
*/
name: {
type: String,
default: "file",
},
/**
* 最大文件大小单位M
*/
maxFileSize: {
type: Number,
default: 10,
},
/**
* 上传图片格式,默认支持所有图片(image/*),指定格式示例:'.png,.jpg,.jpeg,.gif,.bmp'
*/
accept: {
type: String,
default: "image/*",
},
/**
* 自定义样式,用于设置组件的宽度和高度等其他样式
*/
style: {
type: Object,
default: () => {
return {
width: "150px",
height: "150px",
};
},
},
});
const modelValue = defineModel("modelValue", {
type: String,
required: true,
default: () => "",
});
/**
* 限制用户上传文件的格式和大小
*/
function handleBeforeUpload(file: UploadRawFile) {
// 校验文件类型:虽然 accept 属性限制了用户在文件选择器中可选的文件类型,但仍需在上传时再次校验文件实际类型,确保符合 accept 的规则
const acceptTypes = props.accept.split(",").map((type) => type.trim());
// 检查文件格式是否符合 accept
const isValidType = acceptTypes.some((type) => {
if (type === "image/*") {
// 如果是 image/*,检查 MIME 类型是否以 "image/" 开头
return file.type.startsWith("image/");
} else if (type.startsWith(".")) {
// 如果是扩展名 (.png, .jpg),检查文件名是否以指定扩展名结尾
return file.name.toLowerCase().endsWith(type);
} else {
// 如果是具体的 MIME 类型 (image/png, image/jpeg),检查是否完全匹配
return file.type === type;
}
});
if (!isValidType) {
ElMessage.warning(`上传文件的格式不正确,仅支持:${props.accept}`);
return false;
}
// 限制文件大小
if (file.size > props.maxFileSize * 1024 * 1024) {
ElMessage.warning("上传图片不能大于" + props.maxFileSize + "M");
return false;
}
return true;
}
/*
* 上传图片
*/
function handleUpload(options: UploadRequestOptions) {
return new Promise((resolve, reject) => {
const file = options.file;
const formData = new FormData();
formData.append(props.name, file);
// 处理附加参数
Object.keys(props.data).forEach((key) => {
formData.append(key, props.data[key]);
});
FileAPI.upload(formData)
.then((data) => {
resolve(data);
})
.catch((error) => {
reject(error);
});
});
}
/**
* 删除图片
*/
function handleDelete() {
modelValue.value = "";
}
/**
* 上传成功回调
*
* @param fileInfo 上传成功后的文件信息
*/
const onSuccess = (fileInfo: FileInfo) => {
ElMessage.success("上传成功");
modelValue.value = fileInfo.url;
};
/**
* 上传失败回调
*/
const onError = (error: any) => {
console.log("onError");
ElMessage.error("上传失败: " + error.message);
};
</script>
<style scoped lang="scss">
:deep(.el-upload--picture-card) {
/* width: var(--el-upload-picture-card-size);
height: var(--el-upload-picture-card-size); */
width: v-bind("props.style.width");
height: v-bind("props.style.height");
}
.single-upload {
position: relative;
overflow: hidden;
cursor: pointer;
border: 1px var(--el-border-color) solid;
border-radius: 5px;
&:hover {
border-color: var(--el-color-primary);
}
&__delete-btn {
position: absolute;
top: 1px;
right: 1px;
font-size: 16px;
color: #ff7901;
cursor: pointer;
background: #fff;
border-radius: 100%;
:hover {
color: #ff4500;
}
}
}
</style>

View File

@@ -0,0 +1,90 @@
<!--
* 基于 wangEditor-next 的富文本编辑器组件二次封装
* 版权所有 © 2021-present 有来开源组织
*
* 开源协议https://opensource.org/licenses/MIT
* 项目地址https://gitee.com/youlaiorg/vue3-element-admin
*
* 在使用时请保留此注释感谢您对开源的支持
-->
<template>
<div style="z-index: 999; border: 1px solid #ccc">
<!-- 工具栏 -->
<Toolbar
:editor="editorRef"
mode="simple"
:default-config="toolbarConfig"
style="border-bottom: 1px solid #ccc"
/>
<!-- 编辑器 -->
<Editor
v-model="modelValue"
:style="{ height: height, overflowY: 'hidden' }"
:default-config="editorConfig"
mode="simple"
@on-created="handleCreated"
/>
</div>
</template>
<script setup lang="ts">
import "@wangeditor-next/editor/dist/css/style.css";
import { Toolbar, Editor } from "@wangeditor-next/editor-for-vue";
import { IToolbarConfig, IEditorConfig } from "@wangeditor-next/editor";
// 文件上传 API
import FileAPI from "@/api/file";
// 上传图片回调函数类型
type InsertFnType = (url: string, alt: string, href: string) => void;
defineProps({
height: {
type: String,
default: "500px",
},
});
const emit = defineEmits(["update:modelValue"]);
// 双向绑定
const modelValue = defineModel("modelValue", {
type: String,
required: false,
});
// 编辑器实例,必须用 shallowRef重要
const editorRef = shallowRef();
// 工具栏配置
const toolbarConfig = ref<Partial<IToolbarConfig>>({});
// 编辑器配置
const editorConfig = ref<Partial<IEditorConfig>>({
placeholder: "请输入内容...",
MENU_CONF: {
uploadImage: {
customUpload(file: File, insertFn: InsertFnType) {
// 上传图片
FileAPI.uploadFile(file).then((res) => {
// 插入图片
insertFn(res.url, res.name, res.url);
});
},
} as any,
},
});
// 记录 editor 实例,重要!
const handleCreated = (editor: any) => {
editorRef.value = editor;
};
// 组件销毁时,也及时销毁编辑器,重要!
onBeforeUnmount(() => {
const editor = editorRef.value;
if (editor == null) return;
editor.destroy();
});
</script>