refactor: 将组件前缀从Art统一替换为Fa

- 更新所有FA前缀的组件文件
- 新增Fa前缀的对应组件
- 更新所有引用路径和组件名
- 修改相关样式和变量名
- 调整部分组件目录结构
This commit is contained in:
zhangtao
2026-05-11 09:23:31 +08:00
parent 68b50359c5
commit 03a8151513
185 changed files with 2912 additions and 1283 deletions
@@ -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(/&nbsp;/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(/&nbsp;/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;
}
}
}