mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 14:52:56 +00:00
refactor: 将组件前缀从Art统一替换为Fa
- 更新所有FA前缀的组件文件 - 新增Fa前缀的对应组件 - 更新所有引用路径和组件名 - 修改相关样式和变量名 - 调整部分组件目录结构
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
<!-- 更多按钮 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElDropdown v-if="hasAnyAuthItem">
|
||||
<FaIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<template v-for="item in list" :key="item.key">
|
||||
<ElDropdownItem
|
||||
v-if="!item.auth || hasAuth(item.auth)"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex-c gap-2" :style="{ color: item.color }">
|
||||
<FaSvgIcon v-if="item.icon" :icon="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</template>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuth } from "@/hooks/core/useAuth";
|
||||
|
||||
defineOptions({ name: "FaButtonMore" });
|
||||
|
||||
const { hasAuth } = useAuth();
|
||||
|
||||
export interface ButtonMoreItem {
|
||||
/** 按钮标识,可用于点击事件 */
|
||||
key: string | number;
|
||||
/** 按钮文本 */
|
||||
label: string;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 权限标识 */
|
||||
auth?: string;
|
||||
/** 图标组件 */
|
||||
icon?: string;
|
||||
/** 文本颜色 */
|
||||
color?: string;
|
||||
/** 图标颜色(优先级高于 color) */
|
||||
iconColor?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 下拉项列表 */
|
||||
list: ButtonMoreItem[];
|
||||
/** 整体权限控制 */
|
||||
auth?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
// 检查是否有任何有权限的 item
|
||||
const hasAnyAuthItem = computed(() => {
|
||||
return props.list.some((item) => !item.auth || hasAuth(item.auth));
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", item: ButtonMoreItem): void;
|
||||
}>();
|
||||
|
||||
const handleClick = (item: ButtonMoreItem) => {
|
||||
emit("click", item);
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- 表格按钮 -->
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
|
||||
buttonClass,
|
||||
]"
|
||||
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<FaSvgIcon :icon="iconContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaButtonTable" });
|
||||
|
||||
interface Props {
|
||||
/** 按钮类型 */
|
||||
type?: "add" | "edit" | "delete" | "more" | "view";
|
||||
/** 按钮图标 */
|
||||
icon?: string;
|
||||
/** 按钮样式类 */
|
||||
iconClass?: string;
|
||||
/** icon 颜色 */
|
||||
iconColor?: string;
|
||||
/** 按钮背景色 */
|
||||
buttonBgColor?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click"): void;
|
||||
}>();
|
||||
|
||||
// 默认按钮配置
|
||||
const defaultButtons = {
|
||||
add: { icon: "ri:add-fill", class: "bg-theme/12 text-theme" },
|
||||
edit: { icon: "ri:pencil-line", class: "bg-secondary/12 text-secondary" },
|
||||
delete: { icon: "ri:delete-bin-5-line", class: "bg-error/12 text-error" },
|
||||
view: { icon: "ri:eye-line", class: "bg-info/12 text-info" },
|
||||
more: { icon: "ri:more-2-fill", class: "" },
|
||||
} as const;
|
||||
|
||||
// 获取图标内容
|
||||
const iconContent = computed(() => {
|
||||
return props.icon || (props.type ? defaultButtons[props.type]?.icon : "") || "";
|
||||
});
|
||||
|
||||
// 获取按钮样式类
|
||||
const buttonClass = computed(() => {
|
||||
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : "") || "";
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
emit("click");
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,429 @@
|
||||
<!-- 拖拽验证组件 -->
|
||||
<template>
|
||||
<div
|
||||
ref="dragVerify"
|
||||
class="drag_verify"
|
||||
:style="dragVerifyStyle"
|
||||
@mousemove="dragMoving"
|
||||
@mouseup="dragFinish"
|
||||
@mouseleave="dragFinish"
|
||||
@touchmove="dragMoving"
|
||||
@touchend="dragFinish"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="dv_progress_bar"
|
||||
:class="{ goFirst2: isOk }"
|
||||
ref="progressBar"
|
||||
:style="progressBarStyle"
|
||||
></div>
|
||||
|
||||
<!-- 提示文本 -->
|
||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||
{{ message }}
|
||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 滑块处理器 -->
|
||||
<div
|
||||
class="dv_handler dv_handler_bg"
|
||||
:class="{ goFirst: isOk }"
|
||||
@mousedown="dragStart"
|
||||
@touchstart="dragStart"
|
||||
ref="handler"
|
||||
:style="handlerStyle"
|
||||
>
|
||||
<FaSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></FaSvgIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaDragVerify" });
|
||||
|
||||
// 事件定义
|
||||
const emit = defineEmits(["handlerMove", "update:value", "passCallback"]);
|
||||
|
||||
// 组件属性接口定义
|
||||
interface PropsType {
|
||||
/** 是否通过验证 */
|
||||
value: boolean;
|
||||
/** 组件宽度 */
|
||||
width?: number | string;
|
||||
/** 组件高度 */
|
||||
height?: number;
|
||||
/** 默认提示文本 */
|
||||
text?: string;
|
||||
/** 成功提示文本 */
|
||||
successText?: string;
|
||||
/** 背景色 */
|
||||
background?: string;
|
||||
/** 进度条背景色 */
|
||||
progressBarBg?: string;
|
||||
/** 完成状态背景色 */
|
||||
completedBg?: string;
|
||||
/** 是否圆角 */
|
||||
circle?: boolean;
|
||||
/** 圆角大小 */
|
||||
radius?: string;
|
||||
/** 滑块图标 */
|
||||
handlerIcon?: string;
|
||||
/** 成功图标 */
|
||||
successIcon?: string;
|
||||
/** 滑块背景色 */
|
||||
handlerBg?: string;
|
||||
/** 文本大小 */
|
||||
textSize?: string;
|
||||
/** 文本颜色 */
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
// 属性默认值设置
|
||||
const props = withDefaults(defineProps<PropsType>(), {
|
||||
value: false,
|
||||
width: "100%",
|
||||
height: 40,
|
||||
text: "按住滑块拖动",
|
||||
successText: "success",
|
||||
background: "#eee",
|
||||
progressBarBg: "#1385FF",
|
||||
completedBg: "#57D187",
|
||||
circle: false,
|
||||
radius: "calc(var(--custom-radius) / 3 + 2px)",
|
||||
handlerIcon: "solar:double-alt-arrow-right-linear",
|
||||
successIcon: "ri:check-fill",
|
||||
handlerBg: "#fff",
|
||||
textSize: "13px",
|
||||
textColor: "#333",
|
||||
});
|
||||
|
||||
// 组件状态接口定义
|
||||
interface StateType {
|
||||
isMoving: boolean; // 是否正在拖拽
|
||||
x: number; // 拖拽起始位置
|
||||
isOk: boolean; // 是否验证成功
|
||||
}
|
||||
|
||||
// 响应式状态定义
|
||||
const state = reactive(<StateType>{
|
||||
isMoving: false,
|
||||
x: 0,
|
||||
isOk: false,
|
||||
});
|
||||
|
||||
// 解构响应式状态
|
||||
const { isOk } = toRefs(state);
|
||||
|
||||
// DOM 元素引用
|
||||
const dragVerify = ref();
|
||||
const messageRef = ref();
|
||||
const handler = ref();
|
||||
const progressBar = ref();
|
||||
|
||||
// 触摸事件变量 - 用于禁止页面滑动
|
||||
let startX: number, startY: number, moveX: number, moveY: number;
|
||||
|
||||
/**
|
||||
* 触摸开始事件处理
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchStart = (e: any) => {
|
||||
startX = e.targetTouches[0].pageX;
|
||||
startY = e.targetTouches[0].pageY;
|
||||
};
|
||||
|
||||
/**
|
||||
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchMove = (e: any) => {
|
||||
moveX = e.targetTouches[0].pageX;
|
||||
moveY = e.targetTouches[0].pageY;
|
||||
|
||||
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
|
||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 全局事件监听器添加
|
||||
document.addEventListener("touchstart", onTouchStart);
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
|
||||
// 获取数值形式的宽度
|
||||
const getNumericWidth = (): number => {
|
||||
if (typeof props.width === "string") {
|
||||
// 如果是字符串,尝试从DOM元素获取实际宽度
|
||||
return dragVerify.value?.offsetWidth || 260;
|
||||
}
|
||||
return props.width;
|
||||
};
|
||||
|
||||
// 获取样式字符串形式的宽度
|
||||
const getStyleWidth = (): string => {
|
||||
if (typeof props.width === "string") {
|
||||
return props.width;
|
||||
}
|
||||
return props.width + "px";
|
||||
};
|
||||
|
||||
// 组件挂载后的初始化
|
||||
onMounted(() => {
|
||||
// 设置 CSS 自定义属性
|
||||
dragVerify.value?.style.setProperty("--textColor", props.textColor);
|
||||
|
||||
// 等待DOM更新后设置宽度相关属性
|
||||
nextTick(() => {
|
||||
const numericWidth = getNumericWidth();
|
||||
dragVerify.value?.style.setProperty("--width", Math.floor(numericWidth / 2) + "px");
|
||||
dragVerify.value?.style.setProperty("--pwidth", -Math.floor(numericWidth / 2) + "px");
|
||||
});
|
||||
|
||||
// 重复添加事件监听器(确保事件绑定)
|
||||
document.addEventListener("touchstart", onTouchStart);
|
||||
document.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
});
|
||||
|
||||
// 组件卸载前清理事件监听器
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("touchstart", onTouchStart);
|
||||
document.removeEventListener("touchmove", onTouchMove);
|
||||
});
|
||||
|
||||
// 滑块样式计算
|
||||
const handlerStyle = {
|
||||
left: "0",
|
||||
width: props.height + "px",
|
||||
height: props.height + "px",
|
||||
background: props.handlerBg,
|
||||
};
|
||||
|
||||
// 主容器样式计算
|
||||
const dragVerifyStyle = computed(() => ({
|
||||
width: getStyleWidth(),
|
||||
height: props.height + "px",
|
||||
lineHeight: props.height + "px",
|
||||
background: props.background,
|
||||
borderRadius: props.circle ? props.height / 2 + "px" : props.radius,
|
||||
}));
|
||||
|
||||
// 进度条样式计算
|
||||
const progressBarStyle = {
|
||||
background: props.progressBarBg,
|
||||
height: props.height + "px",
|
||||
borderRadius: props.circle
|
||||
? props.height / 2 + "px 0 0 " + props.height / 2 + "px"
|
||||
: props.radius,
|
||||
};
|
||||
|
||||
// 文本样式计算
|
||||
const textStyle = computed(() => ({
|
||||
fontSize: props.textSize,
|
||||
}));
|
||||
|
||||
// 显示消息计算属性
|
||||
const message = computed(() => {
|
||||
return props.value ? props.successText : props.text;
|
||||
});
|
||||
|
||||
/**
|
||||
* 拖拽开始处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragStart = (e: any) => {
|
||||
if (!props.value) {
|
||||
state.isMoving = true;
|
||||
handler.value.style.transition = "none";
|
||||
// 计算拖拽起始位置
|
||||
state.x =
|
||||
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace("px", ""), 10);
|
||||
}
|
||||
emit("handlerMove");
|
||||
};
|
||||
|
||||
/**
|
||||
* 拖拽移动处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragMoving = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth();
|
||||
// 计算当前位置
|
||||
const _x = (e.pageX || e.touches[0].pageX) - state.x;
|
||||
|
||||
// 在有效范围内移动
|
||||
if (_x > 0 && _x <= numericWidth - props.height) {
|
||||
handler.value.style.left = _x + "px";
|
||||
progressBar.value.style.width = _x + props.height / 2 + "px";
|
||||
} else if (_x > numericWidth - props.height) {
|
||||
// 拖拽到末端,触发验证成功
|
||||
handler.value.style.left = numericWidth - props.height + "px";
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
|
||||
passVerify();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 拖拽结束处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragFinish = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth();
|
||||
// 计算最终位置
|
||||
const _x = (e.pageX || e.changedTouches[0].pageX) - state.x;
|
||||
|
||||
if (_x < numericWidth - props.height) {
|
||||
// 未拖拽到末端,重置位置
|
||||
state.isOk = true;
|
||||
handler.value.style.left = "0";
|
||||
handler.value.style.transition = "all 0.2s";
|
||||
progressBar.value.style.width = "0";
|
||||
state.isOk = false;
|
||||
} else {
|
||||
// 拖拽到末端,保持验证成功状态
|
||||
handler.value.style.transition = "none";
|
||||
handler.value.style.left = numericWidth - props.height + "px";
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + "px";
|
||||
passVerify();
|
||||
}
|
||||
state.isMoving = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证通过处理函数
|
||||
*/
|
||||
const passVerify = () => {
|
||||
emit("update:value", true);
|
||||
state.isMoving = false;
|
||||
// 更新样式为成功状态
|
||||
progressBar.value.style.background = props.completedBg;
|
||||
messageRef.value.style["-webkit-text-fill-color"] = "unset";
|
||||
messageRef.value.style.animation = "slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite";
|
||||
messageRef.value.style.color = "#fff";
|
||||
emit("passCallback");
|
||||
};
|
||||
|
||||
/**
|
||||
* 重置验证状态函数
|
||||
*/
|
||||
const reset = () => {
|
||||
// 重置滑块位置
|
||||
handler.value.style.left = "0";
|
||||
progressBar.value.style.width = "0";
|
||||
progressBar.value.style.background = props.progressBarBg;
|
||||
// 重置文本样式
|
||||
messageRef.value.style["-webkit-text-fill-color"] = "transparent";
|
||||
messageRef.value.style.animation = "slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite";
|
||||
messageRef.value.style.color = props.background;
|
||||
// 重置状态
|
||||
emit("update:value", false);
|
||||
state.isOk = false;
|
||||
state.isMoving = false;
|
||||
state.x = 0;
|
||||
};
|
||||
|
||||
// 暴露重置方法给父组件
|
||||
defineExpose({
|
||||
reset,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drag_verify {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
|
||||
.dv_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
|
||||
i {
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.el-icon-circle-check {
|
||||
margin-top: 9px;
|
||||
color: #6c6;
|
||||
}
|
||||
}
|
||||
|
||||
.dv_progress_bar {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.dv_text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--textColor) 0%,
|
||||
var(--textColor) 40%,
|
||||
#fff 50%,
|
||||
var(--textColor) 60%,
|
||||
var(--textColor) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-size-adjust: none;
|
||||
|
||||
* {
|
||||
-webkit-text-fill-color: var(--textColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goFirst {
|
||||
left: 0 !important;
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.goFirst2 {
|
||||
width: 0 !important;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes slidetounlock {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slidetounlock2 {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,541 @@
|
||||
<!-- 导出 Excel 文件 -->
|
||||
<template>
|
||||
<ElButton
|
||||
:type="type"
|
||||
:size="size"
|
||||
:loading="isExporting"
|
||||
:disabled="disabled || !hasData"
|
||||
v-ripple
|
||||
@click="handleExport"
|
||||
>
|
||||
<template #loading>
|
||||
<ElIcon class="is-loading">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
{{ loadingText }}
|
||||
</template>
|
||||
<slot>{{ buttonText }}</slot>
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from "xlsx";
|
||||
import FileSaver from "file-saver";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { Loading } from "@element-plus/icons-vue";
|
||||
import type { ButtonType } from "element-plus";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
|
||||
defineOptions({ name: "FaExcelExport" });
|
||||
|
||||
/** 导出数据类型 */
|
||||
type ExportValue = string | number | boolean | null | undefined | Date;
|
||||
|
||||
interface ExportData {
|
||||
[key: string]: ExportValue;
|
||||
}
|
||||
|
||||
/** 列配置 */
|
||||
interface ColumnConfig {
|
||||
/** 列标题 */
|
||||
title: string;
|
||||
/** 列宽度 */
|
||||
width?: number;
|
||||
/** 数据格式化函数 */
|
||||
formatter?: (value: ExportValue, row: ExportData, index: number) => string;
|
||||
}
|
||||
|
||||
/** 导出配置选项 */
|
||||
interface ExportOptions {
|
||||
/** 数据源 */
|
||||
data: ExportData[];
|
||||
/** 文件名(不含扩展名) */
|
||||
filename?: string;
|
||||
/** 工作表名称 */
|
||||
sheetName?: string;
|
||||
/** 按钮类型 */
|
||||
type?: ButtonType;
|
||||
/** 按钮尺寸 */
|
||||
size?: "large" | "default" | "small";
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 按钮文本 */
|
||||
buttonText?: string;
|
||||
/** 加载中文本 */
|
||||
loadingText?: string;
|
||||
/** 是否自动添加序号列 */
|
||||
autoIndex?: boolean;
|
||||
/** 序号列标题 */
|
||||
indexColumnTitle?: string;
|
||||
/** 列配置映射 */
|
||||
columns?: Record<string, ColumnConfig>;
|
||||
/** 表头映射(简化版本,向后兼容) */
|
||||
headers?: Record<string, string>;
|
||||
/** 最大导出行数 */
|
||||
maxRows?: number;
|
||||
/** 是否显示成功消息 */
|
||||
showSuccessMessage?: boolean;
|
||||
/** 是否显示错误消息 */
|
||||
showErrorMessage?: boolean;
|
||||
/** 工作簿配置 */
|
||||
workbookOptions?: {
|
||||
/** 创建者 */
|
||||
creator?: string;
|
||||
/** 最后修改者 */
|
||||
lastModifiedBy?: string;
|
||||
/** 创建时间 */
|
||||
created?: Date;
|
||||
/** 修改时间 */
|
||||
modified?: Date;
|
||||
};
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetName: "Sheet1",
|
||||
type: "primary",
|
||||
size: "default",
|
||||
disabled: false,
|
||||
buttonText: "导出 Excel",
|
||||
loadingText: "导出中...",
|
||||
autoIndex: false,
|
||||
indexColumnTitle: "序号",
|
||||
columns: () => ({}),
|
||||
headers: () => ({}),
|
||||
maxRows: 100000,
|
||||
showSuccessMessage: true,
|
||||
showErrorMessage: true,
|
||||
workbookOptions: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"before-export": [data: ExportData[]];
|
||||
"export-success": [filename: string, rowCount: number];
|
||||
"export-error": [error: ExportError];
|
||||
"export-progress": [progress: number];
|
||||
}>();
|
||||
|
||||
/** 导出错误类型 */
|
||||
class ExportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ExportError";
|
||||
}
|
||||
}
|
||||
|
||||
const isExporting = ref(false);
|
||||
|
||||
/** 是否有数据可导出 */
|
||||
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0);
|
||||
|
||||
/** 验证导出数据 */
|
||||
const validateData = (data: ExportData[]): void => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ExportError("数据必须是数组格式", "INVALID_DATA_TYPE");
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new ExportError("没有可导出的数据", "NO_DATA");
|
||||
}
|
||||
|
||||
if (data.length > props.maxRows) {
|
||||
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, "EXCEED_MAX_ROWS", {
|
||||
currentRows: data.length,
|
||||
maxRows: props.maxRows,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化单元格值 */
|
||||
const formatCellValue = (
|
||||
value: ExportValue,
|
||||
key: string,
|
||||
row: ExportData,
|
||||
index: number
|
||||
): string => {
|
||||
// 使用列配置的格式化函数
|
||||
const column = props.columns[key];
|
||||
if (column?.formatter) {
|
||||
return column.formatter(value, row, index);
|
||||
}
|
||||
|
||||
// 处理特殊值
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "是" : "否";
|
||||
}
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/** 处理数据 */
|
||||
const processData = (data: ExportData[]): Record<string, string>[] => {
|
||||
const processedData = data.map((item, index) => {
|
||||
const processedItem: Record<string, string> = {};
|
||||
|
||||
// 添加序号列
|
||||
if (props.autoIndex) {
|
||||
processedItem[props.indexColumnTitle] = String(index + 1);
|
||||
}
|
||||
|
||||
// 处理数据列
|
||||
Object.entries(item).forEach(([key, value]) => {
|
||||
// 获取列标题
|
||||
let columnTitle = key;
|
||||
if (props.columns[key]?.title) {
|
||||
columnTitle = props.columns[key].title;
|
||||
} else if (props.headers[key]) {
|
||||
columnTitle = props.headers[key];
|
||||
}
|
||||
|
||||
// 格式化值
|
||||
processedItem[columnTitle] = formatCellValue(value, key, item, index);
|
||||
});
|
||||
|
||||
return processedItem;
|
||||
});
|
||||
|
||||
return processedData;
|
||||
};
|
||||
|
||||
/** 计算列宽度 */
|
||||
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
|
||||
if (data.length === 0) return [];
|
||||
|
||||
const sampleSize = Math.min(data.length, 100); // 只取前100行计算列宽
|
||||
const columns = Object.keys(data[0]);
|
||||
|
||||
return columns.map((column) => {
|
||||
// 使用配置的列宽度
|
||||
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width;
|
||||
|
||||
if (configWidth) {
|
||||
return { wch: configWidth };
|
||||
}
|
||||
|
||||
// 自动计算列宽度
|
||||
const maxLength = Math.max(
|
||||
column.length, // 标题长度
|
||||
...data.slice(0, sampleSize).map((row) => String(row[column] || "").length)
|
||||
);
|
||||
|
||||
// 限制最小和最大宽度
|
||||
const width = Math.min(Math.max(maxLength + 2, 8), 50);
|
||||
return { wch: width };
|
||||
});
|
||||
};
|
||||
|
||||
/** 导出到 Excel */
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
emit("export-progress", 10);
|
||||
|
||||
// 处理数据
|
||||
const processedData = processData(data);
|
||||
emit("export-progress", 30);
|
||||
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// 设置工作簿属性
|
||||
if (props.workbookOptions) {
|
||||
workbook.Props = {
|
||||
Title: filename,
|
||||
Subject: "数据导出",
|
||||
Author: props.workbookOptions.creator || "Art Design Pro",
|
||||
Manager: props.workbookOptions.lastModifiedBy || "",
|
||||
Company: "系统导出",
|
||||
Category: "数据",
|
||||
Keywords: "excel,export,data",
|
||||
Comments: "由系统自动生成",
|
||||
CreatedDate: props.workbookOptions.created || new Date(),
|
||||
ModifiedDate: props.workbookOptions.modified || new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
emit("export-progress", 50);
|
||||
|
||||
// 创建工作表
|
||||
const worksheet = XLSX.utils.json_to_sheet(processedData);
|
||||
|
||||
// 设置列宽度
|
||||
worksheet["!cols"] = calculateColumnWidths(processedData);
|
||||
|
||||
emit("export-progress", 70);
|
||||
|
||||
// 添加工作表到工作簿
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
|
||||
emit("export-progress", 85);
|
||||
|
||||
// 生成 Excel 文件
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: "xlsx",
|
||||
type: "array",
|
||||
compression: true,
|
||||
});
|
||||
|
||||
// 创建 Blob 并下载
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
emit("export-progress", 95);
|
||||
|
||||
// 使用时间戳确保文件名唯一
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const finalFilename = `${filename}_${timestamp}.xlsx`;
|
||||
|
||||
FileSaver.saveAs(blob, finalFilename);
|
||||
|
||||
emit("export-progress", 100);
|
||||
|
||||
// 等待下载开始
|
||||
await nextTick();
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, "EXPORT_FAILED", error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 处理导出 */
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return;
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
try {
|
||||
// 验证数据
|
||||
validateData(props.data);
|
||||
|
||||
// 触发导出前事件
|
||||
emit("before-export", props.data);
|
||||
|
||||
// 执行导出
|
||||
await exportToExcel(props.data, props.filename, props.sheetName);
|
||||
|
||||
// 触发成功事件
|
||||
emit("export-success", props.filename, props.data.length);
|
||||
|
||||
// 显示成功消息
|
||||
if (props.showSuccessMessage) {
|
||||
ElMessage.success({
|
||||
message: `成功导出 ${props.data.length} 条数据`,
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const exportError =
|
||||
error instanceof ExportError
|
||||
? error
|
||||
: new ExportError(`导出失败: ${(error as Error).message}`, "UNKNOWN_ERROR", error);
|
||||
|
||||
// 触发错误事件
|
||||
emit("export-error", exportError);
|
||||
|
||||
// 显示错误消息
|
||||
if (props.showErrorMessage) {
|
||||
ElMessage.error({
|
||||
message: exportError.message,
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
console.error("Excel 导出错误:", exportError);
|
||||
} finally {
|
||||
isExporting.value = false;
|
||||
emit("export-progress", 0);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
exportData: handleExport,
|
||||
isExporting: readonly(isExporting),
|
||||
hasData,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- <template>
|
||||
<div class="page-content">
|
||||
<FaExcelImport @import-success="handleImportSuccess" @import-error="handleImportError">
|
||||
<template #import-text>上传 Excel</template>
|
||||
</FaExcelImport>
|
||||
|
||||
<FaExcelExport
|
||||
style="margin-left: 10px"
|
||||
:data="tableData"
|
||||
filename="用户数据-1"
|
||||
sheetName="用户列表"
|
||||
type="success"
|
||||
:headers="headers"
|
||||
auto-index
|
||||
:columns="columnConfig"
|
||||
@export-success="handleExportSuccess"
|
||||
@export-error="handleExportError"
|
||||
@export-progress="handleProgress"
|
||||
>
|
||||
导出 Excel
|
||||
</FaExcelExport>
|
||||
|
||||
<ElButton type="danger" @click="handleClear" v-ripple>清除数据</ElButton>
|
||||
|
||||
<FaTable :data="tableData" style="margin-top: 10px">
|
||||
<ElTableColumn
|
||||
v-for="key in Object.keys(headers)"
|
||||
:key="key"
|
||||
:prop="key"
|
||||
:label="headers[key as keyof typeof headers]"
|
||||
/>
|
||||
</FaTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaExcelExportDemo" });
|
||||
|
||||
/**
|
||||
* 表格数据类型定义
|
||||
*/
|
||||
interface TableData {
|
||||
name: string;
|
||||
age: number;
|
||||
city: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据
|
||||
*/
|
||||
const tableData = ref<TableData[]>([
|
||||
{ name: "李四", age: 20, city: "上海" },
|
||||
{ name: "张三", age: 25, city: "北京" },
|
||||
{ name: "王五", age: 30, city: "广州" },
|
||||
{ name: "赵六", age: 35, city: "深圳" },
|
||||
{ name: "孙七", age: 28, city: "杭州" },
|
||||
{ name: "周八", age: 32, city: "成都" },
|
||||
{ name: "吴九", age: 27, city: "武汉" },
|
||||
{ name: "郑十", age: 40, city: "南京" },
|
||||
{ name: "刘一", age: 22, city: "重庆" },
|
||||
{ name: "陈二", age: 33, city: "西安" },
|
||||
]);
|
||||
|
||||
/**
|
||||
* 表头映射配置
|
||||
* 用于 Excel 导入导出时的字段映射
|
||||
*/
|
||||
const headers = {
|
||||
name: "姓名",
|
||||
age: "年龄",
|
||||
city: "城市",
|
||||
};
|
||||
|
||||
/**
|
||||
* 列配置
|
||||
* 用于 Excel 导出时的列宽和格式化
|
||||
*/
|
||||
const columnConfig = {
|
||||
name: {
|
||||
title: "姓名",
|
||||
width: 20,
|
||||
formatter: (value: unknown) => (value ? String(value) : "未知"),
|
||||
},
|
||||
age: {
|
||||
title: "年龄",
|
||||
width: 10,
|
||||
formatter: (value: unknown) => (value ? `${value}岁` : "0岁"),
|
||||
},
|
||||
city: {
|
||||
title: "城市",
|
||||
width: 12,
|
||||
formatter: (value: unknown) => (value ? `${value}市` : "未知"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入成功
|
||||
* 将导入的数据转换为表格数据格式
|
||||
* @param data 导入的原始数据
|
||||
*/
|
||||
const handleImportSuccess = (data: Array<Record<string, unknown>>) => {
|
||||
const formattedData: TableData[] = data.map((item) => ({
|
||||
name: String(item["姓名"] || ""),
|
||||
age: Number(item["年龄"]) || 0,
|
||||
city: String(item["城市"] || ""),
|
||||
}));
|
||||
tableData.value = formattedData;
|
||||
ElMessage.success(`成功导入 ${formattedData.length} 条数据`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleImportError = (error: Error) => {
|
||||
console.error("导入失败:", error);
|
||||
ElMessage.error(`导入失败: ${error.message}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出成功
|
||||
*/
|
||||
const handleExportSuccess = () => {
|
||||
console.log("导出成功");
|
||||
ElMessage.success("Excel 导出成功");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleExportError = (error: Error) => {
|
||||
ElMessage.error(`导出失败: ${error.message}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理导出进度
|
||||
* @param progress 导出进度百分比
|
||||
*/
|
||||
const handleProgress = (progress: number) => {
|
||||
console.log("导出进度:", progress);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空表格数据
|
||||
*/
|
||||
const handleClear = () => {
|
||||
tableData.value = [];
|
||||
ElMessage.info("已清空数据");
|
||||
};
|
||||
</script> -->
|
||||
@@ -0,0 +1,214 @@
|
||||
<!-- 导入 Excel 文件 -->
|
||||
<template>
|
||||
<div class="inline-block">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<ElButton type="primary" v-ripple>
|
||||
<slot>导入 Excel</slot>
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from "xlsx";
|
||||
import type { UploadFile } from "element-plus";
|
||||
|
||||
defineOptions({ name: "FaExcelImport" });
|
||||
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result;
|
||||
const workbook = XLSX.read(data, { type: "array" });
|
||||
const firstSheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[firstSheetName];
|
||||
const results = XLSX.utils.sheet_to_json(worksheet);
|
||||
resolve(results as Array<Record<string, unknown>>);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = (error) => reject(error);
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
"import-success": [data: Array<Record<string, unknown>>];
|
||||
"import-error": [error: Error];
|
||||
}>();
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return;
|
||||
const results = await importExcel(uploadFile.raw);
|
||||
emit("import-success", results);
|
||||
} catch (error) {
|
||||
emit("import-error", error as Error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- <template>
|
||||
<div class="page-content">
|
||||
<FaExcelImport @import-success="handleImportSuccess" @import-error="handleImportError">
|
||||
<template #import-text>上传 Excel</template>
|
||||
</FaExcelImport>
|
||||
|
||||
<FaExcelExport
|
||||
style="margin-left: 10px"
|
||||
:data="tableData"
|
||||
filename="用户数据-1"
|
||||
sheetName="用户列表"
|
||||
type="success"
|
||||
:headers="headers"
|
||||
auto-index
|
||||
:columns="columnConfig"
|
||||
@export-success="handleExportSuccess"
|
||||
@export-error="handleExportError"
|
||||
@export-progress="handleProgress"
|
||||
>
|
||||
导出 Excel
|
||||
</FaExcelExport>
|
||||
|
||||
<ElButton type="danger" @click="handleClear" v-ripple>清除数据</ElButton>
|
||||
|
||||
<FaTable :data="tableData" style="margin-top: 10px">
|
||||
<ElTableColumn
|
||||
v-for="key in Object.keys(headers)"
|
||||
:key="key"
|
||||
:prop="key"
|
||||
:label="headers[key as keyof typeof headers]"
|
||||
/>
|
||||
</FaTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaExcelImportDemo" });
|
||||
|
||||
/**
|
||||
* 表格数据类型定义
|
||||
*/
|
||||
interface TableData {
|
||||
name: string;
|
||||
age: number;
|
||||
city: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格数据
|
||||
*/
|
||||
const tableData = ref<TableData[]>([
|
||||
{ name: "李四", age: 20, city: "上海" },
|
||||
{ name: "张三", age: 25, city: "北京" },
|
||||
{ name: "王五", age: 30, city: "广州" },
|
||||
{ name: "赵六", age: 35, city: "深圳" },
|
||||
{ name: "孙七", age: 28, city: "杭州" },
|
||||
{ name: "周八", age: 32, city: "成都" },
|
||||
{ name: "吴九", age: 27, city: "武汉" },
|
||||
{ name: "郑十", age: 40, city: "南京" },
|
||||
{ name: "刘一", age: 22, city: "重庆" },
|
||||
{ name: "陈二", age: 33, city: "西安" },
|
||||
]);
|
||||
|
||||
/**
|
||||
* 表头映射配置
|
||||
* 用于 Excel 导入导出时的字段映射
|
||||
*/
|
||||
const headers = {
|
||||
name: "姓名",
|
||||
age: "年龄",
|
||||
city: "城市",
|
||||
};
|
||||
|
||||
/**
|
||||
* 列配置
|
||||
* 用于 Excel 导出时的列宽和格式化
|
||||
*/
|
||||
const columnConfig = {
|
||||
name: {
|
||||
title: "姓名",
|
||||
width: 20,
|
||||
formatter: (value: unknown) => (value ? String(value) : "未知"),
|
||||
},
|
||||
age: {
|
||||
title: "年龄",
|
||||
width: 10,
|
||||
formatter: (value: unknown) => (value ? `${value}岁` : "0岁"),
|
||||
},
|
||||
city: {
|
||||
title: "城市",
|
||||
width: 12,
|
||||
formatter: (value: unknown) => (value ? `${value}市` : "未知"),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入成功
|
||||
* 将导入的数据转换为表格数据格式
|
||||
* @param data 导入的原始数据
|
||||
*/
|
||||
const handleImportSuccess = (data: Array<Record<string, unknown>>) => {
|
||||
const formattedData: TableData[] = data.map((item) => ({
|
||||
name: String(item["姓名"] || ""),
|
||||
age: Number(item["年龄"]) || 0,
|
||||
city: String(item["城市"] || ""),
|
||||
}));
|
||||
tableData.value = formattedData;
|
||||
ElMessage.success(`成功导入 ${formattedData.length} 条数据`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导入错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleImportError = (error: Error) => {
|
||||
console.error("导入失败:", error);
|
||||
ElMessage.error(`导入失败: ${error.message}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出成功
|
||||
*/
|
||||
const handleExportSuccess = () => {
|
||||
console.log("导出成功");
|
||||
ElMessage.success("Excel 导出成功");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 Excel 导出错误
|
||||
* @param error 错误对象
|
||||
*/
|
||||
const handleExportError = (error: Error) => {
|
||||
ElMessage.error(`导出失败: ${error.message}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理导出进度
|
||||
* @param progress 导出进度百分比
|
||||
*/
|
||||
const handleProgress = (progress: number) => {
|
||||
console.log("导出进度:", progress);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空表格数据
|
||||
*/
|
||||
const handleClear = () => {
|
||||
tableData.value = [];
|
||||
ElMessage.info("已清空数据");
|
||||
};
|
||||
</script> -->
|
||||
@@ -0,0 +1,507 @@
|
||||
<!-- 表单组件 -->
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow class="flex flex-wrap" :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
:model-value="getFieldValue(item.key)"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
{{ t("table.form.reset") }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSubmit"
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
@click="handleSubmit"
|
||||
v-ripple
|
||||
:disabled="disabledSubmit"
|
||||
>
|
||||
{{ t("table.form.submit") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toRaw, type Component } from "vue";
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance,
|
||||
} from "element-plus";
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils/form";
|
||||
|
||||
defineOptions({ name: "FaForm" });
|
||||
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputtag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect, // 树选择器
|
||||
};
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const { t } = useI18n();
|
||||
const isMobile = computed(() => width.value < 500);
|
||||
|
||||
const formInstance = useTemplateRef<FormInstance>("formRef");
|
||||
|
||||
// 表单项配置
|
||||
export interface FormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string;
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component;
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number;
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string;
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component;
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean;
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number;
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>;
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>;
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>;
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
|
||||
// 表单配置
|
||||
interface FormProps {
|
||||
/** 表单数据 */
|
||||
items: FormItem[];
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number;
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number;
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: "left" | "right" | "top";
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number;
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number;
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean;
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmit?: boolean;
|
||||
/** 是否禁用提交按钮 */
|
||||
disabledSubmit?: boolean;
|
||||
/** 提交时是否清洗空值 */
|
||||
sanitizeOutput?: Partial<SanitizeOutputOptions>;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
/** 移除空字符串 */
|
||||
removeEmptyString: boolean;
|
||||
/** 移除空数组 */
|
||||
removeEmptyArray: boolean;
|
||||
/** 移除清洗后为空的对象 */
|
||||
removeEmptyObject: boolean;
|
||||
/** 移除空富文本占位内容,如 <p><br></p> */
|
||||
removeEmptyRichText: boolean;
|
||||
/** 保留数字 0 这类有效值 */
|
||||
keepZero: boolean;
|
||||
/** 保留 false 这类有效值 */
|
||||
keepFalse: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FormProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
labelPosition: "right",
|
||||
labelWidth: "70px",
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSubmit: true,
|
||||
disabledSubmit: false,
|
||||
sanitizeOutput: () => ({}),
|
||||
});
|
||||
|
||||
interface FormEmits {
|
||||
reset: [];
|
||||
submit: [Record<string, any>];
|
||||
}
|
||||
|
||||
const emit = defineEmits<FormEmits>();
|
||||
|
||||
const modelValue = defineModel<Record<string, any>>({ default: {} });
|
||||
const initialModelValue = ref<Record<string, any>>({});
|
||||
|
||||
// 保存组件初始化时的表单快照,用于 reset 时恢复默认值。
|
||||
const cloneModelValue = (value: Record<string, any> | undefined) => {
|
||||
if (!value) return {};
|
||||
|
||||
const deepClone = (source: unknown): unknown => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item) => deepClone(item));
|
||||
}
|
||||
|
||||
if (source && typeof source === "object") {
|
||||
const rawSource = toRaw(source);
|
||||
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
|
||||
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
return deepClone(toRaw(value)) as Record<string, any>;
|
||||
};
|
||||
|
||||
initialModelValue.value = cloneModelValue(modelValue.value);
|
||||
|
||||
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
|
||||
// 输出时的清洗策略默认偏“接口友好”,但允许按业务覆盖。
|
||||
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
|
||||
removeEmptyString: true,
|
||||
removeEmptyArray: true,
|
||||
removeEmptyObject: true,
|
||||
removeEmptyRichText: true,
|
||||
keepZero: true,
|
||||
keepFalse: true,
|
||||
...props.sanitizeOutput,
|
||||
}));
|
||||
|
||||
const PATH_NUMBER_RE = /^\d+$/;
|
||||
|
||||
// 兼容 a.b、a.0.b 这类路径写法,数字段会被当作数组索引处理。
|
||||
const parsePath = (path: string) => {
|
||||
return path
|
||||
.split(".")
|
||||
.filter(Boolean)
|
||||
.map((segment) => (PATH_NUMBER_RE.test(segment) ? Number(segment) : segment));
|
||||
};
|
||||
|
||||
const getFieldValue = (path: string) => {
|
||||
return parsePath(path).reduce<any>((currentValue, segment) => {
|
||||
if (currentValue == null) return undefined;
|
||||
return currentValue[segment];
|
||||
}, modelValue.value);
|
||||
};
|
||||
|
||||
// 清空字段时只删除路径的最后一段,避免误删同级数据。
|
||||
const deleteFieldValue = (path: string) => {
|
||||
const segments = parsePath(path);
|
||||
if (!segments.length) return;
|
||||
|
||||
const lastSegment = segments.pop();
|
||||
const parent = segments.reduce<any>((currentValue, segment) => {
|
||||
if (currentValue == null) return undefined;
|
||||
return currentValue[segment];
|
||||
}, modelValue.value);
|
||||
|
||||
if (parent != null && lastSegment !== undefined) {
|
||||
delete parent[lastSegment];
|
||||
}
|
||||
};
|
||||
|
||||
// 表单清空输入时不保留空字符串,同时按路径自动补齐中间对象或数组。
|
||||
const setFieldValue = (path: string, value: unknown) => {
|
||||
const normalizedValue = value === "" ? undefined : value;
|
||||
const segments = parsePath(path);
|
||||
|
||||
if (!segments.length) return;
|
||||
|
||||
if (normalizedValue === undefined) {
|
||||
deleteFieldValue(path);
|
||||
return;
|
||||
}
|
||||
|
||||
let currentValue: any = modelValue.value;
|
||||
|
||||
segments.forEach((segment, index) => {
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
if (isLast) {
|
||||
currentValue[segment] = normalizedValue;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSegment = segments[index + 1];
|
||||
const nextContainer = typeof nextSegment === "number" ? [] : {};
|
||||
|
||||
if (
|
||||
currentValue[segment] === null ||
|
||||
currentValue[segment] === undefined ||
|
||||
typeof currentValue[segment] !== "object"
|
||||
) {
|
||||
currentValue[segment] = nextContainer;
|
||||
}
|
||||
|
||||
currentValue = currentValue[segment];
|
||||
});
|
||||
};
|
||||
|
||||
const isRichTextEmpty = (value: string) => {
|
||||
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
|
||||
return (
|
||||
value
|
||||
.replace(/ /gi, "")
|
||||
.replace(/<br\s*\/?>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim() === ""
|
||||
);
|
||||
};
|
||||
|
||||
// 提交时按配置清洗空值,但保留 0 和 false 这类有效值。
|
||||
const sanitizeOutputValue = (value: unknown): unknown => {
|
||||
const options = sanitizeOutputOptions.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeOutputValue(item))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const rawValue = toRaw(value);
|
||||
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
|
||||
(accumulator, [key, item]) => {
|
||||
const sanitizedItem = sanitizeOutputValue(item);
|
||||
if (sanitizedItem !== undefined) {
|
||||
accumulator[key] = sanitizedItem;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
||||
? undefined
|
||||
: sanitizedObject;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (options.removeEmptyString && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return options.keepZero ? value : undefined;
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return options.keepFalse ? value : undefined;
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
const getSanitizedOutput = () => {
|
||||
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
|
||||
};
|
||||
|
||||
const getProps = (item: FormItem) => {
|
||||
if (item.props) return item.props;
|
||||
const props = { ...item };
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
|
||||
return props;
|
||||
};
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: FormItem) => {
|
||||
if (!item.slots) return {};
|
||||
const validSlots: Record<string, () => any> = {};
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn;
|
||||
}
|
||||
});
|
||||
return validSlots;
|
||||
};
|
||||
|
||||
// 组件
|
||||
const getComponent = (item: FormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render;
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item;
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
|
||||
};
|
||||
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
return props.items.filter((item) => !item.hidden);
|
||||
});
|
||||
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
"justify-content": isMobile.value
|
||||
? "flex-end"
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? "flex-start"
|
||||
: "flex-end",
|
||||
}));
|
||||
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields();
|
||||
|
||||
// 恢复初始表单值,保留默认值而不是简单清空。
|
||||
Object.keys(modelValue.value).forEach((key) => {
|
||||
delete modelValue.value[key];
|
||||
});
|
||||
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
|
||||
|
||||
// 触发 reset 事件
|
||||
emit("reset");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理提交事件
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
// 对外只抛出清洗后的结果,避免业务层重复过滤空值。
|
||||
emit("submit", getSanitizedOutput());
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset,
|
||||
// 允许外部在不触发提交事件时主动获取清洗后的输出。
|
||||
getOutput: getSanitizedOutput,
|
||||
});
|
||||
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props);
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- 在 FaSearchBar 上追加「创建人 / 更新人 / 创建时间 / 更新时间」,并内置 UserTableSelect 插槽;业务页只传自己的 items 即可 -->
|
||||
<template>
|
||||
<FaSearchBar
|
||||
ref="innerRef"
|
||||
v-model="modelValue"
|
||||
v-bind="forwardedAttrs"
|
||||
:items="mergedItems"
|
||||
@search="(p) => emit('search', p)"
|
||||
@reset="emit('reset')"
|
||||
>
|
||||
<template v-for="(_, name) in $slots" :key="name" #[name]="scope">
|
||||
<slot :name="name" v-bind="scope || {}" />
|
||||
</template>
|
||||
<template v-if="!$slots.created_id" #created_id>
|
||||
<div class="w-full min-w-0">
|
||||
<UserTableSelect
|
||||
:model-value="modelValue?.created_id == null ? undefined : modelValue.created_id"
|
||||
@update:model-value="(v) => patchField('created_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!$slots.updated_id" #updated_id>
|
||||
<div class="w-full min-w-0">
|
||||
<UserTableSelect
|
||||
:model-value="modelValue?.updated_id == null ? undefined : modelValue.updated_id"
|
||||
@update:model-value="(v) => patchField('updated_id', v)"
|
||||
@confirm-click="emitImmediateSearch"
|
||||
@clear-click="emitImmediateSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FaSearchBar>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useAttrs } from "vue";
|
||||
import FaSearchBar from "./index.vue";
|
||||
import type { SearchFormItem } from "./index.vue";
|
||||
import {
|
||||
getAuditSearchFormItems,
|
||||
type GetAuditSearchFormItemsOptions,
|
||||
} from "./auditSearchFormItems";
|
||||
import UserTableSelect from "@views/module_system/user/components/UserTableSelect.vue";
|
||||
|
||||
defineOptions({ name: "FaSearchBarWithAudit", inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 仅业务条件表单项,不含审计四字段 */
|
||||
items: SearchFormItem[];
|
||||
/** 为 false 时与原生 FaSearchBar 一致,仅使用 `items` */
|
||||
includeAudit?: boolean;
|
||||
/** 传给 getAuditSearchFormItems 的选项 */
|
||||
auditItemOptions?: GetAuditSearchFormItemsOptions;
|
||||
}>(),
|
||||
{ includeAudit: true }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [Record<string, any>];
|
||||
reset: [];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<Record<string, any>>({ default: () => ({}) });
|
||||
const attrs = useAttrs();
|
||||
|
||||
const innerRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
|
||||
const forwardedAttrs = computed(() => attrs as Record<string, unknown>);
|
||||
|
||||
const auditItems = computed(() => getAuditSearchFormItems(props.auditItemOptions));
|
||||
|
||||
const mergedItems = computed(() => {
|
||||
if (!props.includeAudit) return props.items;
|
||||
return [...props.items, ...auditItems.value];
|
||||
});
|
||||
|
||||
function patchField(key: "created_id" | "updated_id", val: number | undefined) {
|
||||
modelValue.value = { ...modelValue.value, [key]: val };
|
||||
}
|
||||
|
||||
function emitImmediateSearch() {
|
||||
emit("search", { ...modelValue.value });
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
validate: (...args: any[]) => innerRef.value?.validate?.(...args),
|
||||
reset: () => innerRef.value?.reset?.(),
|
||||
getOutput: () => innerRef.value?.getOutput?.(),
|
||||
ref: computed(() => innerRef.value?.ref),
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { SearchFormItem } from "./index.vue";
|
||||
|
||||
/** 与创建/更新人、时间范围配套的后端查询字段(可与其他业务条件组合) */
|
||||
export type AuditSearchFormParams = {
|
||||
created_id?: number | null;
|
||||
updated_id?: number | null;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
};
|
||||
|
||||
export interface GetAuditSearchFormItemsOptions {
|
||||
/** 栅格列宽,与 FaSearchBar `span` 一致时建议传相同值,默认 6 */
|
||||
span?: number;
|
||||
createdByLabel?: string;
|
||||
updatedByLabel?: string;
|
||||
createdTimeLabel?: string;
|
||||
updatedTimeLabel?: string;
|
||||
createdByPlaceholder?: string;
|
||||
updatedByPlaceholder?: string;
|
||||
/** 日期时间范围 valueFormat,默认 YYYY-MM-DD HH:mm:ss */
|
||||
valueFormat?: string;
|
||||
rangeSeparator?: string;
|
||||
startPlaceholder?: string;
|
||||
endPlaceholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 常见「创建人 / 更新人 / 创建时间 / 更新时间」搜索项,供 FaSearchBar `items` 使用。
|
||||
* 创建人、更新人需配合 `#created_id`、`#updated_id` 插槽(如 FaSearchBarWithAudit)。
|
||||
*/
|
||||
export function getAuditSearchFormItems(
|
||||
options?: GetAuditSearchFormItemsOptions
|
||||
): SearchFormItem[] {
|
||||
const span = options?.span ?? 6;
|
||||
const valueFormat = options?.valueFormat ?? "YYYY-MM-DD HH:mm:ss";
|
||||
const rangeSep = options?.rangeSeparator ?? "至";
|
||||
const sp = options?.startPlaceholder ?? "开始";
|
||||
const ep = options?.endPlaceholder ?? "结束";
|
||||
|
||||
return [
|
||||
{
|
||||
label: options?.createdByLabel ?? "创建人",
|
||||
key: "created_id",
|
||||
type: "input",
|
||||
props: {
|
||||
placeholder: options?.createdByPlaceholder ?? "请选择创建人",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
span,
|
||||
},
|
||||
{
|
||||
label: options?.updatedByLabel ?? "更新人",
|
||||
key: "updated_id",
|
||||
type: "input",
|
||||
props: {
|
||||
placeholder: options?.updatedByPlaceholder ?? "请选择更新人",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
span,
|
||||
},
|
||||
{
|
||||
label: options?.createdTimeLabel ?? "创建时间",
|
||||
key: "created_time",
|
||||
type: "datetimerange",
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
type: "datetimerange",
|
||||
rangeSeparator: rangeSep,
|
||||
startPlaceholder: sp,
|
||||
endPlaceholder: ep,
|
||||
valueFormat,
|
||||
},
|
||||
span,
|
||||
},
|
||||
{
|
||||
label: options?.updatedTimeLabel ?? "更新时间",
|
||||
key: "updated_time",
|
||||
type: "datetimerange",
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
type: "datetimerange",
|
||||
rangeSeparator: rangeSep,
|
||||
startPlaceholder: sp,
|
||||
endPlaceholder: ep,
|
||||
valueFormat,
|
||||
},
|
||||
span,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
<!-- 表格搜索组件 -->
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<section class="fa-search-bar fa-card-xs" :class="{ 'is-expanded': isExpanded }">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
:model-value="getFieldValue(item.key)"
|
||||
@update:model-value="setFieldValue(item.key, $event)"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
||||
<div class="form-buttons">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
<template #icon>
|
||||
<Refresh />
|
||||
</template>
|
||||
{{ t("table.searchBar.reset") }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="handleSearch"
|
||||
v-ripple
|
||||
:disabled="disabledSearch"
|
||||
>
|
||||
<template #icon>
|
||||
<Search />
|
||||
</template>
|
||||
{{ t("table.searchBar.search") }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
|
||||
<span>{{ expandToggleText }}</span>
|
||||
<div class="icon-wrapper">
|
||||
<ElIcon>
|
||||
<ArrowUpBold v-if="isExpanded" />
|
||||
<ArrowDownBold v-else />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpBold, ArrowDownBold, Refresh, Search } from "@element-plus/icons-vue";
|
||||
import { useWindowSize } from "@vueuse/core";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { toRaw, type Component } from "vue";
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance,
|
||||
} from "element-plus";
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from "@utils/form";
|
||||
|
||||
defineOptions({ name: "FaSearchBar" });
|
||||
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputTag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect, // 树选择器
|
||||
};
|
||||
|
||||
const { width } = useWindowSize();
|
||||
const { t } = useI18n();
|
||||
const isMobile = computed(() => width.value < 500);
|
||||
|
||||
const formInstance = useTemplateRef<FormInstance>("formRef");
|
||||
|
||||
// 表单项配置
|
||||
export interface SearchFormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string;
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component;
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number;
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string;
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component;
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean;
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number;
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>;
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>;
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>;
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
|
||||
// 表单配置
|
||||
interface SearchBarProps {
|
||||
/** 表单数据 */
|
||||
items: SearchFormItem[];
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number;
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number;
|
||||
/** 展开/收起 */
|
||||
isExpand?: boolean;
|
||||
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
|
||||
defaultExpanded?: boolean;
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: "left" | "right" | "top";
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number;
|
||||
/** 是否需要展示,收起 */
|
||||
showExpand?: boolean;
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number;
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean;
|
||||
/** 是否显示搜索按钮 */
|
||||
showSearch?: boolean;
|
||||
/** 是否禁用搜索按钮 */
|
||||
disabledSearch?: boolean;
|
||||
/** 搜索时是否清洗空值 */
|
||||
sanitizeOutput?: Partial<SanitizeOutputOptions>;
|
||||
}
|
||||
|
||||
interface SanitizeOutputOptions {
|
||||
/** 移除空字符串 */
|
||||
removeEmptyString: boolean;
|
||||
/** 移除空数组 */
|
||||
removeEmptyArray: boolean;
|
||||
/** 移除清洗后为空的对象 */
|
||||
removeEmptyObject: boolean;
|
||||
/** 移除空富文本占位内容,如 <p><br></p> */
|
||||
removeEmptyRichText: boolean;
|
||||
/** 保留数字 0 这类有效筛选值 */
|
||||
keepZero: boolean;
|
||||
/** 保留 false 这类有效筛选值 */
|
||||
keepFalse: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SearchBarProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
isExpand: false,
|
||||
labelPosition: "right",
|
||||
labelWidth: "70px",
|
||||
showExpand: true,
|
||||
defaultExpanded: false,
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSearch: true,
|
||||
disabledSearch: false,
|
||||
sanitizeOutput: () => ({}),
|
||||
});
|
||||
|
||||
interface SearchBarEmits {
|
||||
reset: [];
|
||||
search: [Record<string, any>];
|
||||
}
|
||||
|
||||
const emit = defineEmits<SearchBarEmits>();
|
||||
|
||||
const modelValue = defineModel<Record<string, any>>({ default: {} });
|
||||
const initialModelValue = ref<Record<string, any>>({});
|
||||
|
||||
// 保存组件初始化时的表单快照,用于 reset 时恢复默认筛选条件。
|
||||
const cloneModelValue = (value: Record<string, any> | undefined) => {
|
||||
if (!value) return {};
|
||||
|
||||
const deepClone = (source: unknown): unknown => {
|
||||
if (Array.isArray(source)) {
|
||||
return source.map((item) => deepClone(item));
|
||||
}
|
||||
|
||||
if (source && typeof source === "object") {
|
||||
const rawSource = toRaw(source);
|
||||
return Object.keys(rawSource).reduce<Record<string, unknown>>((accumulator, key) => {
|
||||
accumulator[key] = deepClone((rawSource as Record<string, unknown>)[key]);
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
return deepClone(toRaw(value)) as Record<string, any>;
|
||||
};
|
||||
|
||||
initialModelValue.value = cloneModelValue(modelValue.value);
|
||||
|
||||
/**
|
||||
* 是否展开状态
|
||||
*/
|
||||
const isExpanded = ref(props.defaultExpanded);
|
||||
|
||||
const rootProps = ["label", "labelWidth", "key", "type", "hidden", "span", "slots"];
|
||||
// 搜索参数默认更激进地去掉空值,减少无效 query 参数。
|
||||
const sanitizeOutputOptions = computed<SanitizeOutputOptions>(() => ({
|
||||
removeEmptyString: true,
|
||||
removeEmptyArray: true,
|
||||
removeEmptyObject: true,
|
||||
removeEmptyRichText: true,
|
||||
keepZero: true,
|
||||
keepFalse: true,
|
||||
...props.sanitizeOutput,
|
||||
}));
|
||||
|
||||
const getProps = (item: SearchFormItem) => {
|
||||
if (item.props) return item.props;
|
||||
const props = { ...item };
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key]);
|
||||
return props;
|
||||
};
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: SearchFormItem) => {
|
||||
if (!item.slots) return {};
|
||||
const validSlots: Record<string, () => any> = {};
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn;
|
||||
}
|
||||
});
|
||||
return validSlots;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint);
|
||||
};
|
||||
|
||||
// 搜索表单清空输入时不保留空字符串,避免后续请求携带空字段。
|
||||
const normalizeFieldValue = (value: unknown) => {
|
||||
return value === "" ? undefined : value;
|
||||
};
|
||||
|
||||
const getFieldValue = (key: string) => modelValue.value[key];
|
||||
|
||||
const setFieldValue = (key: string, value: unknown) => {
|
||||
const normalizedValue = normalizeFieldValue(value);
|
||||
|
||||
if (normalizedValue === undefined) {
|
||||
delete modelValue.value[key];
|
||||
return;
|
||||
}
|
||||
|
||||
modelValue.value[key] = normalizedValue;
|
||||
};
|
||||
|
||||
const isRichTextEmpty = (value: string) => {
|
||||
if (/<(img|video|audio|iframe|embed|object)\b/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 去掉编辑器常见占位标签后再判断是否还有实际内容。
|
||||
return (
|
||||
value
|
||||
.replace(/ /gi, "")
|
||||
.replace(/<br\s*\/?>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.trim() === ""
|
||||
);
|
||||
};
|
||||
|
||||
// 搜索时按配置清洗空值,但保留 0 和 false 这类有效筛选条件。
|
||||
const sanitizeOutputValue = (value: unknown): unknown => {
|
||||
const options = sanitizeOutputOptions.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const sanitizedArray = value
|
||||
.map((item) => sanitizeOutputValue(item))
|
||||
.filter((item) => item !== undefined);
|
||||
return sanitizedArray.length === 0 && options.removeEmptyArray ? undefined : sanitizedArray;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const rawValue = toRaw(value);
|
||||
const sanitizedObject = Object.entries(rawValue).reduce<Record<string, unknown>>(
|
||||
(accumulator, [key, item]) => {
|
||||
const sanitizedItem = sanitizeOutputValue(item);
|
||||
if (sanitizedItem !== undefined) {
|
||||
accumulator[key] = sanitizedItem;
|
||||
}
|
||||
return accumulator;
|
||||
},
|
||||
{}
|
||||
);
|
||||
return Object.keys(sanitizedObject).length === 0 && options.removeEmptyObject
|
||||
? undefined
|
||||
: sanitizedObject;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
if (options.removeEmptyString && value.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (options.removeEmptyRichText && isRichTextEmpty(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return options.keepZero ? value : undefined;
|
||||
}
|
||||
|
||||
if (value === false) {
|
||||
return options.keepFalse ? value : undefined;
|
||||
}
|
||||
|
||||
return value ?? undefined;
|
||||
};
|
||||
|
||||
const getSanitizedOutput = () => {
|
||||
return (sanitizeOutputValue(cloneModelValue(modelValue.value)) || {}) as Record<string, any>;
|
||||
};
|
||||
|
||||
// 组件
|
||||
const getComponent = (item: SearchFormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render;
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item;
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap["input"];
|
||||
};
|
||||
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden);
|
||||
const shouldShowLess = !props.isExpand && !isExpanded.value;
|
||||
if (shouldShowLess) {
|
||||
const maxItemsPerRow = Math.floor(24 / props.span) - 1;
|
||||
return filteredItems.slice(0, maxItemsPerRow);
|
||||
}
|
||||
return filteredItems;
|
||||
});
|
||||
|
||||
/**
|
||||
* 是否应该显示展开/收起按钮
|
||||
*/
|
||||
const shouldShowExpandToggle = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden);
|
||||
return (
|
||||
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 展开/收起按钮文本
|
||||
*/
|
||||
const expandToggleText = computed(() => {
|
||||
return isExpanded.value ? t("table.searchBar.collapse") : t("table.searchBar.expand");
|
||||
});
|
||||
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
"justify-content": isMobile.value
|
||||
? "flex-end"
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? "flex-start"
|
||||
: "flex-end",
|
||||
}));
|
||||
|
||||
/**
|
||||
* 切换展开/收起状态
|
||||
*/
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields();
|
||||
|
||||
// 恢复初始表单值,保留默认搜索条件而不是简单清空。
|
||||
Object.keys(modelValue.value).forEach((key) => {
|
||||
delete modelValue.value[key];
|
||||
});
|
||||
Object.assign(modelValue.value, cloneModelValue(initialModelValue.value));
|
||||
|
||||
// 触发 reset 事件
|
||||
emit("reset");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理搜索事件
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
// 对外只抛出清洗后的查询参数,避免接口收到空数组/空字符串。
|
||||
emit("search", getSanitizedOutput());
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset,
|
||||
// 允许外部在手动组装请求前直接读取清洗后的参数。
|
||||
getOutput: getSanitizedOutput,
|
||||
});
|
||||
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fa-search-bar {
|
||||
padding: 15px 20px 0;
|
||||
|
||||
.action-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
.action-buttons-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
line-height: 32px;
|
||||
color: var(--theme-color);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--ElColor-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式优化
|
||||
@media (width <= 768px) {
|
||||
.fa-search-bar {
|
||||
padding: 16px 16px 0;
|
||||
|
||||
.action-column {
|
||||
.action-buttons-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.form-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from "vue";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { EmojiText } from "@utils/ui";
|
||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from "@wangeditor/editor";
|
||||
import request from "@utils/http";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
defineOptions({ name: "FaWangEditor" });
|
||||
|
||||
type InsertFnType = (url: string, alt: string, href: string) => void;
|
||||
|
||||
const { VITE_API_URL } = import.meta.env;
|
||||
|
||||
// Props 定义
|
||||
interface Props {
|
||||
/** 编辑器高度 */
|
||||
height?: string;
|
||||
/** 自定义工具栏配置 */
|
||||
toolbarKeys?: string[];
|
||||
/** 插入新工具到指定位置 */
|
||||
insertKeys?: { index: number; keys: string[] };
|
||||
/** 排除的工具栏项 */
|
||||
excludeKeys?: string[];
|
||||
/** 编辑器模式 */
|
||||
mode?: "default" | "simple";
|
||||
/** 占位符文本 */
|
||||
placeholder?: string;
|
||||
/** 上传配置 */
|
||||
uploadConfig?: {
|
||||
maxFileSize?: number;
|
||||
maxNumberOfFiles?: number;
|
||||
server?: string;
|
||||
// 是否开启自定义上传
|
||||
isCustomUpload?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: "500px",
|
||||
mode: "default",
|
||||
placeholder: "请输入内容...",
|
||||
excludeKeys: () => ["fontFamily"],
|
||||
isCustomUpload: false,
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>({ required: true });
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef<IDomEditor>();
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 常量配置
|
||||
const DEFAULT_UPLOAD_CONFIG = {
|
||||
maxFileSize: 3 * 1024 * 1024, // 3MB
|
||||
maxNumberOfFiles: 10,
|
||||
fieldName: "file",
|
||||
allowedFileTypes: ["image/*"],
|
||||
} as const;
|
||||
|
||||
// 计算属性:上传服务器地址
|
||||
const uploadServer = computed(
|
||||
() => props.uploadConfig?.server || `${VITE_API_URL}/common/upload/wangeditor`
|
||||
);
|
||||
|
||||
// 合并上传配置
|
||||
const mergedUploadConfig = computed(() => ({
|
||||
...DEFAULT_UPLOAD_CONFIG,
|
||||
...props.uploadConfig,
|
||||
}));
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
||||
const config: Partial<IToolbarConfig> = {};
|
||||
|
||||
// 完全自定义工具栏
|
||||
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
||||
config.toolbarKeys = props.toolbarKeys;
|
||||
}
|
||||
|
||||
// 插入新工具
|
||||
if (props.insertKeys) {
|
||||
config.insertKeys = props.insertKeys;
|
||||
}
|
||||
|
||||
// 排除工具
|
||||
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
||||
config.excludeKeys = props.excludeKeys;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
fieldName: mergedUploadConfig.value.fieldName,
|
||||
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
||||
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
||||
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
|
||||
server: uploadServer.value,
|
||||
headers: {
|
||||
Authorization: userStore.accessToken,
|
||||
},
|
||||
onSuccess() {
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`);
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
console.error("图片上传失败:", err, res);
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 自定义上传
|
||||
const uploadConfig = props.uploadConfig;
|
||||
if (uploadConfig?.isCustomUpload && uploadConfig.server && editorConfig.MENU_CONF) {
|
||||
const uploadServerUrl = uploadConfig.server;
|
||||
editorConfig.MENU_CONF.uploadImage.customUpload = async (file: File, insertFn: InsertFnType) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append(mergedUploadConfig.value.fieldName, file);
|
||||
|
||||
type UploadImagePayload = { url: string; alt?: string; href?: string };
|
||||
const response = await request.post<
|
||||
ApiResponse<UploadImagePayload>,
|
||||
AxiosResponse<ApiResponse<UploadImagePayload>>
|
||||
>(uploadServerUrl, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
Authorization: userStore.accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
const { url, alt = "", href = "" } = response.data.data ?? ({} as any);
|
||||
|
||||
if (!url) {
|
||||
throw new Error("上传失败,请检查服务端配置");
|
||||
}
|
||||
|
||||
insertFn(url, alt, href);
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`);
|
||||
} catch (error) {
|
||||
console.error("图片上传失败:", error);
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 编辑器创建回调
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor;
|
||||
|
||||
// 监听全屏事件
|
||||
editor.on("fullScreen", () => {
|
||||
console.log("编辑器进入全屏模式");
|
||||
});
|
||||
|
||||
// 确保在编辑器创建后应用自定义图标
|
||||
applyCustomIcons();
|
||||
};
|
||||
|
||||
// 应用自定义图标(带重试机制)
|
||||
const applyCustomIcons = () => {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 100;
|
||||
|
||||
const tryApplyIcons = () => {
|
||||
const editor = editorRef.value;
|
||||
if (!editor) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前编辑器的工具栏容器
|
||||
const editorContainer = editor.getEditableContainer().closest(".editor-wrapper");
|
||||
if (!editorContainer) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const toolbar = editorContainer.querySelector(".w-e-toolbar");
|
||||
const toolbarButtons = editorContainer.querySelectorAll(".w-e-bar-item button[data-menu-key]");
|
||||
|
||||
if (toolbar && toolbarButtons.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果工具栏还没渲染完成,继续重试
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
setTimeout(tryApplyIcons, retryDelay);
|
||||
} else {
|
||||
console.warn("工具栏渲染超时,无法应用自定义图标 - 编辑器实例:", editor.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||
requestAnimationFrame(tryApplyIcons);
|
||||
};
|
||||
|
||||
// 暴露编辑器实例和方法
|
||||
defineExpose({
|
||||
/** 获取编辑器实例 */
|
||||
getEditor: () => editorRef.value,
|
||||
/** 设置编辑器内容 */
|
||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||
/** 获取编辑器内容 */
|
||||
getHtml: () => editorRef.value?.getHtml(),
|
||||
/** 清空编辑器 */
|
||||
clear: () => editorRef.value?.clear(),
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => editorRef.value?.focus(),
|
||||
});
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 图标替换已在 onCreateEditor 中处理
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor) {
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use "./style";
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
$box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
|
||||
// 全屏容器 z-index 调整
|
||||
.w-e-full-screen-container {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--default-border);
|
||||
}
|
||||
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
|
||||
/* 表格选中时候的边框颜色 */
|
||||
--w-e-textarea-selected-border-color: #ddd;
|
||||
|
||||
/* 表格头背景颜色 */
|
||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item svg {
|
||||
fill: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.w-e-bar-item button {
|
||||
color: var(--art-gray-800);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
|
||||
.w-e-bar-item {
|
||||
button {
|
||||
width: 100%;
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-50);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
|
||||
.w-e-text-container {
|
||||
[data-slate-editor] {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0.8em 0 0.4em;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.125em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
ul ul ul {
|
||||
list-style: square;
|
||||
}
|
||||
}
|
||||
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--art-gray-200);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user