mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 06:41:12 +00:00
发布v2.0.0分支
This commit is contained in:
@@ -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 };
|
||||
};
|
||||
</script>
|
||||
@@ -1,46 +1,85 @@
|
||||
<template>
|
||||
<a-breadcrumb :routes="routes">
|
||||
<template #itemRender="{ route, routes }">
|
||||
<span v-if="isLastRoute(route, routes)">{{ route.breadcrumbName }}</span>
|
||||
<router-link v-else :to="route.path">{{ route.breadcrumbName }}</router-link>
|
||||
</template>
|
||||
</a-breadcrumb>
|
||||
<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 lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from "vue-router";
|
||||
<script setup lang="ts">
|
||||
import { RouteLocationMatched } from "vue-router";
|
||||
import { compile } from "path-to-regexp";
|
||||
import router from "@/router";
|
||||
import { translateRouteTitle } from "@/utils/i18n";
|
||||
|
||||
interface BreadcrumbRoute {
|
||||
path: string;
|
||||
breadcrumbName?: string;
|
||||
const currentRoute = useRoute();
|
||||
const pathCompile = (path: string) => {
|
||||
const { params } = currentRoute;
|
||||
const toPath = compile(path);
|
||||
return toPath(params);
|
||||
};
|
||||
|
||||
const breadcrumbs = ref<Array<RouteLocationMatched>>([]);
|
||||
|
||||
function getBreadcrumb() {
|
||||
let matched = currentRoute.matched.filter((item) => item.meta && item.meta.title);
|
||||
const first = matched[0];
|
||||
if (!isDashboard(first)) {
|
||||
matched = [{ path: "/dashboard", meta: { title: "dashboard" } } as any].concat(matched);
|
||||
}
|
||||
breadcrumbs.value = matched.filter((item) => {
|
||||
return item.meta && item.meta.title && item.meta.breadcrumb !== false;
|
||||
});
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const routes = ref<BreadcrumbRoute[]>([]);
|
||||
function isDashboard(route: RouteLocationMatched) {
|
||||
const name = route && route.name;
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
return name.toString().trim().toLocaleLowerCase() === "Dashboard".toLocaleLowerCase();
|
||||
}
|
||||
|
||||
const isLastRoute = (currentRoute: BreadcrumbRoute, allRoutes: BreadcrumbRoute[]) => {
|
||||
return allRoutes.indexOf(currentRoute) === allRoutes.length - 1;
|
||||
};
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
const getBreadcrumb = () => {
|
||||
const matchedRoutes = route.matched
|
||||
.filter((_, index) => index > 0) // 跳过第一个匹配项
|
||||
.map(item => ({
|
||||
path: item.path,
|
||||
breadcrumbName: item.meta.title?.toString()
|
||||
}));
|
||||
|
||||
routes.value = matchedRoutes.length > 1 ? matchedRoutes : [];
|
||||
};
|
||||
watch(
|
||||
() => currentRoute.path,
|
||||
(path) => {
|
||||
if (path.startsWith("/redirect/")) {
|
||||
return;
|
||||
}
|
||||
getBreadcrumb();
|
||||
}
|
||||
);
|
||||
|
||||
// 初始化并监听路由变化
|
||||
watch(() => route.path, getBreadcrumb, { immediate: true });
|
||||
onBeforeMount(() => {
|
||||
getBreadcrumb();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加一些样式优化 */
|
||||
.a-breadcrumb {
|
||||
margin: 12px 0;
|
||||
<style lang="scss" scoped>
|
||||
// 覆盖 element-plus 的样式
|
||||
.el-breadcrumb__inner,
|
||||
.el-breadcrumb__inner a {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded bg-[var(--el-bg-color)] border border-[var(--el-border-color)] p-5 h-full md:flex flex-1 flex-col md:overflow-auto"
|
||||
>
|
||||
<!-- 表格工具栏 -->
|
||||
<div class="flex flex-col md:flex-row justify-between gap-y-2.5 mb-2.5">
|
||||
<!-- 左侧工具栏 -->
|
||||
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarLeftBtn" :key="index">
|
||||
<el-button
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
:disabled="btn.name === 'delete' && removeIds.length === 0"
|
||||
@click="handleToolbar(btn.name)"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 右侧工具栏 -->
|
||||
<div class="toolbar-right flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
|
||||
<template v-for="(btn, index) in toolbarRightBtn" :key="index">
|
||||
<el-popover v-if="btn.name === 'filter'" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<el-button v-bind="btn.attrs"></el-button>
|
||||
</template>
|
||||
<el-scrollbar max-height="350px">
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
<el-checkbox v-if="col.prop" v-model="col.show" :label="col.label" />
|
||||
</template>
|
||||
</el-scrollbar>
|
||||
</el-popover>
|
||||
<el-button
|
||||
v-else
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="handleToolbar(btn.name)"
|
||||
></el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
v-bind="contentConfig.table"
|
||||
:data="pageData"
|
||||
:row-key="pk"
|
||||
class="flex-1"
|
||||
@selection-change="handleSelectionChange"
|
||||
@filter-change="handleFilterChange"
|
||||
>
|
||||
<template v-for="col in cols" :key="col.prop">
|
||||
<el-table-column v-if="col.show" v-bind="col">
|
||||
<template #default="scope">
|
||||
<!-- 显示图片 -->
|
||||
<template v-if="col.templet === 'image'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="Array.isArray(scope.row[col.prop])">
|
||||
<template v-for="(item, index) in scope.row[col.prop]" :key="item">
|
||||
<el-image
|
||||
:src="item"
|
||||
:preview-src-list="scope.row[col.prop]"
|
||||
:initial-index="index"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-image
|
||||
:src="scope.row[col.prop]"
|
||||
:preview-src-list="[scope.row[col.prop]]"
|
||||
:preview-teleported="true"
|
||||
:style="`width: ${col.imageWidth ?? 40}px; height: ${col.imageHeight ?? 40}px`"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 根据行的selectList属性返回对应列表值 -->
|
||||
<template v-else-if="col.templet === 'list'">
|
||||
<template v-if="col.prop">
|
||||
{{ (col.selectList ?? {})[scope.row[col.prop]] }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化显示链接 -->
|
||||
<template v-else-if="col.templet === 'url'">
|
||||
<template v-if="col.prop">
|
||||
<el-link type="primary" :href="scope.row[col.prop]" target="_blank">
|
||||
{{ scope.row[col.prop] }}
|
||||
</el-link>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成开关组件 -->
|
||||
<template v-else-if="col.templet === 'switch'">
|
||||
<template v-if="col.prop">
|
||||
<!-- pageData.length>0: 解决el-switch组件会在表格初始化的时候触发一次change事件 -->
|
||||
<el-switch
|
||||
v-model="scope.row[col.prop]"
|
||||
:active-value="col.activeValue ?? 1"
|
||||
:inactive-value="col.inactiveValue ?? 0"
|
||||
:inline-prompt="true"
|
||||
:active-text="col.activeText ?? ''"
|
||||
:inactive-text="col.inactiveText ?? ''"
|
||||
:validate-event="false"
|
||||
:disabled="col.disabled"
|
||||
@change="
|
||||
pageData.length > 0 && handleModify(col.prop, scope.row[col.prop], scope.row)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成输入框组件 -->
|
||||
<template v-else-if="col.templet === 'input'">
|
||||
<template v-if="col.prop">
|
||||
<el-input
|
||||
v-model="scope.row[col.prop]"
|
||||
:type="col.inputType ?? 'text'"
|
||||
:disabled="col.disabled"
|
||||
@blur="handleModify(col.prop, scope.row[col.prop], scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为价格 -->
|
||||
<template v-else-if="col.templet === 'price'">
|
||||
<template v-if="col.prop">
|
||||
{{ `${col.priceFormat ?? "¥"}${scope.row[col.prop]}` }}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化为百分比 -->
|
||||
<template v-else-if="col.templet === 'percent'">
|
||||
<template v-if="col.prop">{{ scope.row[col.prop] }}%</template>
|
||||
</template>
|
||||
<!-- 显示图标 -->
|
||||
<template v-else-if="col.templet === 'icon'">
|
||||
<template v-if="col.prop">
|
||||
<template v-if="scope.row[col.prop].startsWith('el-icon-')">
|
||||
<el-icon>
|
||||
<component :is="scope.row[col.prop].replace('el-icon-', '')" />
|
||||
</el-icon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="i-svg:{{ scope.row[col.prop] }}" />
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 格式化时间 -->
|
||||
<template v-else-if="col.templet === 'date'">
|
||||
<template v-if="col.prop">
|
||||
{{
|
||||
scope.row[col.prop]
|
||||
? useDateFormat(scope.row[col.prop], col.dateFormat ?? "YYYY-MM-DD HH:mm:ss")
|
||||
.value
|
||||
: ""
|
||||
}}
|
||||
</template>
|
||||
</template>
|
||||
<!-- 列操作栏 -->
|
||||
<template v-else-if="col.templet === 'tool'">
|
||||
<template v-for="(btn, index) in tableToolbarBtn" :key="index">
|
||||
<el-button
|
||||
v-if="btn.render === undefined || btn.render(scope.row)"
|
||||
v-hasPerm="btn.perm ?? '*:*:*'"
|
||||
v-bind="btn.attrs"
|
||||
@click="
|
||||
handleOperate({
|
||||
name: btn.name,
|
||||
row: scope.row,
|
||||
column: scope.column,
|
||||
$index: scope.$index,
|
||||
})
|
||||
"
|
||||
>
|
||||
{{ btn.text }}
|
||||
</el-button>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义 -->
|
||||
<template v-else-if="col.templet === 'custom'">
|
||||
<slot :name="col.slotName ?? col.prop" :prop="col.prop" v-bind="scope" />
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="showPagination" class="mt-4">
|
||||
<el-scrollbar :class="['h-8!', { 'flex-x-end': contentConfig?.pagePosition === 'right' }]">
|
||||
<el-pagination
|
||||
v-bind="pagination"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 导出弹窗 -->
|
||||
<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"
|
||||
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.prop">
|
||||
<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"
|
||||
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="never"
|
||||
@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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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, computed } from "vue";
|
||||
import type { IContentConfig, IObject, IOperateData } from "./types";
|
||||
import type { IToolsButton } from "./types";
|
||||
|
||||
// 定义接收的属性
|
||||
const props = defineProps<{ contentConfig: IContentConfig }>();
|
||||
// 定义自定义事件
|
||||
const emit = defineEmits<{
|
||||
addClick: [];
|
||||
exportClick: [];
|
||||
searchClick: [];
|
||||
toolbarClick: [name: string];
|
||||
editClick: [row: IObject];
|
||||
filterChange: [data: IObject];
|
||||
operateClick: [data: IOperateData];
|
||||
}>();
|
||||
|
||||
// 表格工具栏按钮配置
|
||||
const config = computed(() => props.contentConfig);
|
||||
const buttonConfig = reactive<Record<string, IObject>>({
|
||||
add: { text: "新增", attrs: { icon: "plus", type: "success" }, perm: "add" },
|
||||
delete: { text: "删除", attrs: { icon: "delete", type: "danger" }, perm: "delete" },
|
||||
import: { text: "导入", attrs: { icon: "upload", type: "default" }, perm: "import" },
|
||||
export: { text: "导出", attrs: { icon: "download", type: "default" }, perm: "export" },
|
||||
refresh: { text: "刷新", attrs: { icon: "refresh", type: "default" }, perm: "*:*:*" },
|
||||
filter: { text: "筛选列", attrs: { icon: "operation", type: "default" }, perm: "*:*:*" },
|
||||
search: { text: "搜索", attrs: { icon: "search", type: "" }, perm: "search" },
|
||||
imports: { text: "批量导入", attrs: { icon: "upload", type: "" }, perm: "imports" },
|
||||
exports: { text: "批量导出", attrs: { icon: "download", type: "" }, perm: "exports" },
|
||||
view: { text: "查看", attrs: { icon: "view", type: "primary" }, perm: "view" },
|
||||
edit: { text: "编辑", attrs: { icon: "edit", type: "primary" }, perm: "edit" },
|
||||
});
|
||||
|
||||
// 主键
|
||||
const pk = props.contentConfig.pk ?? "id";
|
||||
// 权限名称前缀
|
||||
const authPrefix = computed(() => props.contentConfig.permPrefix);
|
||||
|
||||
// 获取按钮权限标识
|
||||
function getButtonPerm(action: string): string | null {
|
||||
// 如果action已经包含完整路径(包含冒号),则直接使用
|
||||
if (action.includes(":")) {
|
||||
return action;
|
||||
}
|
||||
// 否则使用权限前缀组合
|
||||
return authPrefix.value ? `${authPrefix.value}:${action}` : null;
|
||||
}
|
||||
|
||||
// 检查是否有权限
|
||||
// function hasButtonPerm(action: string): boolean {
|
||||
// const perm = getButtonPerm(action);
|
||||
// // 如果没有设置权限标识,则默认具有权限
|
||||
// if (!perm) return true;
|
||||
// return hasAuth(perm);
|
||||
// }
|
||||
|
||||
// 创建工具栏按钮
|
||||
function createToolbar(toolbar: Array<string | IToolsButton>, attr = {}) {
|
||||
return toolbar.map((item) => {
|
||||
const isString = typeof item === "string";
|
||||
return {
|
||||
name: isString ? item : item?.name || "",
|
||||
text: isString ? buttonConfig[item].text : item?.text,
|
||||
attrs: {
|
||||
...attr,
|
||||
...(isString ? buttonConfig[item].attrs : item?.attrs),
|
||||
},
|
||||
render: isString ? undefined : (item?.render ?? undefined),
|
||||
perm: isString
|
||||
? getButtonPerm(buttonConfig[item].perm)
|
||||
: item?.perm
|
||||
? getButtonPerm(item.perm as string)
|
||||
: "*:*:*",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 左侧工具栏按钮
|
||||
const toolbarLeftBtn = computed(() => {
|
||||
if (!config.value.toolbar || config.value.toolbar.length === 0) return [];
|
||||
return createToolbar(config.value.toolbar, {});
|
||||
});
|
||||
|
||||
// 右侧工具栏按钮
|
||||
const toolbarRightBtn = computed(() => {
|
||||
if (!config.value.defaultToolbar || config.value.defaultToolbar.length === 0) return [];
|
||||
return createToolbar(config.value.defaultToolbar, { circle: true });
|
||||
});
|
||||
|
||||
// 表格操作工具栏
|
||||
const tableToolbar = config.value.cols[config.value.cols.length - 1].operat ?? ["edit", "delete"];
|
||||
const tableToolbarBtn = createToolbar(tableToolbar, { link: true, size: "small" });
|
||||
|
||||
// 表格列
|
||||
const cols = ref(
|
||||
props.contentConfig.cols.map((col) => {
|
||||
if (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 defaultPagination = {
|
||||
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"
|
||||
? { ...defaultPagination, ...props.contentConfig.pagination }
|
||||
: defaultPagination
|
||||
);
|
||||
// 分页相关的请求参数
|
||||
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 getSelectionData() {
|
||||
return selectionData.value;
|
||||
}
|
||||
|
||||
// 刷新
|
||||
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);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
ElMessage.error("未配置deleteAction");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 导出表单
|
||||
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,
|
||||
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.permPrefix || "export";
|
||||
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 as string);
|
||||
})
|
||||
.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 as string);
|
||||
})
|
||||
.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 handleOperate(data: IOperateData) {
|
||||
switch (data.name) {
|
||||
case "delete":
|
||||
if (props.contentConfig?.deleteAction) {
|
||||
handleDelete(data.row[pk]);
|
||||
} else {
|
||||
emit("operateClick", data);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
emit("operateClick", 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,
|
||||
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: any, 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, getSelectionData, handleRefresh });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
.el-button {
|
||||
margin-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- drawer -->
|
||||
<template v-if="modalConfig.component === 'drawer'">
|
||||
<el-drawer
|
||||
v-model="modalVisible"
|
||||
v-bind="{ destroyOnClose: true, ...modalConfig.drawer }"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" 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 #label>
|
||||
<span>
|
||||
{{ item?.label || "" }}
|
||||
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- components -->
|
||||
<template v-if="item.type === 'custom'">
|
||||
<slot
|
||||
:name="item.slotName ?? item.prop"
|
||||
:prop="item.prop"
|
||||
:form-data="formData"
|
||||
:attrs="item.attrs"
|
||||
></slot>
|
||||
</template>
|
||||
<component
|
||||
:is="componentMap.get(item.type)"
|
||||
v-else
|
||||
v-model.trim="formData[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
>
|
||||
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
|
||||
<component
|
||||
:is="childrenMap.get(item.type)"
|
||||
v-for="opt in item.options"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
></component>
|
||||
</template>
|
||||
|
||||
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
|
||||
<slot :name="item.slotName" />
|
||||
</template>
|
||||
</component>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
<!-- dialog -->
|
||||
<template v-else>
|
||||
<el-dialog
|
||||
v-model="modalVisible"
|
||||
v-bind="{ destroyOnClose: true, alignCenter: true, ...modalConfig.dialog }"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" v-bind="modalConfig.form" :model="formData" :rules="formRules">
|
||||
<el-scrollbar max-height="70vh" :view-style="{ overflowX: 'hidden' }">
|
||||
<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">
|
||||
<template #label>
|
||||
<span>
|
||||
{{ item?.label || "" }}
|
||||
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
<span v-if="modalConfig.colon" class="ml-0.5">:</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="item.type === 'custom'">
|
||||
<slot
|
||||
:name="item.slotName ?? item.prop"
|
||||
:prop="item.prop"
|
||||
:form-data="formData"
|
||||
:attrs="item.attrs"
|
||||
></slot>
|
||||
</template>
|
||||
<component
|
||||
:is="componentMap.get(item.type)"
|
||||
v-else
|
||||
v-model.trim="formData[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
>
|
||||
<template v-if="['select', 'radio', 'checkbox'].includes(item.type)">
|
||||
<component
|
||||
:is="childrenMap.get(item.type)"
|
||||
v-for="opt in item.options"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
></component>
|
||||
</template>
|
||||
|
||||
<template v-if="item?.slotName && $slots[item.slotName]" #[item.slotName]>
|
||||
<slot :name="item.slotName" />
|
||||
</template>
|
||||
</component>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</el-row>
|
||||
<slot name="bottom" :form-data="formData"></slot>
|
||||
</el-scrollbar>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="!formDisable" type="primary" @click="handleSubmit">确 定</el-button>
|
||||
<el-button @click="handleClose">{{ !formDisable ? "取 消" : "关闭" }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import type { IComponentType, IModalConfig, IObject } from "./types";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
import IconSelect from "@/components/IconSelect/index.vue";
|
||||
|
||||
defineSlots<{ [key: string]: (_args: any) => any }>();
|
||||
// 定义接收的属性
|
||||
const props = defineProps<{ modalConfig: IModalConfig }>();
|
||||
// 自定义事件
|
||||
const emit = defineEmits<{ submitClick: []; customSubmit: [queryParams: IObject] }>();
|
||||
// 组件映射表
|
||||
|
||||
const componentMap = new Map<IComponentType, any>([
|
||||
// @ts-ignore
|
||||
["input", markRaw(ElInput)], // @ts-ignore
|
||||
["select", markRaw(ElSelect)], // @ts-ignore
|
||||
["switch", markRaw(ElSwitch)], // @ts-ignore
|
||||
["cascader", markRaw(ElCascader)], // @ts-ignore
|
||||
["input-number", markRaw(ElInputNumber)], // @ts-ignore
|
||||
["input-tag", markRaw(InputTag)], // @ts-ignore
|
||||
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
|
||||
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
|
||||
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
|
||||
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore"
|
||||
["custom-tag", markRaw(InputTag)], // @ts-ignore
|
||||
["text", markRaw(ElText)], // @ts-ignore
|
||||
["radio", markRaw(ElRadioGroup)], // @ts-ignore"
|
||||
["checkbox", markRaw(ElCheckboxGroup)], // @ts-ignore"
|
||||
["icon-select", markRaw(IconSelect)], // @ts-ignore"
|
||||
["custom", ""],
|
||||
]);
|
||||
const childrenMap = new Map<IComponentType, any>([
|
||||
// @ts-ignore
|
||||
["select", markRaw(ElOption)], // @ts-ignore
|
||||
["radio", markRaw(ElRadio)], // @ts-ignore"
|
||||
["checkbox", markRaw(ElCheckbox)],
|
||||
]);
|
||||
|
||||
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); // 表单禁用状态
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
// 隐藏弹窗
|
||||
const handleClose = () => {
|
||||
modalVisible.value = false;
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
// 设置表单值
|
||||
const setFormData = (data: IObject) => {
|
||||
for (const key in formData) {
|
||||
if (Object.prototype.hasOwnProperty.call(formData, key) && key in data) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(data, pk)) {
|
||||
formData[pk] = data[pk];
|
||||
}
|
||||
};
|
||||
// 表单提交
|
||||
const handleSubmit = useThrottleFn(() => {
|
||||
formRef.value?.validate((valid: boolean) => {
|
||||
if (!valid) return;
|
||||
if (typeof props.modalConfig.beforeSubmit === "function") {
|
||||
props.modalConfig.beforeSubmit(formData);
|
||||
}
|
||||
if (!props.modalConfig?.formAction) {
|
||||
emit("customSubmit", formData);
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
props.modalConfig.formAction(formData).then(() => {
|
||||
if (props.modalConfig.component === "drawer") {
|
||||
ElMessage.success(`${props.modalConfig.drawer?.title}成功`);
|
||||
} else {
|
||||
ElMessage.success(`${props.modalConfig.dialog?.title}成功`);
|
||||
}
|
||||
emit("submitClick");
|
||||
handleClose();
|
||||
});
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
onMounted(() => {
|
||||
formItems.forEach((item) => {
|
||||
if (item.initFn) {
|
||||
item.initFn(item);
|
||||
}
|
||||
formRules[item.prop] = item?.rules ?? [];
|
||||
props.modalConfig.form = { labelWidth: "auto", ...props.modalConfig?.form };
|
||||
|
||||
if (["input-tag", "custom-tag", "cascader"].includes(item.type)) {
|
||||
formData[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
|
||||
} else if (item.type === "input-number") {
|
||||
formData[item.prop] = item.initialValue ?? null;
|
||||
} else {
|
||||
formData[item.prop] = item.initialValue ?? "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
setFormData,
|
||||
// 展示/因此 modal
|
||||
setModalVisible: (visible: boolean = true) => (modalVisible.value = visible),
|
||||
// 获取表单数据
|
||||
getFormData: (key: string) => formData[key] ?? formData,
|
||||
// 设置表单项值
|
||||
setFormItemData: (key: string, value: any) => (formData[key] = value),
|
||||
// 禁用表单
|
||||
handleDisabled: (disable: boolean) => {
|
||||
formDisable.value = disable;
|
||||
props.modalConfig.form = {
|
||||
...props.modalConfig.form,
|
||||
disabled: disable,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
text-align: left;
|
||||
}
|
||||
:deep(.el-input-number.is-without-controls .el-input__wrapper) {
|
||||
padding-right: 11px !important ;
|
||||
padding-left: 11px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div v-show="visible">
|
||||
<el-card v-bind="cardAttrs">
|
||||
<el-form ref="queryFormRef" :model="queryParams" v-bind="formAttrs" :class="isGrid">
|
||||
<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 #label>
|
||||
<span class="flex-y-center">
|
||||
{{ item?.label || "" }}
|
||||
<el-tooltip v-if="item?.tips" v-bind="getTooltipProps(item.tips)">
|
||||
<QuestionFilled class="w-4 h-4 mx-1" />
|
||||
</el-tooltip>
|
||||
<span v-if="searchConfig.colon" class="ml-0.5">:</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<el-cascader
|
||||
v-if="item.type === 'cascader'"
|
||||
v-model.trim="queryParams[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
v-on="item.events || {}"
|
||||
/>
|
||||
<component
|
||||
:is="componentMap.get(item.type)"
|
||||
v-else
|
||||
v-model.trim="queryParams[item.prop]"
|
||||
v-bind="{ style: { width: '100%' }, ...item.attrs }"
|
||||
v-on="item.events || {}"
|
||||
>
|
||||
<template v-if="item.type === 'select'">
|
||||
<template v-for="opt in item.options" :key="opt.value">
|
||||
<el-option :label="opt.label" :value="opt.value" />
|
||||
</template>
|
||||
</template>
|
||||
</component>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item :class="{ 'col-[auto/-1] justify-self-end': searchConfig?.grid === 'right' }">
|
||||
<el-button icon="search" type="primary" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="refresh" @click="handleReset">重置</el-button>
|
||||
<!-- 展开/收起 -->
|
||||
<template v-if="isExpandable && formItems.length > showNumber">
|
||||
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
|
||||
{{ isExpand ? "收起" : "展开" }}
|
||||
<component :is="isExpand ? ArrowUp : ArrowDown" class="w-4 h-4 ml-2" />
|
||||
</el-link>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IObject, IForm, ISearchConfig, ISearchComponent } from "./types";
|
||||
import { ArrowUp, ArrowDown } from "@element-plus/icons-vue";
|
||||
import type { FormInstance } from "element-plus";
|
||||
import InputTag from "@/components/InputTag/index.vue";
|
||||
|
||||
// 定义接收的属性
|
||||
const props = defineProps<{ searchConfig: ISearchConfig }>();
|
||||
// 自定义事件
|
||||
const emit = defineEmits<{
|
||||
queryClick: [queryParams: IObject];
|
||||
resetClick: [queryParams: IObject];
|
||||
}>();
|
||||
// 组件映射表
|
||||
const componentMap = new Map<ISearchComponent, any>([
|
||||
// @ts-ignore
|
||||
["input", markRaw(ElInput)], // @ts-ignore
|
||||
["select", markRaw(ElSelect)], // @ts-ignore
|
||||
["cascader", markRaw(ElCascader)], // @ts-ignore
|
||||
["input-number", markRaw(ElInputNumber)], // @ts-ignore
|
||||
["date-picker", markRaw(ElDatePicker)], // @ts-ignore
|
||||
["time-picker", markRaw(ElTimePicker)], // @ts-ignore
|
||||
["time-select", markRaw(ElTimeSelect)], // @ts-ignore
|
||||
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
|
||||
["input-tag", markRaw(ElInputTag)], // @ts-ignore
|
||||
["custom-tag", markRaw(InputTag)],
|
||||
]);
|
||||
|
||||
// 存储表单实例
|
||||
const queryFormRef = ref<FormInstance>();
|
||||
// 存储查询参数
|
||||
const queryParams = reactive<IObject>({});
|
||||
// 是否显示
|
||||
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(() =>
|
||||
isExpandable.value ? (props.searchConfig?.showNumber ?? 3) : formItems.length
|
||||
);
|
||||
// 卡片组件自定义属性(阴影、自定义边距样式等)
|
||||
const cardAttrs = computed<IObject>(() => {
|
||||
return { shadow: "never", style: { "margin-bottom": "12px" }, ...props.searchConfig?.cardAttrs };
|
||||
});
|
||||
// 表单组件自定义属性(label位置、宽度、对齐方式等)
|
||||
const formAttrs = computed<IForm>(() => {
|
||||
return { inline: true, ...props.searchConfig?.form };
|
||||
});
|
||||
// 是否使用自适应网格布局
|
||||
const isGrid = computed(() =>
|
||||
props.searchConfig?.grid
|
||||
? "grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6 gap-5"
|
||||
: "flex flex-wrap gap-x-8 gap-y-4"
|
||||
);
|
||||
|
||||
// 获取tooltip提示框属性
|
||||
const getTooltipProps = (tips: string | IObject) => {
|
||||
return typeof tips === "string" ? { content: tips } : tips;
|
||||
};
|
||||
// 查询/重置操作
|
||||
const handleQuery = () => emit("queryClick", queryParams);
|
||||
const handleReset = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
emit("resetClick", queryParams);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
formItems.forEach((item) => {
|
||||
if (item?.initFn) {
|
||||
item.initFn(item);
|
||||
}
|
||||
if (["input-tag", "custom-tag", "cascader"].includes(item?.type ?? "")) {
|
||||
queryParams[item.prop] = Array.isArray(item.initialValue) ? item.initialValue : [];
|
||||
} else if (item.type === "input-number") {
|
||||
queryParams[item.prop] = item.initialValue ?? null;
|
||||
} else {
|
||||
queryParams[item.prop] = item.initialValue ?? "";
|
||||
}
|
||||
});
|
||||
});
|
||||
// 暴露的属性和方法
|
||||
defineExpose({
|
||||
// 获取分页数据
|
||||
getQueryParams: () => queryParams,
|
||||
// 显示/隐藏 SearchForm
|
||||
toggleVisible: () => (visible.value = !visible.value),
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-input-number .el-input__inner) {
|
||||
text-align: left;
|
||||
}
|
||||
.el-form-item {
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,225 @@
|
||||
import type { DialogProps, DrawerProps, FormItemRule, PaginationProps } from "element-plus";
|
||||
import type { FormProps, TableProps, ColProps, ButtonProps, CardProps } from "element-plus";
|
||||
import type PageContent from "./PageContent.vue";
|
||||
import type PageModal from "./PageModal.vue";
|
||||
import type PageSearch from "./PageSearch.vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
export type PageSearchInstance = InstanceType<typeof PageSearch>;
|
||||
export type PageContentInstance = InstanceType<typeof PageContent>;
|
||||
export type PageModalInstance = InstanceType<typeof PageModal>;
|
||||
|
||||
export type IObject = Record<string, any>;
|
||||
|
||||
type DateComponent = "date-picker" | "time-picker" | "time-select" | "custom-tag" | "input-tag";
|
||||
type InputComponent = "input" | "select" | "input-number" | "cascader" | "tree-select";
|
||||
type OtherComponent = "text" | "radio" | "checkbox" | "switch" | "icon-select" | "custom";
|
||||
export type ISearchComponent = DateComponent | InputComponent;
|
||||
export type IComponentType = DateComponent | InputComponent | OtherComponent;
|
||||
|
||||
type ToolbarLeft = "add" | "delete" | "import" | "export";
|
||||
type ToolbarRight = "refresh" | "filter" | "imports" | "exports" | "search";
|
||||
type ToolbarTable = "edit" | "view" | "delete";
|
||||
export type IToolsButton = {
|
||||
name: string; // 按钮名称
|
||||
text?: string; // 按钮文本
|
||||
perm?: Array<string> | string; // 权限标识(可以是完整权限字符串如'sys:user:add'或操作权限如'add')
|
||||
attrs?: Partial<ButtonProps> & { style?: CSSProperties }; // 按钮属性
|
||||
render?: (row: IObject) => boolean; // 条件渲染
|
||||
};
|
||||
export type IToolsDefault = ToolbarLeft | ToolbarRight | ToolbarTable | IToolsButton;
|
||||
|
||||
export interface IOperateData {
|
||||
name: string;
|
||||
row: IObject;
|
||||
column: IObject;
|
||||
$index: number;
|
||||
}
|
||||
|
||||
export interface ISearchConfig {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// 标签冒号(默认:false)
|
||||
colon?: boolean;
|
||||
// 表单项(默认:[])
|
||||
formItems?: IFormItems<ISearchComponent>;
|
||||
// 是否开启展开和收缩(默认:true)
|
||||
isExpandable?: boolean;
|
||||
// 默认展示的表单项数量(默认:3)
|
||||
showNumber?: number;
|
||||
// 卡片属性
|
||||
cardAttrs?: Partial<CardProps> & { style?: CSSProperties };
|
||||
// form组件属性
|
||||
form?: IForm;
|
||||
// 自适应网格布局(使用时表单不要添加 style: { width: "200px" })
|
||||
grid?: boolean | "left" | "right";
|
||||
}
|
||||
|
||||
export interface IContentConfig<T = any> {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// table组件属性
|
||||
table?: Omit<TableProps<any>, "data">;
|
||||
// 分页组件位置(默认:left)
|
||||
pagePosition?: "left" | "right";
|
||||
// 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<ToolbarLeft | IToolsButton>;
|
||||
// 表格工具栏右侧图标(默认:refresh,filter,imports,exports,search)
|
||||
defaultToolbar?: Array<ToolbarRight | IToolsButton>;
|
||||
// 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<ToolbarTable | IToolsButton>;
|
||||
// filter值拼接符
|
||||
filterJoin?: string;
|
||||
[key: string]: any;
|
||||
// 初始化数据函数
|
||||
initFn?: (item: IObject) => void;
|
||||
// 是否禁用
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface IModalConfig<T = any> {
|
||||
// 权限前缀(如sys:user,用于组成权限标识),不提供则不进行权限校验
|
||||
permPrefix?: string;
|
||||
// 标签冒号(默认:false)
|
||||
colon?: boolean;
|
||||
// 主键名(主要用于编辑数据,默认为id)
|
||||
pk?: string;
|
||||
// 组件类型(默认:dialog)
|
||||
component?: "dialog" | "drawer";
|
||||
// dialog组件属性
|
||||
dialog?: Partial<Omit<DialogProps, "modelValue">>;
|
||||
// drawer组件属性
|
||||
drawer?: Partial<Omit<DrawerProps, "modelValue">>;
|
||||
// form组件属性
|
||||
form?: IForm;
|
||||
// 表单项
|
||||
formItems: IFormItems<IComponentType>;
|
||||
// 提交之前处理
|
||||
beforeSubmit?: (data: T) => void;
|
||||
// 提交的网络请求函数(需返回promise)
|
||||
formAction?: (data: T) => Promise<any>;
|
||||
}
|
||||
|
||||
export type IForm = Partial<Omit<FormProps, "model" | "rules">>;
|
||||
|
||||
// 表单项
|
||||
export type IFormItems<T = IComponentType> = Array<{
|
||||
// 组件类型(如input,select,radio,custom等)
|
||||
type: T;
|
||||
// 标签提示
|
||||
tips?: string | IObject;
|
||||
// 标签文本
|
||||
label: string;
|
||||
// 键名
|
||||
prop: string;
|
||||
// 组件属性
|
||||
attrs?: IObject;
|
||||
// 组件可选项(只适用于select,radio,checkbox组件)
|
||||
options?: Array<{ label: string; value: any; [key: string]: any }> | Ref<any[]>;
|
||||
// 验证规则
|
||||
rules?: FormItemRule[];
|
||||
// 初始值
|
||||
initialValue?: any;
|
||||
// 插槽名(适用于自定义组件,设置类型为custom)
|
||||
slotName?: string;
|
||||
// 是否隐藏
|
||||
hidden?: boolean;
|
||||
// layout组件Col属性
|
||||
col?: Partial<ColProps>;
|
||||
// 组件事件
|
||||
events?: Record<string, (...args: any) => void>;
|
||||
// 初始化数据函数扩展
|
||||
initFn?: (item: IObject) => void;
|
||||
}>;
|
||||
|
||||
export interface IPageForm {
|
||||
// 主键名(主要用于编辑数据,默认为id)
|
||||
pk?: string;
|
||||
// form组件属性
|
||||
form?: IForm;
|
||||
// 表单项
|
||||
formItems: IFormItems<IComponentType>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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(RefImpl?: Ref<PageModalInstance>) {
|
||||
if (RefImpl) {
|
||||
RefImpl?.value.setModalVisible();
|
||||
RefImpl?.value.handleDisabled(false);
|
||||
} else {
|
||||
addModalRef.value?.setModalVisible();
|
||||
addModalRef.value?.handleDisabled(false);
|
||||
}
|
||||
}
|
||||
// 编辑
|
||||
async function handleEditClick(
|
||||
row: IObject,
|
||||
callback?: (result?: IObject) => IObject,
|
||||
RefImpl?: Ref<PageModalInstance>
|
||||
) {
|
||||
if (RefImpl) {
|
||||
RefImpl.value?.setModalVisible();
|
||||
RefImpl.value?.handleDisabled(false);
|
||||
const from = await (callback?.(row) ?? Promise.resolve(row));
|
||||
RefImpl.value?.setFormData(from ? from : row);
|
||||
} else {
|
||||
editModalRef.value?.setModalVisible();
|
||||
editModalRef.value?.handleDisabled(false);
|
||||
const from = await (callback?.(row) ?? Promise.resolve(row));
|
||||
editModalRef.value?.setFormData(from ? from : row);
|
||||
}
|
||||
}
|
||||
// 查看
|
||||
async function handleViewClick(
|
||||
row: IObject,
|
||||
callback?: (result?: IObject) => IObject,
|
||||
RefImpl?: Ref<PageModalInstance>
|
||||
) {
|
||||
if (RefImpl) {
|
||||
RefImpl.value?.setModalVisible();
|
||||
RefImpl.value?.handleDisabled(true);
|
||||
const from = await (callback?.(row) ?? Promise.resolve(row));
|
||||
RefImpl.value?.setFormData(from ? from : row);
|
||||
} else {
|
||||
editModalRef.value?.setModalVisible();
|
||||
editModalRef.value?.handleDisabled(true);
|
||||
const from = await (callback?.(row) ?? Promise.resolve(row));
|
||||
editModalRef.value?.setFormData(from ? from : 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,
|
||||
handleViewClick,
|
||||
handleSubmitClick,
|
||||
handleExportClick,
|
||||
handleSearchClick,
|
||||
handleFilterChange,
|
||||
};
|
||||
}
|
||||
|
||||
export default usePage;
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div cursor-pointer flex-center rounded class="el" :class="padding">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
padding: {
|
||||
type: String,
|
||||
default: "p-2",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
.el {
|
||||
transition: 0.3s var(--el-transition-function-ease-in-out-bezier);
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!-- 复制组件 -->
|
||||
<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 { t } = useI18n();
|
||||
|
||||
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(t("common.copySuccess"));
|
||||
})
|
||||
.catch((error) => {
|
||||
ElMessage.warning(t("common.copyFailed"));
|
||||
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(t("common.copySuccess"));
|
||||
} else {
|
||||
ElMessage.warning(t("common.copyFailed"));
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.warning(t("common.copyFailed"));
|
||||
console.log("[CopyButton] Copy failed.", err);
|
||||
} finally {
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleDarkChange">
|
||||
<el-icon :size="20">
|
||||
<component :is="settingsStore.theme === ThemeMode.DARK ? Moon : Sunny" />
|
||||
</el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="item in theneList"
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:disabled="settingsStore.theme === item.value"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="item.component" />
|
||||
</el-icon>
|
||||
{{ item.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@/store";
|
||||
import { ThemeMode } from "@/enums";
|
||||
import { Moon, Sunny } from "@element-plus/icons-vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
const theneList = [
|
||||
{ label: t("login.light"), value: ThemeMode.LIGHT, component: Sunny },
|
||||
{ label: t("login.dark"), value: ThemeMode.DARK, component: Moon },
|
||||
];
|
||||
|
||||
const handleDarkChange = (theme: ThemeMode) => {
|
||||
settingsStore.updateTheme(theme);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
* 基于 ECharts 的 Vue3 图表组件
|
||||
* 版权所有 © 2021-present 有来开源组织
|
||||
*
|
||||
* 开源协议:https://opensource.org/licenses/MIT
|
||||
* 项目地址:https://gitee.com/youlaiorg/vue3-element-admin
|
||||
* 参考:https://echarts.apache.org/handbook/zh/basics/import/#%E6%8C%89%E9%9C%80%E5%BC%95%E5%85%A5-echarts-%E5%9B%BE%E8%A1%A8%E5%92%8C%E7%BB%84%E4%BB%B6
|
||||
*
|
||||
* 在使用时,请保留此注释,感谢您对开源的支持!
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ width, height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
|
||||
import * as echarts from "echarts/core";
|
||||
// 引入柱状、折线和饼图常用图表
|
||||
import { BarChart, LineChart, PieChart, RadarChart } from "echarts/charts";
|
||||
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
|
||||
import { useResizeObserver } from "@vueuse/core";
|
||||
|
||||
// 按需注册组件
|
||||
echarts.use([
|
||||
RadarChart,
|
||||
CanvasRenderer,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
LegendComponent,
|
||||
]);
|
||||
|
||||
const props = defineProps<{
|
||||
options: echarts.EChartsCoreOption;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}>();
|
||||
|
||||
const chartRef = ref<HTMLDivElement | null>(null);
|
||||
let chartInstance: echarts.ECharts | null = null;
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (chartRef.value) {
|
||||
chartInstance = echarts.init(chartRef.value);
|
||||
if (props.options) {
|
||||
chartInstance.setOption(props.options);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听尺寸变化,自动调整
|
||||
useResizeObserver(chartRef, () => {
|
||||
chartInstance?.resize();
|
||||
});
|
||||
|
||||
// 监听 options 变化,更新图表
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
if (chartInstance && newOptions) {
|
||||
chartInstance.setOption(newOptions);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => initChart());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartInstance?.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,34 @@
|
||||
<!-- 自定义 iframe 组件 -->
|
||||
<template>
|
||||
<div v-loading="loading" :style="'height:' + height">
|
||||
<iframe
|
||||
:src="url"
|
||||
frameborder="no"
|
||||
style="width: 100%; height: 100%"
|
||||
scrolling="auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const height = ref(document.documentElement.clientHeight - 94.5 + "px;")
|
||||
const loading = ref(true)
|
||||
const url = computed(() => props.src)
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
loading.value = false;
|
||||
}, 300);
|
||||
window.onresize = function temp() {
|
||||
height.value = document.documentElement.clientHeight - 94.5 + "px;";
|
||||
};
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<!-- 全屏切换按钮 -->
|
||||
<template>
|
||||
<div @click="toggle">
|
||||
<div :class="`i-svg:` + (isFullscreen ? 'fullscreen-exit' : 'fullscreen')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!-- github 角标 -->
|
||||
<template>
|
||||
<a
|
||||
href="https://github.com/1014TaoTao/fastapi_vue3_admin"
|
||||
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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<!-- 引导页 -->
|
||||
<template>
|
||||
<el-tour
|
||||
v-model="open"
|
||||
:show-close="false"
|
||||
@change="handleChange"
|
||||
>
|
||||
<el-tour-step
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
:target="step.target"
|
||||
:title="step.title"
|
||||
:description="step.description"
|
||||
:prev-button-props="{
|
||||
children: t('common.prevLabel'),
|
||||
onClick: handlePrevClick
|
||||
}"
|
||||
:next-button-props="{
|
||||
children: nextBtnName(index),
|
||||
onClick: handleNextClick
|
||||
}"
|
||||
:placement="step.placement"
|
||||
/>
|
||||
<template #indicators>
|
||||
<el-button size="small" @click="handleSkip">{{ t("common.skipLabel") }}</el-button>
|
||||
</template>
|
||||
</el-tour>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useSettingsStore } from "@/store";
|
||||
|
||||
const settingStore = useSettingsStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// 是否可见
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
teleport: {
|
||||
type: [String, Object] as PropType<string | HTMLElement | null>,
|
||||
default: 'body',
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'prev', 'next', 'skip']);
|
||||
|
||||
const open = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
});
|
||||
|
||||
interface TourStep {
|
||||
target: string;
|
||||
title: string;
|
||||
description: string;
|
||||
placement: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
const layout = settingStore.layout;
|
||||
|
||||
const menuTarget = (): string => {
|
||||
if (layout === 'left') {
|
||||
return '.layout__sidebar';
|
||||
} else if (layout === 'top') {
|
||||
return '.layout__header-left';
|
||||
} else {
|
||||
return '.layout__header-menu';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 内置引导步骤数据
|
||||
const steps: TourStep[] = [
|
||||
{
|
||||
target: menuTarget(),
|
||||
title: t("common.menu"),
|
||||
description: t("common.menuDes"),
|
||||
placement: layout === 'left' ? 'right' : 'bottom'
|
||||
},
|
||||
{
|
||||
target: ".navbar-actions",
|
||||
title: t("common.tool"),
|
||||
description: t("common.toolDes"),
|
||||
placement: "bottom"
|
||||
},
|
||||
{
|
||||
target: ".tags-container",
|
||||
title: t("common.tagsView"),
|
||||
description: t("common.tagsViewDes"),
|
||||
placement: "bottom"
|
||||
},
|
||||
];
|
||||
|
||||
// 当前步数
|
||||
const currentStep = ref(0);
|
||||
|
||||
// 动态设置下一步按钮名称
|
||||
const nextBtnName = computed(() => (index: number) => {
|
||||
if (index === steps.length - 1) {
|
||||
return t('common.doneLabel');
|
||||
}
|
||||
return t('common.nextLabel');
|
||||
});
|
||||
|
||||
// 步数切换时触发
|
||||
function handleChange(step: number) {
|
||||
currentStep.value = step;
|
||||
emit('change', step);
|
||||
}
|
||||
|
||||
// 点击跳过按钮时触发
|
||||
function handleSkip() {
|
||||
open.value = false;
|
||||
emit('skip');
|
||||
}
|
||||
|
||||
// 点击上一步按钮时触发
|
||||
function handlePrevClick() {
|
||||
emit('prev');
|
||||
}
|
||||
|
||||
// 点击下一步按钮时触发
|
||||
function handleNextClick() {
|
||||
emit('next');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可根据需要添加自定义样式 */
|
||||
.el-tour__content .el-tour-indicators {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- 折叠按钮 -->
|
||||
<template>
|
||||
<div class="hamburger-wrapper" @click="toggleClick">
|
||||
<div :class="['i-svg:collapse', { hamburger: true, 'is-active': isActive }, hamburgerClass]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsStore } from "@/store";
|
||||
import { ThemeMode, SidebarColor } from "@/enums/settings/theme.enum";
|
||||
import { LayoutMode } from "@/enums/settings/layout.enum";
|
||||
|
||||
defineProps({
|
||||
isActive: { type: Boolean, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggleClick"]);
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const layout = computed(() => settingsStore.layout);
|
||||
|
||||
const hamburgerClass = computed(() => {
|
||||
// 如果暗黑主题
|
||||
if (settingsStore.theme === ThemeMode.DARK) {
|
||||
return "hamburger--white";
|
||||
}
|
||||
|
||||
// 如果是混合布局 && 导航背景方案是经典蓝
|
||||
if (
|
||||
layout.value === LayoutMode.MIX &&
|
||||
settingsStore.sidebarColorScheme === SidebarColor.CLASSIC_BLUE
|
||||
) {
|
||||
return "hamburger--white";
|
||||
}
|
||||
|
||||
// 默认返回空字符串
|
||||
return "";
|
||||
});
|
||||
|
||||
function toggleClick() {
|
||||
emit("toggleClick");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hamburger-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 15px;
|
||||
cursor: pointer;
|
||||
|
||||
.hamburger {
|
||||
vertical-align: middle;
|
||||
transform: scaleX(-1);
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&--white {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<!-- 图标选择器 -->
|
||||
<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>
|
||||
<div :class="`i-svg:${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">
|
||||
<div :class="`i-svg:${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>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<el-scrollbar>
|
||||
<div class="flex-y-center gap-2">
|
||||
<el-tag
|
||||
v-for="tag in tags"
|
||||
:key="tag"
|
||||
closable
|
||||
:disable-transitions="false"
|
||||
v-bind="config.tagAttrs"
|
||||
@close="handleClose(tag)"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<el-input
|
||||
v-if="inputVisible"
|
||||
ref="inputRef"
|
||||
v-model.trim="inputValue"
|
||||
style="min-width: 100px"
|
||||
@keyup.enter.stop.prevent="handleInputConfirm"
|
||||
@blur.stop.prevent="handleInputConfirm"
|
||||
/>
|
||||
<el-button v-else v-bind="config.buttonAttrs" @click="showInput">
|
||||
{{ config.buttonAttrs.btnText ? config.buttonAttrs.btnText : "+ New Tag" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { InputInstance } from "element-plus";
|
||||
|
||||
const inputValue = ref("");
|
||||
const inputVisible = ref(false);
|
||||
const inputRef = ref<InputInstance>();
|
||||
|
||||
// 定义 model,用于与父组件的 v-model绑定
|
||||
const tags = defineModel<string[]>();
|
||||
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object as () => {
|
||||
buttonAttrs: Record<string, any>;
|
||||
inputAttrs: Record<string, any>;
|
||||
tagAttrs: Record<string, any>;
|
||||
},
|
||||
default: () => ({
|
||||
buttonAttrs: {},
|
||||
inputAttrs: {},
|
||||
tagAttrs: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = (tag: string) => {
|
||||
if (tags.value) {
|
||||
const newTags = tags.value.filter((t) => t !== tag);
|
||||
tags.value = [...newTags];
|
||||
}
|
||||
};
|
||||
|
||||
const showInput = () => {
|
||||
inputVisible.value = true;
|
||||
nextTick(() => inputRef.value?.focus());
|
||||
};
|
||||
|
||||
const handleInputConfirm = () => {
|
||||
if (inputValue.value) {
|
||||
const newTags = [...(tags.value || []), inputValue.value];
|
||||
tags.value = newTags;
|
||||
}
|
||||
inputVisible.value = false;
|
||||
inputValue.value = "";
|
||||
};
|
||||
</script>
|
||||
@@ -1,42 +1,42 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="4">
|
||||
<a-form-item label="秒">
|
||||
<a-select v-model:value="crontabValueObj.second" placeholder="秒">
|
||||
<a-select-option v-for="second in seconds" :key="second" :value="second">{{ second }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-form-item label="分">
|
||||
<a-select v-model:value="crontabValueObj.min" placeholder="分">
|
||||
<a-select-option v-for="min in minutes" :key="min" :value="min">{{ min }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-form-item label="时">
|
||||
<a-select v-model:value="crontabValueObj.hour" placeholder="时">
|
||||
<a-select-option v-for="hour in hours" :key="hour" :value="hour">{{ hour }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-form-item label="天">
|
||||
<a-select v-model:value="crontabValueObj.day" placeholder="天">
|
||||
<a-select-option v-for="day in days" :key="day" :value="day">{{ day }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<a-form-item label="周">
|
||||
<a-select v-model:value="crontabValueObj.week" placeholder="周">
|
||||
<a-select-option v-for="week in weeks" :key="week" :value="week">{{ week }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="4">
|
||||
<el-form-item label="秒">
|
||||
<el-select v-model:value="crontabValueObj.second" placeholder="秒">
|
||||
<el-option v-for="second in seconds" :key="second" :value="second">{{ second }}</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="分">
|
||||
<el-select v-model:value="crontabValueObj.min" placeholder="分">
|
||||
<el-option v-for="min in minutes" :key="min" :value="min">{{ min }}</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="时">
|
||||
<el-select v-model:value="crontabValueObj.hour" placeholder="时">
|
||||
<el-option v-for="hour in hours" :key="hour" :value="hour">{{ hour }}</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="天">
|
||||
<el-select v-model:value="crontabValueObj.day" placeholder="天">
|
||||
<el-option v-for="day in days" :key="day" :value="day">{{ day }}</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="4">
|
||||
<el-form-item label="周">
|
||||
<el-select v-model:value="crontabValueObj.week" placeholder="周">
|
||||
<el-option v-for="week in weeks" :key="week" :value="week">{{ week }}</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -68,5 +68,4 @@ defineExpose({ handleConfirm, crontabValueObj })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 样式可以根据需要调整
|
||||
</style>
|
||||
// 样式可以根据需要调整</style>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- 语言切换 -->
|
||||
<template>
|
||||
<el-dropdown trigger="click" @command="handleLanguageChange">
|
||||
<div class="i-svg:language" :class="size" />
|
||||
<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 { useAppStore } from "@/store/modules/app.store";
|
||||
import { LanguageEnum } from "@/enums/settings/locale.enum";
|
||||
|
||||
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();
|
||||
|
||||
/**
|
||||
* 处理语言切换
|
||||
*
|
||||
* @param lang 语言(zh-cn、en)
|
||||
*/
|
||||
function handleLanguageChange(lang: string) {
|
||||
locale.value = lang;
|
||||
appStore.changeLanguage(lang);
|
||||
|
||||
ElMessage.success(t("langSelect.message.success"));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div @click="openSearchModal">
|
||||
<div class="i-svg: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">
|
||||
<!-- 搜索历史 -->
|
||||
<template v-if="searchKeyword === '' && searchHistory.length > 0">
|
||||
<div class="search-history">
|
||||
<div class="search-history__title">
|
||||
搜索历史
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
class="search-history__clear"
|
||||
@click="clearHistory"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<ul class="search-history__list">
|
||||
<li
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
class="search-history__item"
|
||||
@click="navigateToRoute(item)"
|
||||
>
|
||||
<div class="search-history__icon">
|
||||
<el-icon><Clock /></el-icon>
|
||||
</div>
|
||||
<span class="search-history__name">{{ item.title }}</span>
|
||||
<div class="search-history__action">
|
||||
<el-icon @click.stop="removeHistoryItem(index)"><Close /></el-icon>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<template v-else>
|
||||
<ul v-if="displayResults.length > 0">
|
||||
<li
|
||||
v-for="(item, index) in displayResults"
|
||||
:key="item.path"
|
||||
:class="[
|
||||
'search-result__item',
|
||||
{
|
||||
'search-result__item--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>
|
||||
<div v-else-if="item.icon" :class="`i-svg:${item.icon}`" />
|
||||
<div v-else class="i-svg:menu" />
|
||||
<span class="ml-2">{{ item.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- 无搜索历史显示 -->
|
||||
<div v-if="searchKeyword === '' && searchHistory.length === 0" class="no-history">
|
||||
<p class="no-history__text">没有搜索历史</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="ctrl-k-hint">
|
||||
<span class="ctrl-k-text">Ctrl+K 快速打开</span>
|
||||
</div>
|
||||
<div class="shortcuts-group">
|
||||
<div class="key-box">
|
||||
<div class="key-btn">选择</div>
|
||||
</div>
|
||||
<div class="arrow-box">
|
||||
<div class="arrow-up-down">
|
||||
<div class="key-btn">
|
||||
<div class="i-svg:up" />
|
||||
</div>
|
||||
<div class="key-btn ml-1">
|
||||
<div class="i-svg:down" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="key-text">切换</span>
|
||||
</div>
|
||||
<div class="key-box">
|
||||
<div class="key-btn esc-btn">ESC</div>
|
||||
<span class="key-text">关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import router from "@/router";
|
||||
import { usePermissionStore } from "@/store";
|
||||
import { isExternal } from "@/utils";
|
||||
import { RouteRecordRaw, LocationQueryRaw } from "vue-router";
|
||||
import { Clock, Close, Delete } from "@element-plus/icons-vue";
|
||||
|
||||
const HISTORY_KEY = "menu_search_history";
|
||||
const MAX_HISTORY = 5;
|
||||
|
||||
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);
|
||||
const searchHistory = ref<SearchItem[]>([]);
|
||||
|
||||
interface SearchItem {
|
||||
title: string;
|
||||
path: string;
|
||||
name?: string;
|
||||
icon?: string;
|
||||
redirect?: string;
|
||||
params?: LocationQueryRaw;
|
||||
}
|
||||
|
||||
// 从本地存储加载搜索历史
|
||||
function loadSearchHistory() {
|
||||
const historyStr = localStorage.getItem(HISTORY_KEY);
|
||||
if (historyStr) {
|
||||
try {
|
||||
searchHistory.value = JSON.parse(historyStr);
|
||||
} catch {
|
||||
searchHistory.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存搜索历史到本地存储
|
||||
function saveSearchHistory() {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(searchHistory.value));
|
||||
}
|
||||
|
||||
// 添加项目到搜索历史
|
||||
function addToHistory(item: SearchItem) {
|
||||
// 检查是否已存在
|
||||
const index = searchHistory.value.findIndex((i) => i.path === item.path);
|
||||
|
||||
// 如果存在则移除
|
||||
if (index !== -1) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
}
|
||||
|
||||
// 添加到历史开头
|
||||
searchHistory.value.unshift(item);
|
||||
|
||||
// 限制历史记录数量
|
||||
if (searchHistory.value.length > MAX_HISTORY) {
|
||||
searchHistory.value = searchHistory.value.slice(0, MAX_HISTORY);
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 移除历史记录项
|
||||
function removeHistoryItem(index: number) {
|
||||
searchHistory.value.splice(index, 1);
|
||||
saveSearchHistory();
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
function clearHistory() {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem(HISTORY_KEY);
|
||||
}
|
||||
|
||||
// 注册全局快捷键
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// 判断是否为Ctrl+K组合键
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
openSearchModal();
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
onMounted(() => {
|
||||
loadRoutes(permissionStore.routes);
|
||||
loadSearchHistory();
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 移除键盘事件监听
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
});
|
||||
|
||||
// 打开搜索模态框
|
||||
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();
|
||||
// 添加到历史记录
|
||||
addToHistory(item);
|
||||
|
||||
if (isExternal(item.path)) {
|
||||
window.open(item.path, "_blank");
|
||||
} else {
|
||||
router.push({ path: item.path, query: item.params });
|
||||
}
|
||||
}
|
||||
|
||||
function loadRoutes(routes: RouteRecordRaw[], parentPath = "") {
|
||||
routes.forEach((route) => {
|
||||
const path = route.path.startsWith("/")
|
||||
? route.path
|
||||
: `${parentPath}${parentPath.endsWith("/") ? "" : "/"}${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,
|
||||
params: route.meta.params
|
||||
? JSON.parse(JSON.stringify(toRaw(route.meta.params)))
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-result {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
|
||||
&--active {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-menu-hover-bg-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索历史样式 */
|
||||
.search-history {
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 34px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__clear {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__action {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
|
||||
.search-history__action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 没有搜索历史时的样式 */
|
||||
.no-history {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100px;
|
||||
|
||||
&__text {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shortcuts-group {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-box {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.arrow-up-down {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.key-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
height: 20px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
background-color: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
inset 0 -2px 0 0 var(--el-border-color),
|
||||
inset 0 0 1px 1px var(--el-color-white),
|
||||
0 1px 2px rgba(30, 35, 90, 0.2);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
left: 1px;
|
||||
height: 50%;
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0));
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.esc-btn {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.ctrl-k-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctrl-k-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
// 适配Element Plus对话框
|
||||
:deep(.el-dialog__footer) {
|
||||
box-sizing: border-box;
|
||||
padding-top: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
// 暗黑模式适配
|
||||
html.dark {
|
||||
.key-btn::before {
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!-- 顶部通知公告 -->
|
||||
<template>
|
||||
<el-dropdown trigger="click">
|
||||
<el-badge v-if="noticeList.length > 0" :value="noticeList.length" :max="99">
|
||||
<div class="i-svg:bell" />
|
||||
</el-badge>
|
||||
|
||||
<div v-else class="i-svg:bell" />
|
||||
|
||||
<template #dropdown>
|
||||
<div class="p-5">
|
||||
<template v-if="noticeList.length > 0">
|
||||
<div v-for="(item, index) in noticeList" :key="index" class="py-3">
|
||||
<div class="flex-y-center">
|
||||
<el-tag :type="item.notice_type === '1' ? 'primary' : 'warning'">
|
||||
{{ item.notice_type === '1' ? '通知' : '公告' }}
|
||||
</el-tag>
|
||||
|
||||
<!-- truncated: 超出部分省略 -->
|
||||
<el-text size="small" class="w-200px cursor-pointer !ml-2 !flex-1" truncated >
|
||||
{{ item.notice_title }}
|
||||
</el-text>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="text-xs text-gray">
|
||||
{{ item.created_at }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider />
|
||||
|
||||
<div class="flex-x-between">
|
||||
<el-link type="primary" underline="never" @click="handleViewMoreNotice">
|
||||
<span class="text-xs">查看更多</span>
|
||||
<el-icon class="text-xs">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
</el-link>
|
||||
<el-link v-if="noticeList.length > 0" type="primary" underline="never" @click="handleMarkAllAsRead">
|
||||
<span class="text-xs">全部已读</span>
|
||||
</el-link>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-center h-150px w-350px">
|
||||
<el-empty :image-size="50" description="暂无消息" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dialog v-model="noticeDialogVisible" :title="noticeDetail?.notice_title ?? '通知详情'" width="800px"
|
||||
custom-class="notification-detail">
|
||||
<div v-if="noticeDetail" class="p-x-20px">
|
||||
<div class="flex-y-center mb-16px text-13px text-color-secondary">
|
||||
<span class="flex-y-center">
|
||||
<el-icon>
|
||||
<User />
|
||||
</el-icon>
|
||||
{{ noticeDetail.creator?.username }}
|
||||
</span>
|
||||
<span class="ml-2 flex-y-center">
|
||||
<el-icon>
|
||||
<Timer />
|
||||
</el-icon>
|
||||
{{ noticeDetail.created_at }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-60vh pt-16px mb-24px overflow-y-auto border-t border-solid border-color">
|
||||
<div v-html="noticeDetail.notice_content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import NoticeAPI, { NoticeTable } from "@/api/system/notice";
|
||||
import router from "@/router";
|
||||
|
||||
const noticeList = ref<NoticeTable[]>([]);
|
||||
const noticeDialogVisible = ref(false);
|
||||
const noticeDetail = ref<NoticeTable | null>(null);
|
||||
|
||||
/**
|
||||
* 获取我的通知公告
|
||||
*/
|
||||
function featchMyNotice() {
|
||||
NoticeAPI.getNoticeList({ page_no: 1, page_size: 5, status: true }).then((response) => {
|
||||
noticeList.value = response.data.data.items;
|
||||
});
|
||||
}
|
||||
|
||||
// 查看更多
|
||||
function handleViewMoreNotice() {
|
||||
router.push({ name: "MyNotice" });
|
||||
}
|
||||
|
||||
// 全部已读
|
||||
function handleMarkAllAsRead() {
|
||||
NoticeAPI.batchAvailableNotice({
|
||||
ids: noticeList.value.map((item) => item.id),
|
||||
status: true
|
||||
}).then(() => {
|
||||
noticeList.value = [];
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
featchMyNotice();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
featchMyNotice();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<el-table-column
|
||||
:label="label"
|
||||
:fixed="fixed"
|
||||
:align="align"
|
||||
:show-overflow-tooltip="showOverflowTooltip"
|
||||
:width="finalWidth"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-auto-width class="operation-buttons">
|
||||
<slot :row="row"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
listDataLength: number;
|
||||
prop?: string;
|
||||
label?: string;
|
||||
fixed?: string;
|
||||
align?: string;
|
||||
width?: number;
|
||||
showOverflowTooltip?: boolean;
|
||||
minWidth?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
label: "操作",
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
minWidth: 80,
|
||||
});
|
||||
|
||||
const count = ref(0);
|
||||
const operationWidth = ref(props.minWidth || 80);
|
||||
|
||||
// 计算操作列宽度
|
||||
const calculateWidth = () => {
|
||||
count.value++;
|
||||
|
||||
if (count.value !== props.listDataLength) return;
|
||||
const maxWidth = getOperationMaxWidth();
|
||||
operationWidth.value = Math.max(maxWidth, props.minWidth);
|
||||
count.value = 0;
|
||||
};
|
||||
|
||||
// 计算最终宽度
|
||||
const finalWidth = computed(() => {
|
||||
return props.width || operationWidth.value || props.minWidth;
|
||||
});
|
||||
|
||||
// 自适应宽度指令
|
||||
const vAutoWidth = {
|
||||
mounted() {
|
||||
// 初次挂载的时候计算一次
|
||||
calculateWidth();
|
||||
},
|
||||
updated() {
|
||||
// 数据更新时重新计算一次
|
||||
calculateWidth();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取按钮数量和宽带来获取操作组的最大宽度
|
||||
* 注意使用时需要使用 `class="operation-buttons"` 的标签包裹操作按钮
|
||||
* @returns {number} 返回操作组的最大宽度
|
||||
*/
|
||||
const getOperationMaxWidth = () => {
|
||||
const el = document.getElementsByClassName("operation-buttons");
|
||||
|
||||
// 取操作组的最大宽度
|
||||
let maxWidth = 0;
|
||||
let totalWidth: any = 0;
|
||||
Array.prototype.forEach.call(el, (item) => {
|
||||
// 获取每个item的dom
|
||||
const buttons = item.querySelectorAll(".el-button");
|
||||
// 获取每行按钮的总宽度
|
||||
totalWidth = Array.from(buttons).reduce((acc, button: any) => {
|
||||
return acc + button.scrollWidth + 22; // 每个按钮的宽度加上预留宽度
|
||||
}, 0);
|
||||
|
||||
// 获取最大的宽度
|
||||
if (totalWidth > maxWidth) maxWidth = totalWidth;
|
||||
});
|
||||
|
||||
return maxWidth;
|
||||
};
|
||||
</script>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<a-page-header
|
||||
:style="headerStyle"
|
||||
:title="getPageTitle"
|
||||
>
|
||||
<!-- 可以添加更多插槽,让组件更灵活 -->
|
||||
<template #extra>
|
||||
<slot name="extra"></slot>
|
||||
</template>
|
||||
</a-page-header>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps<{
|
||||
title?: string;
|
||||
headerStyle?: string;
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// 计算属性获取页面标题
|
||||
const getPageTitle = computed(() => {
|
||||
return props.title || route.meta.title?.toString() || '未命名';
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -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: {
|
||||
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>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!-- 布局大小 -->
|
||||
<template>
|
||||
<el-tooltip :content="t('sizeSelect.tooltip')" effect="dark" placement="bottom">
|
||||
<el-dropdown trigger="click" @command="handleSizeChange">
|
||||
<div class="i-svg:size" />
|
||||
<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 { ComponentSize } from "@/enums/settings/layout.enum";
|
||||
import { useAppStore } from "@/store/modules/app.store";
|
||||
|
||||
const { t } = useI18n();
|
||||
const sizeOptions = computed(() => {
|
||||
return [
|
||||
{ label: t("sizeSelect.default"), value: ComponentSize.DEFAULT },
|
||||
{ label: t("sizeSelect.large"), value: ComponentSize.LARGE },
|
||||
{ label: t("sizeSelect.small"), value: ComponentSize.SMALL },
|
||||
];
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
function handleSizeChange(size: string) {
|
||||
appStore.changeSize(size);
|
||||
ElMessage.success(t("sizeSelect.message.success"));
|
||||
}
|
||||
</script>
|
||||
@@ -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 { 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,
|
||||
});
|
||||
|
||||
// 计算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[]) {
|
||||
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>
|
||||
@@ -0,0 +1,427 @@
|
||||
<!--
|
||||
TextScroll 组件 - 文本滚动公告
|
||||
|
||||
功能:
|
||||
- 支持水平方向文本滚动
|
||||
- 提供多种预设样式(默认、成功、警告、危险、信息)
|
||||
- 支持自定义滚动速度和方向
|
||||
- 可选的打字机输入效果
|
||||
- 鼠标悬停时暂停滚动
|
||||
- 可选的关闭按钮
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="text-scroll-container"
|
||||
:class="[`text-scroll--${props.type}`]"
|
||||
:typewriter="props.typewriter ? 'true' : undefined"
|
||||
>
|
||||
<!-- 左侧图标 -->
|
||||
<div class="left-icon">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</div>
|
||||
<!-- 滚动内容包装器 -->
|
||||
<div class="scroll-wrapper">
|
||||
<div
|
||||
ref="scrollContent"
|
||||
class="text-scroll-content"
|
||||
:class="{ scrolling: shouldScroll }"
|
||||
:style="scrollStyle"
|
||||
>
|
||||
<!-- 滚动内容,复制两份以实现无缝滚动 -->
|
||||
<div class="scroll-item" v-html="sanitizedContent" />
|
||||
<div class="scroll-item" v-html="sanitizedContent" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 可选的关闭按钮 -->
|
||||
<div v-if="showClose" class="right-icon" @click="handleRightIconClick">
|
||||
<el-icon><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 使用案例 -->
|
||||
<!-- <div class="app-container">
|
||||
<TextScroll text="这是一条基础的滚动公告,默认向左滚动。" typewriter />
|
||||
|
||||
<TextScroll type="success" text="这是一条成功类型的滚动公告" typewriter />
|
||||
|
||||
<TextScroll type="warning" text="这是一条警告类型的滚动公告" />
|
||||
|
||||
<TextScroll type="danger" text="这是一条危险类型的滚动公告" />
|
||||
|
||||
<TextScroll type="info" text="这是一条信息类型的滚动公告" />
|
||||
|
||||
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" show-close />
|
||||
</div> -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementHover } from "@vueuse/core";
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
interface Props {
|
||||
/** 滚动文本内容(必填) */
|
||||
text: string;
|
||||
/** 滚动速度,数值越小滚动越慢 */
|
||||
speed?: number;
|
||||
/** 滚动方向:左侧或右侧 */
|
||||
direction?: "left" | "right";
|
||||
/** 样式类型 */
|
||||
type?: "default" | "success" | "warning" | "danger" | "info";
|
||||
/** 是否显示关闭按钮 */
|
||||
showClose?: boolean;
|
||||
/** 是否启用打字机效果 */
|
||||
typewriter?: boolean;
|
||||
/** 打字机效果的速度,数值越小打字越快 */
|
||||
typewriterSpeed?: number;
|
||||
}
|
||||
|
||||
// 定义组件属性及默认值
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
speed: 70,
|
||||
direction: "left",
|
||||
type: "default",
|
||||
showClose: false,
|
||||
typewriter: false,
|
||||
typewriterSpeed: 100,
|
||||
});
|
||||
|
||||
// 容器元素引用
|
||||
const containerRef = ref<HTMLElement | null>(null);
|
||||
// 使用 vueuse 的 useElementHover 检测鼠标悬停状态
|
||||
const isHovered = useElementHover(containerRef);
|
||||
// 滚动内容元素引用
|
||||
const scrollContent = ref<HTMLElement | null>(null);
|
||||
// 动画持续时间(秒)
|
||||
const animationDuration = ref(0);
|
||||
|
||||
/**
|
||||
* 打字机效果相关状态
|
||||
*/
|
||||
// 当前已显示的文本内容
|
||||
const currentText = ref("");
|
||||
// 打字机定时器引用,用于清理
|
||||
let typewriterTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// 打字机效果是否已完成
|
||||
const isTypewriterComplete = ref(false);
|
||||
|
||||
/**
|
||||
* 计算是否应该滚动
|
||||
* 条件:
|
||||
* 1. 鼠标未悬停在组件上
|
||||
* 2. 如果启用了打字机效果,则需要等待打字效果完成
|
||||
*/
|
||||
const shouldScroll = computed(() => {
|
||||
if (props.typewriter) {
|
||||
return !isHovered.value && isTypewriterComplete.value;
|
||||
}
|
||||
return !isHovered.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 计算最终显示的内容
|
||||
* 如果启用了打字机效果,则显示当前已打出的文本
|
||||
* 否则直接显示完整文本
|
||||
* 注意:内容支持 HTML,使用时需注意 XSS 风险
|
||||
*/
|
||||
const sanitizedContent = computed(() => (props.typewriter ? currentText.value : props.text));
|
||||
|
||||
/**
|
||||
* 计算滚动样式
|
||||
* 包括动画持续时间、播放状态和方向
|
||||
* 这些值通过 CSS 变量传递给样式
|
||||
*/
|
||||
const scrollStyle = computed(() => ({
|
||||
"--animation-duration": `${animationDuration.value}s`,
|
||||
"--animation-play-state": shouldScroll.value ? "running" : "paused",
|
||||
"--animation-direction": props.direction === "left" ? "normal" : "reverse",
|
||||
}));
|
||||
|
||||
/**
|
||||
* 计算动画持续时间
|
||||
* 根据内容宽度和设定的速度计算出合适的动画持续时间
|
||||
* 内容越长或速度值越小,动画持续时间越长
|
||||
*/
|
||||
const calculateDuration = () => {
|
||||
if (scrollContent.value) {
|
||||
const contentWidth = scrollContent.value.scrollWidth / 2;
|
||||
animationDuration.value = contentWidth / props.speed;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理关闭按钮点击事件
|
||||
* 触发 close 事件,并直接销毁当前组件
|
||||
*/
|
||||
const handleRightIconClick = () => {
|
||||
emit("close");
|
||||
// 获取当前组件的DOM元素
|
||||
if (containerRef.value) {
|
||||
// 从DOM中移除元素
|
||||
containerRef.value.remove();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 启动打字机效果
|
||||
* 逐字显示文本内容,完成后设置状态以开始滚动
|
||||
*/
|
||||
const startTypewriter = () => {
|
||||
let index = 0;
|
||||
currentText.value = "";
|
||||
isTypewriterComplete.value = false; // 重置状态
|
||||
|
||||
// 递归函数,逐字添加文本
|
||||
const type = () => {
|
||||
if (index < props.text.length) {
|
||||
// 添加一个字符
|
||||
currentText.value += props.text[index];
|
||||
index++;
|
||||
// 设置下一个字符的延迟
|
||||
typewriterTimer = setTimeout(type, props.typewriterSpeed);
|
||||
} else {
|
||||
// 所有字符都已添加,设置完成状态
|
||||
isTypewriterComplete.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 开始打字过程
|
||||
type();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 计算初始动画持续时间
|
||||
calculateDuration();
|
||||
// 监听窗口大小变化,重新计算动画持续时间
|
||||
window.addEventListener("resize", calculateDuration);
|
||||
|
||||
// 如果启用了打字机效果,开始打字
|
||||
if (props.typewriter) {
|
||||
startTypewriter();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
window.removeEventListener("resize", calculateDuration);
|
||||
// 清除打字机定时器
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 监听文本内容变化
|
||||
* 当文本内容变化时,如果启用了打字机效果,重新开始打字
|
||||
*/
|
||||
watch(
|
||||
() => props.text,
|
||||
() => {
|
||||
if (props.typewriter) {
|
||||
// 清除现有定时器
|
||||
if (typewriterTimer) {
|
||||
clearTimeout(typewriterTimer);
|
||||
}
|
||||
// 重新开始打字效果
|
||||
startTypewriter();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.text-scroll-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-right: 16px;
|
||||
overflow: hidden;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border: 1px solid var(--main-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.left-icon,
|
||||
.right-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
.left-icon {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right-icon {
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
flex: 1;
|
||||
margin-left: 34px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.text-scroll-content {
|
||||
display: flex;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
white-space: nowrap;
|
||||
animation: scroll linear infinite;
|
||||
animation-duration: var(--animation-duration);
|
||||
animation-direction: var(--animation-direction);
|
||||
animation-play-state: var(--animation-play-state);
|
||||
|
||||
.scroll-item {
|
||||
display: inline-block;
|
||||
min-width: 100%;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary-light-2) !important;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
|
||||
:deep(a) {
|
||||
color: #fd4e4e !important;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加类型样式
|
||||
&.text-scroll--default {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border-color: var(--el-color-primary);
|
||||
|
||||
.right-icon,
|
||||
.left-icon i {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--success {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
border-color: var(--el-color-success);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-success-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-success) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--warning {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
border-color: var(--el-color-warning);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-warning-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-warning) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--danger {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
border-color: var(--el-color-danger);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-danger-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.text-scroll--info {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
border-color: var(--el-color-info);
|
||||
|
||||
.left-icon {
|
||||
background-color: var(--el-color-info-light-9) !important;
|
||||
|
||||
i {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-item {
|
||||
color: var(--el-color-info) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加打字机效果的光标样式
|
||||
.text-scroll-content .scroll-item {
|
||||
&::after {
|
||||
content: "";
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在启用打字机效果时显示光标
|
||||
.text-scroll-container[typewriter] .text-scroll-content .scroll-item::after {
|
||||
content: "|";
|
||||
opacity: 0;
|
||||
animation: cursor 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes cursor {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,124 +0,0 @@
|
||||
<template>
|
||||
<a-tabs
|
||||
v-model:activeKey="activeKey"
|
||||
hide-add
|
||||
:animated="false"
|
||||
type="editable-card"
|
||||
@edit="onEdit"
|
||||
@change="onChange"
|
||||
>
|
||||
|
||||
<a-tab-pane
|
||||
v-for="pane in panes"
|
||||
:key="pane.key"
|
||||
:tab="pane.title"
|
||||
:closable="pane.closable"
|
||||
/>
|
||||
<!-- 使用 rightExtra 插槽添加关闭全部按钮 -->
|
||||
<template #rightExtra>
|
||||
<!-- 修改为以 icon 显示的刷新缓存按钮 -->
|
||||
<a-button type="link" @click="refreshCache">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button type="link" @click="closeAllTabs">关闭全部</a-button>
|
||||
</template>
|
||||
</a-tabs>
|
||||
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'; // 导入刷新图标
|
||||
interface Pane {
|
||||
title: string;
|
||||
path: string;
|
||||
key: string;
|
||||
closable?: boolean;
|
||||
}
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const panes = ref<Pane[]>([
|
||||
{
|
||||
title: '工作台',
|
||||
path: '/dashboard/workplace',
|
||||
key: '/dashboard/workplace',
|
||||
closable: false
|
||||
}
|
||||
]);
|
||||
const activeKey = ref(panes.value[0].key);
|
||||
const getRoute = () => {
|
||||
const existingPane = panes.value.some(pane => pane.key === route.path);
|
||||
if (!existingPane) {
|
||||
panes.value.push({
|
||||
title: route.meta.title?.toString() || '未命名',
|
||||
path: route.path,
|
||||
key: route.path,
|
||||
closable: true
|
||||
});
|
||||
}
|
||||
activeKey.value = route.path;
|
||||
}
|
||||
const removePane = (targetKey: string) => {
|
||||
const paneIndex = panes.value.findIndex(pane => pane.key === targetKey);
|
||||
panes.value = panes.value.filter(pane => pane.key !== targetKey);
|
||||
|
||||
if (activeKey.value === targetKey && panes.value.length) {
|
||||
const newIndex = Math.max(0, paneIndex - 1);
|
||||
activeKey.value = panes.value[newIndex].key;
|
||||
router.push(activeKey.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = (targetKey: string | MouseEvent, action: string) => {
|
||||
if (action === 'remove') {
|
||||
removePane(targetKey as string);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (targetKey: string) => {
|
||||
if (targetKey !== 'close-all') {
|
||||
router.push(targetKey);
|
||||
} else {
|
||||
// 防止跳转到不存在的路由
|
||||
activeKey.value = panes.value[0].key;
|
||||
}
|
||||
};
|
||||
|
||||
// 新增关闭所有标签的方法
|
||||
const closeAllTabs = () => {
|
||||
// 过滤出不可关闭的标签
|
||||
panes.value = panes.value.filter(pane => !pane.closable);
|
||||
if (panes.value.length > 0) {
|
||||
activeKey.value = panes.value[0].key;
|
||||
router.push(activeKey.value);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增刷新缓存的方法
|
||||
const refreshCache = () => {
|
||||
// 刷新页面
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
onMounted(getRoute);
|
||||
watch(() => route.path, getRoute);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 添加以下样式 */
|
||||
:deep(.ant-tabs-nav) {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-ink-bar) {
|
||||
transition: transform 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<!-- 文件上传组件 -->
|
||||
<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.stop="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,
|
||||
UploadFile,
|
||||
UploadFiles,
|
||||
UploadProgressEvent,
|
||||
UploadRequestOptions,
|
||||
} from "element-plus";
|
||||
|
||||
import FileAPI, { FileInfo } from "@/api/file.api";
|
||||
|
||||
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<FileInfo[]>,
|
||||
required: true,
|
||||
default: () => [],
|
||||
});
|
||||
|
||||
const fileList = ref([] as UploadFile[]);
|
||||
|
||||
const showProgress = ref(false);
|
||||
const progressPercent = ref(0);
|
||||
|
||||
// 监听 modelValue 转换用于显示的 fileList
|
||||
watch(
|
||||
modelValue,
|
||||
(value) => {
|
||||
fileList.value = value.map((item) => {
|
||||
const name = item.name ? item.name : item.url?.substring(item.url.lastIndexOf("/") + 1);
|
||||
return {
|
||||
name,
|
||||
url: item.url,
|
||||
status: "success",
|
||||
uid: getUid(),
|
||||
} as UploadFile;
|
||||
});
|
||||
},
|
||||
{
|
||||
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 = (response: any, uploadFile: UploadFile, files: UploadFiles) => {
|
||||
ElMessage.success("上传成功");
|
||||
//只有当状态为success或者fail,代表文件上传全部完成了,失败也算完成
|
||||
if (
|
||||
files.every((file: UploadFile) => {
|
||||
return file.status === "success" || file.status === "fail";
|
||||
})
|
||||
) {
|
||||
const fileInfos = [] as FileInfo[];
|
||||
files.map((file: UploadFile) => {
|
||||
if (file.status === "success") {
|
||||
//只取携带response的才是刚上传的
|
||||
const res = file.response as FileInfo;
|
||||
if (res) {
|
||||
fileInfos.push({ name: res.name, url: res.url } as FileInfo);
|
||||
}
|
||||
} else {
|
||||
//失败上传 从fileList删掉,不展示
|
||||
fileList.value.splice(
|
||||
fileList.value.findIndex((e) => e.uid === file.uid),
|
||||
1
|
||||
);
|
||||
}
|
||||
});
|
||||
if (fileInfos.length > 0) {
|
||||
modelValue.value = [...modelValue.value, ...fileInfos];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传失败
|
||||
*/
|
||||
const handleError = (_error: any) => {
|
||||
console.error(_error);
|
||||
ElMessage.error("上传失败");
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
function handleRemove(fileUrl: string) {
|
||||
FileAPI.delete(fileUrl).then(() => {
|
||||
modelValue.value = modelValue.value.filter((file) => file.url !== fileUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function handleDownload(file: UploadUserFile) {
|
||||
const { url, name } = file;
|
||||
if (url) {
|
||||
FileAPI.download(url, name);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取一个不重复的id */
|
||||
function getUid(): number {
|
||||
// 时间戳左移13位(相当于乘以8192) + 4位随机数
|
||||
return (Date.now() << 13) | Math.floor(Math.random() * 8192);
|
||||
}
|
||||
</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;
|
||||
transform: translateY(-50%);
|
||||
transition: opacity var(--el-transition-duration);
|
||||
}
|
||||
|
||||
:deep(.el-upload-list) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-upload-list__item) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,215 @@
|
||||
<!-- 图片上传组件 -->
|
||||
<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><View /></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.api";
|
||||
|
||||
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[]>,
|
||||
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() {
|
||||
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>
|
||||
@@ -0,0 +1,242 @@
|
||||
<!-- 单图上传组件 -->
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<el-icon v-if="modelValue" class="single-upload__preview-btn" @click.stop="handlePreview(modelValue)">
|
||||
<View />
|
||||
</el-icon>
|
||||
</template>
|
||||
</el-upload>
|
||||
|
||||
<el-image-viewer
|
||||
v-if="showPreview"
|
||||
:url-list="srcList"
|
||||
show-progress
|
||||
:initial-index=previewImageIndex
|
||||
@close="showPreview = false"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadRawFile, UploadRequestOptions, ElImageViewer } from "element-plus";
|
||||
import FileAPI, { FileInfo } from "@/api/file.api";
|
||||
|
||||
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,
|
||||
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 = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览
|
||||
*/
|
||||
const showPreview = ref(false); // 是否显示预览
|
||||
const srcList = ref<string[]>([]);
|
||||
const previewImageIndex = ref(0); // 预览图片的索引
|
||||
|
||||
function handlePreview(imagePath: string) {
|
||||
srcList.value = [imagePath];
|
||||
previewImageIndex.value = 0;
|
||||
showPreview.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传成功回调
|
||||
*
|
||||
* @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: v-bind("props.style.width");
|
||||
height: v-bind("props.style.height");
|
||||
}
|
||||
|
||||
.single-upload {
|
||||
position: relative;
|
||||
width: v-bind("props.style.width");
|
||||
height: v-bind("props.style.height");
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
&__preview-btn {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 1px;
|
||||
font-size: 16px;
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 100%;
|
||||
|
||||
:hover {
|
||||
color: #1d7bff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<!--
|
||||
* 基于 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 var(--el-border-color)">
|
||||
<!-- 工具栏 -->
|
||||
<Toolbar
|
||||
:editor="editorRef"
|
||||
mode="simple"
|
||||
:default-config="toolbarConfig"
|
||||
style="border-bottom: 1px solid var(--el-border-color)"
|
||||
/>
|
||||
<!-- 编辑器 -->
|
||||
<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.api";
|
||||
|
||||
// 上传图片回调函数类型
|
||||
type InsertFnType = (_url: string, _alt: string, _href: string) => void;
|
||||
|
||||
defineProps({
|
||||
height: {
|
||||
type: String,
|
||||
default: "500px",
|
||||
},
|
||||
});
|
||||
// 双向绑定
|
||||
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>
|
||||
Reference in New Issue
Block a user