初始化

This commit is contained in:
2026-02-22 18:56:10 +08:00
commit 26677972a6
3112 changed files with 255972 additions and 0 deletions

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { computed, onMounted, ref, toRefs } from 'vue';
import { $t } from '@easyflow/locales';
import { preferences } from '@easyflow/preferences';
import { Delete, Edit, MoreFilled, Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDialog,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElEmpty,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElMessage,
ElMessageBox,
} from 'element-plus';
const props = defineProps({
title: {
type: String,
default: '数据管理',
},
categoryData: {
type: Array<Record<string, any>>,
default: () => [],
},
disabled: {
type: Boolean,
},
loading: {
type: Boolean,
},
titleKey: {
type: String,
default: 'title',
},
panelWidth: {
type: Number,
default: 200,
},
valueKey: {
type: String,
default: undefined,
},
defaultSelectedCategory: {
type: String,
default: null,
},
defaultFormData: {
type: Object,
default: () => ({}),
},
});
// 暴露给父组件的事件
const emit = defineEmits([
'add', // 新增提交时触发
'edit', // 编辑提交时触发
'delete', // 删除时触发
'click', // 点击时触发
]);
const finalValueKey = computed(() => {
// 父组件传递了 valueKey 就用 valueKey否则用 titleKey
return props.valueKey ?? props.titleKey;
});
const { defaultFormData } = toRefs(props);
// 状态管理
const dialogVisible = ref(false); // 弹窗显隐
const isEdit = ref(false); // 是否为编辑模式
const formData = ref({
...defaultFormData,
}); // 表单数据
/**
* 从 Proxy 对象提取原始数据
*/
const extractFromProxy = (proxyObj: any) => {
try {
if (proxyObj && typeof proxyObj === 'object') {
const raw = (proxyObj as any).__v_raw || proxyObj;
if (raw && typeof raw === 'object') {
const result: any = Array.isArray(raw) ? [] : {};
for (const key in raw) {
if (
key !== '__v_skip' &&
Object.prototype.hasOwnProperty.call(raw, key)
) {
result[key] = extractFromProxy(raw[key]);
}
}
return result;
}
return raw;
}
return proxyObj;
} catch (error) {
console.warn('提取 Proxy 对象失败,使用浅拷贝:', error);
return { ...proxyObj };
}
};
/**
* 新增按钮点击事件
*/
const handleAdd = () => {
isEdit.value = false;
formData.value = {
...defaultFormData,
};
dialogVisible.value = true;
};
/**
* 编辑按钮点击事件
* @param {object} row - 表格当前行数据
*/
const handleEdit = (row: any) => {
isEdit.value = true;
// 使用提取原始数据的方法
formData.value = extractFromProxy(row);
dialogVisible.value = true;
};
/**
* 删除按钮点击事件
* @param {object} row - 表格当前行数据
*/
const handleDelete = (row: any) => {
// 先提取原始数据
const rawData = extractFromProxy(row);
ElMessageBox.confirm(`此操作将永久删除该${props.title}, 是否继续?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
// 触发父组件删除事件,传递原始数据
emit('delete', rawData);
})
.catch(() => {
ElMessage({ type: 'info', message: '已取消删除' });
});
};
/**
* 表单提交事件
*/
const handleSubmit = () => {
// 触发对应事件,传递表单数据
if (isEdit.value) {
emit('edit', formData.value);
} else {
emit('add', formData.value);
}
// 提交后关闭弹窗
dialogVisible.value = false;
};
const selectedCategory = ref();
onMounted(() => {
// 初始化时,检查是否有默认选中的分类
if (props.defaultSelectedCategory) {
selectedCategory.value = props.defaultSelectedCategory;
}
});
const handleCategoryClick = (category: any) => {
// 选中值:用 finalValueKey父组件指定的字段
selectedCategory.value = category[finalValueKey.value];
emit('click', {
item: extractFromProxy(category),
value: category[finalValueKey.value], // 父组件指定字段的值
label: category[props.titleKey], // 分类名称
});
};
const handleEditClick = (event: any, item: any) => {
event.stopPropagation();
handleEdit(item);
};
const handleDeleteClick = (event: any, item: any) => {
event.stopPropagation();
handleDelete(item);
};
</script>
<template>
<div
class="flex h-full flex-col rounded-lg border border-[var(--el-border-color)] bg-[var(--el-bg-color)] p-2"
:style="{ width: `${panelWidth}px` }"
>
<div class="flex flex-1 flex-col gap-5">
<!-- <h3 class="text-base font-medium">{{ title }}</h3> -->
<div class="flex-1 overflow-scroll">
<div v-for="(item, index) in categoryData" :key="index">
<div
:class="{ selected: selectedCategory === item[finalValueKey] }"
class="crud-category-item"
@click="handleCategoryClick(item)"
>
<div>{{ item[titleKey] }}</div>
<div v-if="item.id !== '0'">
<ElDropdown @click.stop>
<span class="dropdown-trigger">
<ElIcon><MoreFilled /></ElIcon>
</span>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="handleEditClick($event, item)">
<ElButton :icon="Edit" link>
{{ $t('button.edit') }}
</ElButton>
</ElDropdownItem>
<ElDropdownItem @click="handleDeleteClick($event, item)">
<ElButton type="danger" :icon="Delete" link>
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</div>
</div>
</div>
</div>
<ElButton @click="handleAdd" :icon="Plus" :disabled="disabled" plain>
{{ $t('button.add') }}
</ElButton>
<!-- 无数据提示 -->
<div v-if="categoryData.length === 0 && !loading" class="no-data">
<ElEmpty
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
:description="$t('common.noDataAvailable')"
/>
</div>
<!-- 新增/编辑弹窗 -->
<ElDialog
:title="
isEdit ? `${$t('button.edit')}${title}` : `${$t('button.add')}${title}`
"
v-model="dialogVisible"
width="500px"
:close-on-click-modal="false"
>
<!-- 表单内容父组件通过插槽配置 -->
<slot name="form" :form-data="formData" :is-edit="isEdit"></slot>
<ElForm :model="formData" status-icon>
<ElFormItem :prop="titleKey">
<ElInput v-model.trim="formData[titleKey]" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">
{{ $t('button.cancel') }}
</ElButton>
<ElButton type="primary" @click="handleSubmit">
{{ $t('button.confirm') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<style scoped>
.crud-category-item:hover {
background-color: var(--el-color-primary-light-9);
cursor: pointer;
}
.crud-category-item.selected {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.no-data {
text-align: center;
padding: 40px 0;
}
.crud-category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
height: 40px;
border-radius: 8px;
font-size: 14px;
}
.crud-category-item :deep(.el-icon) {
outline: none !important;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
.dropdown-trigger:hover {
background-color: #f0f0f0;
}
</style>

View File

@@ -0,0 +1,370 @@
<script setup>
import { computed, isVNode, onMounted, ref } from 'vue';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
import { ElIcon } from 'element-plus';
// 定义组件属性
const props = defineProps({
// 分类数据,格式示例:[{ name: '分类1', icon: SomeIcon }, { name: '分类2' }]
categories: {
type: Array,
default: () => [],
required: true,
},
valueKey: {
type: String,
default: undefined,
},
titleKey: {
type: String,
default: 'name',
},
needHideCollapse: {
type: Boolean,
default: false,
},
iconKey: {
type: String,
default: 'icon',
},
// 自定义展开状态宽度默认300px
expandWidth: {
type: Number,
default: 120,
},
// 自定义收缩状态宽度默认48px
collapseWidth: {
type: Number,
default: 48,
},
// 默认选中的分类(用于初始化) 指定key
defaultSelectedCategory: {
type: String,
default: null,
},
iconSize: { type: [Number, String], default: 18 },
iconColor: { type: String, default: 'var(--el-text-color-primary)' },
// 新增:是否用 img 标签渲染 SVG 字符串(默认 false
useImgForSvg: { type: Boolean, default: false },
});
// 定义事件
const emit = defineEmits([
'click', // 分类项点击事件
'panelToggle', // 面板收缩状态改变事件
]);
const finalValueKey = computed(() => {
// 父组件传递了 valueKey 就用 valueKey否则用 titleKey
return props.valueKey ?? props.titleKey;
});
// -------------------------- 核心工具函数 --------------------------
/**
* SVG 字符串转 Data URL供 img 标签使用)
* @param {string} svgString - 清理后的 SVG 字符串
* @returns {string} Data URL
*/
const svgToDataUrl = (svgString) => {
// 1. 去除 SVG 中的换行和多余空格(优化编码后体积)
const cleanedSvg = svgString
.replaceAll('\n', '')
.replaceAll(/\s+/g, ' ')
.trim();
// 2. URL 编码 + 拼接 Data URL 格式
return `data:image/svg+xml;utf8,${encodeURIComponent(cleanedSvg)}`;
};
/**
* 判断是否为组件Element Plus 图标 / 自定义 SVG 组件)
*/
const isComponent = (icon) => {
return (
typeof icon === 'object' && (typeof icon === 'object' || isVNode(icon))
);
};
/**
* 判断是否为 SVG 字符串
*/
const isSvgString = (icon) => {
return typeof icon === 'string' && icon.trim().startsWith('<svg');
};
/**
* 判断是否为图片 URL
*/
const isImageUrl = (icon) => {
return (
typeof icon === 'string' &&
(icon.endsWith('.svg') ||
icon.endsWith('.png') ||
icon.endsWith('.jpg') ||
icon.startsWith('http://') ||
icon.startsWith('https://'))
);
};
// 面板收缩状态
const isCollapsed = ref(false);
// 检查是否有分类包含图标
const hasIcons = computed(() => {
return props.categories.some((item) => item[props.iconKey]);
});
// 动态计算面板宽度
const panelWidth = computed(() => {
if (isCollapsed.value) {
// 收缩状态:有图标用自定义收缩宽度,无图标保持最小适配宽度
return hasIcons.value ? props.collapseWidth : 120;
} else {
// 展开状态:使用自定义展开宽度
return props.expandWidth;
}
});
// 切换面板收缩状态
const togglePanel = () => {
isCollapsed.value = !isCollapsed.value;
emit('panelToggle', {
collapsed: isCollapsed.value,
currentWidth: panelWidth.value,
});
};
const selectedCategory = ref(null);
// 处理分类项点击
const handleCategoryClick = (category) => {
// 选中值:用 finalValueKey父组件指定的字段
selectedCategory.value = category[finalValueKey.value];
emit('click', {
item: category,
value: category[finalValueKey.value], // 父组件指定字段的值
label: category[props.titleKey], // 分类名称
});
};
onMounted(() => {
// 初始化时,检查是否有默认选中的分类
if (props.defaultSelectedCategory) {
selectedCategory.value = props.defaultSelectedCategory;
}
});
</script>
<template>
<div class="category-panel" :style="{ width: `${panelWidth}px` }">
<!-- 右上角收缩/展开按钮 -->
<div class="toggle-panel-btn" @click="togglePanel" v-if="!needHideCollapse">
<ElIcon>
<ArrowLeft v-if="!isCollapsed" />
<ArrowRight v-else />
</ElIcon>
</div>
<div style="margin-bottom: 48px" v-if="!needHideCollapse"></div>
<!-- 分类列表容器 -->
<div class="category-list" :class="{ collapsed: isCollapsed }">
<!-- 遍历一级分类数据 -->
<div
v-for="(category, index) in categories"
:key="index"
class="category-item"
>
<div
class="category-item-content"
:class="{ selected: selectedCategory === category[finalValueKey] }"
@click="handleCategoryClick(category)"
>
<!-- 图标 -->
<div v-if="category[iconKey]" class="category-icon">
<!-- 1. 组件类型图标Element Plus / 自定义 SVG 组件 -->
<ElIcon v-if="isComponent(category[iconKey])">
<component :is="category[iconKey]" />
</ElIcon>
<!-- 2. SVG 字符串支持 v-html img 两种渲染方式 -->
<template v-else-if="isSvgString(category[iconKey])">
<div
v-if="!useImgForSvg"
v-html="category[iconKey]"
class="custom-svg"
></div>
<img
v-else
:src="svgToDataUrl(category[iconKey])"
:alt="category[titleKey]"
class="svg-image"
/>
</template>
<!-- 3. 图片 URL本地/网络 SVG/PNG -->
<img
v-else-if="isImageUrl(category[iconKey])"
:src="category[iconKey]"
:alt="category[titleKey]"
class="svg-image"
/>
</div>
<!-- 分类名称收缩状态且有图标时隐藏文字 -->
<span
class="category-name"
:class="{ hidden: isCollapsed && category[iconKey] }"
>
{{ category[titleKey] }}
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.category-panel {
position: relative; /* 相对定位,用于按钮绝对定位 */
overflow: hidden;
height: 100%;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 平滑宽度过渡 */
box-sizing: border-box;
background: #ffffff;
border-radius: 8px;
border: 1px solid #f0f0f0;
padding: 20px;
}
/* 右上角收缩/展开按钮 */
.toggle-panel-btn {
position: absolute;
top: 8px;
right: 8px;
z-index: 10; /* 确保按钮在最上层 */
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: var(--el-color-white);
border: 1px solid #e5e7eb;
cursor: pointer;
transition: all 0.2s;
/* 按钮不随面板收缩移动 */
transform: translateX(0);
}
.toggle-panel-btn:hover {
background-color: #f3f4f6;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.toggle-panel-btn .el-icon {
width: 16px;
height: 16px;
color: #666;
}
/* 分类列表容器 */
.category-list {
overflow: hidden;
height: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 1px;
}
.category-item {
}
.category-item:last-child {
border-bottom: none;
}
.category-item-content {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background-color 0.2s;
gap: 12px;
padding: 6px 0 6px 14px;
border-radius: 4px;
}
.category-item-content:hover {
background-color: var(--el-color-primary-light-9);
}
.category-icon {
width: v-bind(iconSize);
height: v-bind(iconSize);
color: v-bind(iconColor);
display: inline-flex;
align-items: center;
justify-content: center;
vertical-align: middle;
}
.category-icon .el-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.category-item-content {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: background-color 0.2s;
gap: 12px;
}
.category-name {
transition:
opacity 0.2s,
transform 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family:
PingFangSC,
PingFang SC;
font-weight: 400;
font-size: 14px;
line-height: 20px;
text-align: left;
font-style: normal;
}
/* 收缩状态样式 */
.hidden {
display: none;
}
.collapsed .category-item-content {
justify-content: center;
padding: 12px 0;
}
/* 收缩状态下文字强制隐藏(避免无图标时文字溢出) */
.collapsed .category-name {
display: none;
}
/* 新增:选中态样式 */
.category-item-content.selected {
font-weight: 600;
background: rgba(0, 102, 255, 0.06);
color: #0066ff;
}
.category-item-content.selected:hover {
background: rgba(0, 102, 255, 0.06);
}
</style>