初始化
This commit is contained in:
14
easyflow-ui-admin/app/src/components/botAvatar/botAvatar.vue
Normal file
14
easyflow-ui-admin/app/src/components/botAvatar/botAvatar.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ElAvatar } from 'element-plus';
|
||||
|
||||
import defaultBotAvatar from '#/assets/ai/bot/defaultBotAvatar.png';
|
||||
|
||||
const props = defineProps<{
|
||||
size?: number;
|
||||
src?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElAvatar :src="props.src ?? defaultBotAvatar" :size="props.size ?? 36" />
|
||||
</template>
|
||||
355
easyflow-ui-admin/app/src/components/cardPage/CardPage.vue
Normal file
355
easyflow-ui-admin/app/src/components/cardPage/CardPage.vue
Normal file
@@ -0,0 +1,355 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
|
||||
import { hasPermission } from '#/api/common/hasPermission.ts';
|
||||
|
||||
const props = defineProps({
|
||||
titleKey: {
|
||||
type: String,
|
||||
default: 'title',
|
||||
},
|
||||
avatarKey: {
|
||||
type: String,
|
||||
default: 'avatar',
|
||||
},
|
||||
/** 默认头像 */
|
||||
defaultAvatar: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
descriptionKey: {
|
||||
type: String,
|
||||
default: 'description',
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
// 操作按钮配置
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
// 定义组件事件
|
||||
const emit = defineEmits(['actionClick']);
|
||||
|
||||
// 可见的操作按钮(最多3个)
|
||||
const visibleActions = computed(() => {
|
||||
return props.actions.slice(0, 3);
|
||||
});
|
||||
|
||||
// 下拉菜单中的操作按钮
|
||||
const dropdownActions = computed(() => {
|
||||
return props.actions.slice(3);
|
||||
});
|
||||
|
||||
// 处理操作按钮点击
|
||||
const handleActionClick = (action, item) => {
|
||||
emit('actionClick', { action, item });
|
||||
};
|
||||
|
||||
const filteredActions = computed(() => {
|
||||
return dropdownActions.value.filter((action) => {
|
||||
return hasPermission([action.permission]);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-list-container">
|
||||
<div class="card-list">
|
||||
<ElCard v-for="item in data" :key="item.id" class="card-item">
|
||||
<div class="card-content">
|
||||
<!-- 卡片头部:头像和基本信息 -->
|
||||
<div class="card-header">
|
||||
<ElAvatar :src="item[avatarKey] ?? defaultAvatar" />
|
||||
<div class="card-info">
|
||||
<h3 class="card-title">{{ item[titleKey] }}</h3>
|
||||
<p class="card-desc">{{ item[descriptionKey] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="card-actions">
|
||||
<!-- 最多显示3个操作按钮 -->
|
||||
<ElButton
|
||||
v-for="(action, index) in visibleActions"
|
||||
:key="index"
|
||||
link
|
||||
class="action-btn"
|
||||
:icon="action.icon"
|
||||
v-access:code="action.permission"
|
||||
@click="handleActionClick(action, item)"
|
||||
>
|
||||
{{ action.label }}
|
||||
<span
|
||||
v-if="
|
||||
index < visibleActions.length - 1 ||
|
||||
(index === visibleActions.length - 1 &&
|
||||
filteredActions.length > 0)
|
||||
"
|
||||
class="divider"
|
||||
></span>
|
||||
</ElButton>
|
||||
|
||||
<!-- 更多操作下拉菜单 -->
|
||||
<ElDropdown
|
||||
v-if="filteredActions.length > 0"
|
||||
class="action-btn"
|
||||
@command="(command) => handleActionClick(command, item)"
|
||||
>
|
||||
<ElIcon class="el-icon--right">
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="action in filteredActions"
|
||||
:key="action.name"
|
||||
:command="action"
|
||||
:icon="action.icon"
|
||||
:class="{
|
||||
'delete-dropdown-item': action.name === 'delete',
|
||||
}"
|
||||
>
|
||||
{{ action.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div v-if="data.length === 0" class="empty-state">
|
||||
<ElEmpty
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-list-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px 0 0 0;
|
||||
}
|
||||
.card-item {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
height: 165px;
|
||||
min-width: 0;
|
||||
}
|
||||
:deep(.delete-dropdown-item) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
:deep(.delete-dropdown-item:hover) {
|
||||
color: #ff7875 !important;
|
||||
}
|
||||
:deep(.delete-dropdown-item .el-icon) {
|
||||
color: inherit !important;
|
||||
}
|
||||
.card-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
flex: 1;
|
||||
padding: 0 20px 0 20px;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 48px;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
var(--el-color-primary-light-9),
|
||||
var(--el-bg-color)
|
||||
),
|
||||
var(--el-bg-color);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: #acb7c6;
|
||||
position: relative;
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 确保按钮内容不换行 */
|
||||
.action-btn .el-button {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
/* 超大屏幕 */
|
||||
@media (min-width: 1920px) {
|
||||
.card-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 大屏幕 */
|
||||
@media (min-width: 1200px) and (max-width: 1919px) {
|
||||
.card-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕 */
|
||||
@media (min-width: 992px) and (max-width: 1199px) {
|
||||
.card-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 0 16px 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕 - 平板 */
|
||||
@media (min-width: 768px) and (max-width: 991px) {
|
||||
.card-list {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.card-header {
|
||||
padding: 0 12px 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 12px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.action-btn .el-button {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { ElButton, ElDialog, ElForm, ElFormItem, ElInput } from 'element-plus';
|
||||
|
||||
interface BasicFormItem {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
const generateDefaultFormItems = (
|
||||
data: BasicFormItem[] = [],
|
||||
): BasicFormItem[] => {
|
||||
return Array.from({ length: 5 }, (_, i) => ({
|
||||
key: (i + 1).toString(),
|
||||
description: data[i]?.description || '',
|
||||
}));
|
||||
};
|
||||
|
||||
const openDialog = (data: BasicFormItem[]) => {
|
||||
nextTick(() => {
|
||||
basicFormRef.value?.resetFields();
|
||||
});
|
||||
basicForm.value = generateDefaultFormItems(data);
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const basicForm: Ref<BasicFormItem[]> = ref(generateDefaultFormItems());
|
||||
const basicFormRef = ref();
|
||||
|
||||
defineExpose({
|
||||
openDialog(data: BasicFormItem[]) {
|
||||
openDialog(data);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfirm = () => {
|
||||
basicFormRef.value?.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
emit('success', basicForm.value);
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="$t('button.add')"
|
||||
width="700"
|
||||
align-center
|
||||
>
|
||||
<ElForm
|
||||
ref="basicFormRef"
|
||||
style="width: 100%; margin-top: 20px"
|
||||
:model="basicForm"
|
||||
label-width="auto"
|
||||
>
|
||||
<template v-for="(item, index) in basicForm" :key="item.key">
|
||||
<ElFormItem
|
||||
:label="`${$t('bot.problemPresupposition')}${item.key}`"
|
||||
:prop="`${index}.description`"
|
||||
label-position="right"
|
||||
>
|
||||
<ElInput v-model="item.description" />
|
||||
</ElFormItem>
|
||||
</template>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">
|
||||
{{ $t('button.confirm') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElInput,
|
||||
ElMessage,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
interface BasicFormItem {
|
||||
weChatMpAppId: string;
|
||||
weChatMpSecret: string;
|
||||
weChatMpToken: string;
|
||||
EncodingAESKey: string;
|
||||
}
|
||||
const emit = defineEmits(['reload']);
|
||||
const dialogVisible = ref(false);
|
||||
const botId = ref('');
|
||||
const openDialog = (newBotId: string, options: BasicFormItem) => {
|
||||
nextTick(() => {
|
||||
basicFormRef.value?.resetFields();
|
||||
});
|
||||
botId.value = newBotId;
|
||||
basicForm.value = { ...options };
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const basicForm: Ref<BasicFormItem> = ref({
|
||||
weChatMpAppId: '',
|
||||
weChatMpSecret: '',
|
||||
weChatMpToken: '',
|
||||
EncodingAESKey: '',
|
||||
});
|
||||
const basicFormRef = ref<FormInstance>();
|
||||
defineExpose({
|
||||
openDialog(botId: string, options: BasicFormItem) {
|
||||
openDialog(botId, options);
|
||||
},
|
||||
});
|
||||
|
||||
const rules = ref({
|
||||
weChatMpAppId: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
weChatMpSecret: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
weChatMpToken: [
|
||||
{
|
||||
required: true,
|
||||
message: $t('message.required'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const handleConfirm = () => {
|
||||
basicFormRef.value?.validate((valid: boolean) => {
|
||||
if (valid) {
|
||||
api
|
||||
.post('/api/v1/bot/updateOptions', {
|
||||
id: botId.value,
|
||||
options: {
|
||||
weChatMpAppId: basicForm.value?.weChatMpAppId,
|
||||
weChatMpSecret: basicForm.value?.weChatMpSecret,
|
||||
weChatMpToken: basicForm.value?.weChatMpToken,
|
||||
EncodingAESKey: basicForm.value?.EncodingAESKey,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.updateOkMessage'));
|
||||
emit('reload');
|
||||
} else {
|
||||
ElMessage.error(res.message);
|
||||
}
|
||||
});
|
||||
dialogVisible.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
:title="$t('bot.weChatOfficialAccountConfiguration')"
|
||||
width="700"
|
||||
align-center
|
||||
>
|
||||
<ElForm
|
||||
ref="basicFormRef"
|
||||
style="width: 100%; margin-top: 20px"
|
||||
:model="basicForm"
|
||||
label-width="auto"
|
||||
:rules="rules"
|
||||
>
|
||||
<ElFormItem label="AppId" prop="weChatMpAppId" label-position="right">
|
||||
<ElInput v-model="basicForm.weChatMpAppId" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="Secret" prop="weChatMpSecret" label-position="right">
|
||||
<ElInput v-model="basicForm.weChatMpSecret" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="Token" prop="weChatMpToken" label-position="right">
|
||||
<ElInput v-model="basicForm.weChatMpToken" />
|
||||
</ElFormItem>
|
||||
<ElFormItem
|
||||
label="EncodingAESKey"
|
||||
prop="EncodingAESKey"
|
||||
label-position="right"
|
||||
>
|
||||
<ElInput v-model="basicForm.EncodingAESKey" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">
|
||||
{{ $t('button.confirm') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
126
easyflow-ui-admin/app/src/components/chat/SenderHeader.vue
Normal file
126
easyflow-ui-admin/app/src/components/chat/SenderHeader.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<!--<script setup lang="ts">-->
|
||||
<!--import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';-->
|
||||
|
||||
<!--import { ref } from 'vue';-->
|
||||
<!--import { Attachments } from 'vue-element-plus-x';-->
|
||||
|
||||
<!--import { ElMessage } from 'element-plus';-->
|
||||
|
||||
<!--const senderRef = ref();-->
|
||||
<!--const showHeaderFlog = ref(false);-->
|
||||
|
||||
<!--type SelfFilesCardProps = FilesCardProps & {-->
|
||||
<!-- id?: number | string;-->
|
||||
<!--};-->
|
||||
|
||||
<!--const files = ref<SelfFilesCardProps[]>([]);-->
|
||||
<!--defineExpose( {-->
|
||||
<!-- init(firstFile: File) {-->
|
||||
<!-- console.log('firstFile', firstFile);-->
|
||||
<!-- showHeaderFlog.value = true;-->
|
||||
<!-- files.value = [-->
|
||||
<!-- {-->
|
||||
<!-- id: 0,-->
|
||||
<!-- uid: `${firstFile.name}_${firstFile.size}`,-->
|
||||
<!-- name: firstFile.name,-->
|
||||
<!-- fileSize: firstFile.size,-->
|
||||
<!-- imgFile: firstFile,-->
|
||||
<!-- showDelIcon: true,-->
|
||||
<!-- imgVariant: 'square',-->
|
||||
<!-- },-->
|
||||
<!-- ];-->
|
||||
<!-- },-->
|
||||
<!--});-->
|
||||
|
||||
<!--function closeHeader() {-->
|
||||
<!-- showHeaderFlog.value = false;-->
|
||||
<!-- senderRef.value.closeHeader();-->
|
||||
<!--}-->
|
||||
|
||||
<!--function handlePasteFile(firstFile: File, fileList: FileList) {-->
|
||||
<!-- showHeaderFlog.value = true;-->
|
||||
<!-- senderRef.value.openHeader();-->
|
||||
<!-- const fileArray = [...fileList];-->
|
||||
|
||||
<!-- fileArray.forEach((file, index) => {-->
|
||||
<!-- files.value.push({-->
|
||||
<!-- id: index,-->
|
||||
<!-- uid: `${index}_${file.name}_${file.size}`,-->
|
||||
<!-- name: file.name,-->
|
||||
<!-- fileSize: file.size,-->
|
||||
<!-- imgFile: file,-->
|
||||
<!-- showDelIcon: true,-->
|
||||
<!-- imgVariant: 'square',-->
|
||||
<!-- });-->
|
||||
<!-- });-->
|
||||
<!--}-->
|
||||
|
||||
<!--async function handleHttpRequest(options: any) {-->
|
||||
<!-- const formData = new FormData();-->
|
||||
<!-- formData.append('file', options.file);-->
|
||||
<!-- ElMessage.info('上传中...');-->
|
||||
|
||||
<!-- setTimeout(() => {-->
|
||||
<!-- const res = {-->
|
||||
<!-- message: '文件上传成功',-->
|
||||
<!-- fileName: options.file.name,-->
|
||||
<!-- uid: options.file.uid,-->
|
||||
<!-- fileSize: options.file.size,-->
|
||||
<!-- imgFile: options.file,-->
|
||||
<!-- };-->
|
||||
<!-- files.value.push({-->
|
||||
<!-- id: files.value.length,-->
|
||||
<!-- uid: res.uid,-->
|
||||
<!-- name: res.fileName,-->
|
||||
<!-- fileSize: res.fileSize,-->
|
||||
<!-- imgFile: res.imgFile,-->
|
||||
<!-- showDelIcon: true,-->
|
||||
<!-- imgVariant: 'square',-->
|
||||
<!-- });-->
|
||||
|
||||
<!-- ElMessage.success('上传成功');-->
|
||||
<!-- }, 1000);-->
|
||||
<!--}-->
|
||||
|
||||
<!--function handleDeleteCard(item: SelfFilesCardProps) {-->
|
||||
<!-- files.value = files.value.filter((items: any) => items.id !== item.id);-->
|
||||
<!-- ElMessage.success('删除成功');-->
|
||||
<!--}-->
|
||||
<!--</script>-->
|
||||
|
||||
<!--<template>-->
|
||||
<!-- <div class="header-self-wrap">-->
|
||||
<!-- <Attachments-->
|
||||
<!-- :items="files"-->
|
||||
<!-- :http-request="handleHttpRequest"-->
|
||||
<!-- @delete-card="handleDeleteCard"-->
|
||||
<!-- />-->
|
||||
<!-- </div>-->
|
||||
<!--</template>-->
|
||||
|
||||
<!--<style scoped>-->
|
||||
<!--.header-self-wrap {-->
|
||||
<!-- display: flex;-->
|
||||
<!-- flex-direction: row;-->
|
||||
<!-- padding: 16px;-->
|
||||
<!-- width: 100%;-->
|
||||
<!-- overflow: auto;-->
|
||||
<!-- .header-self-title {-->
|
||||
<!-- width: 100%;-->
|
||||
<!-- display: flex;-->
|
||||
<!-- height: 30px;-->
|
||||
<!-- align-items: center;-->
|
||||
<!-- justify-content: space-between;-->
|
||||
<!-- padding-bottom: 8px;-->
|
||||
<!-- }-->
|
||||
<!-- .header-self-content {-->
|
||||
<!-- flex: 1;-->
|
||||
<!-- display: flex;-->
|
||||
<!-- align-items: center;-->
|
||||
<!-- justify-content: center;-->
|
||||
<!-- font-size: 20px;-->
|
||||
<!-- color: #626aef;-->
|
||||
<!-- font-weight: 600;-->
|
||||
<!-- }-->
|
||||
<!--}-->
|
||||
<!--</style>-->
|
||||
700
easyflow-ui-admin/app/src/components/chat/chat.vue
Normal file
700
easyflow-ui-admin/app/src/components/chat/chat.vue
Normal file
@@ -0,0 +1,700 @@
|
||||
<script setup lang="ts">
|
||||
import type { Sender } from 'vue-element-plus-x';
|
||||
import type { BubbleListProps } from 'vue-element-plus-x/types/BubbleList';
|
||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
import type { TypewriterInstance } from 'vue-element-plus-x/types/Typewriter';
|
||||
|
||||
import type { BotInfo, ChatMessage } from '@easyflow/types';
|
||||
|
||||
import { onMounted, ref, watchEffect } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { useBotStore } from '@easyflow/stores';
|
||||
import { cloneDeep, cn, uuid } from '@easyflow/utils';
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
CopyDocument,
|
||||
Paperclip,
|
||||
RefreshRight,
|
||||
} from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElIcon,
|
||||
ElMessage,
|
||||
ElSpace,
|
||||
} from 'element-plus';
|
||||
import { tryit } from 'radash';
|
||||
|
||||
import { getMessageList, getPerQuestions } from '#/api';
|
||||
import { api, sseClient } from '#/api/request';
|
||||
import SendEnableIcon from '#/components/icons/SendEnableIcon.vue';
|
||||
import SendIcon from '#/components/icons/SendIcon.vue';
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
||||
|
||||
import BotAvatar from '../botAvatar/botAvatar.vue';
|
||||
import SendingIcon from '../icons/SendingIcon.vue';
|
||||
|
||||
type Think = {
|
||||
reasoning_content?: string;
|
||||
thinkingStatus?: ThinkingStatus;
|
||||
thinlCollapse?: boolean;
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
result: string;
|
||||
status: 'TOOL_CALL' | 'TOOL_RESULT';
|
||||
};
|
||||
|
||||
type MessageItem = ChatMessage & {
|
||||
chains?: (Think | Tool)[];
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
bot?: BotInfo;
|
||||
conversationId?: string;
|
||||
// 是否显示对话列表
|
||||
showChatConversations?: boolean;
|
||||
}>();
|
||||
const botStore = useBotStore();
|
||||
interface historyMessageType {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
interface presetQuestionsType {
|
||||
key: string;
|
||||
description: string;
|
||||
}
|
||||
const route = useRoute();
|
||||
const botId = ref<string>((route.params.id as string) || '');
|
||||
const router = useRouter();
|
||||
|
||||
const bubbleItems = ref<BubbleListProps<MessageItem>['list']>([]);
|
||||
const senderRef = ref<InstanceType<typeof Sender>>();
|
||||
const senderValue = ref('');
|
||||
const sending = ref(false);
|
||||
const getConversationId = async () => {
|
||||
const res = await api.get('/api/v1/bot/generateConversationId');
|
||||
return res.data;
|
||||
};
|
||||
const localeConversationId = ref<any>('');
|
||||
|
||||
const presetQuestions = ref<presetQuestionsType[]>([]);
|
||||
defineExpose({
|
||||
clear() {
|
||||
bubbleItems.value = [];
|
||||
messages.value = [];
|
||||
},
|
||||
});
|
||||
const getPresetQuestions = () => {
|
||||
api
|
||||
.get('/api/v1/bot/detail', {
|
||||
params: {
|
||||
id: botId.value,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.options?.presetQuestions) {
|
||||
presetQuestions.value = res.data.options?.presetQuestions
|
||||
.filter(
|
||||
(item: presetQuestionsType) =>
|
||||
item.description && item.description.trim() !== '',
|
||||
)
|
||||
.map((item: presetQuestionsType) => ({
|
||||
key: item.key,
|
||||
description: item.description,
|
||||
}));
|
||||
}
|
||||
});
|
||||
};
|
||||
onMounted(async () => {
|
||||
// 初始化 conversationId
|
||||
localeConversationId.value =
|
||||
props.conversationId && props.conversationId.length > 0
|
||||
? props.conversationId
|
||||
: await getConversationId();
|
||||
getPresetQuestions();
|
||||
});
|
||||
watchEffect(async () => {
|
||||
if (props.bot && props.conversationId) {
|
||||
const [, res] = await tryit(getMessageList)({
|
||||
conversationId: props.conversationId,
|
||||
botId: props.bot.id,
|
||||
tempUserId: uuid() + props.bot.id,
|
||||
});
|
||||
|
||||
if (res?.errorCode === 0) {
|
||||
bubbleItems.value = res.data.map((item) => ({
|
||||
...item,
|
||||
content:
|
||||
item.role === 'assistant'
|
||||
? item.content.replace(/^Final Answer:\s*/i, '')
|
||||
: item.content,
|
||||
placement: item.role === 'assistant' ? 'start' : 'end',
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
bubbleItems.value = [];
|
||||
}
|
||||
});
|
||||
const lastUserMessage = ref('');
|
||||
const messages = ref<historyMessageType[]>([]);
|
||||
const stopSse = () => {
|
||||
sseClient.abort();
|
||||
sending.value = false;
|
||||
const lastBubbleItem = bubbleItems.value[bubbleItems.value.length - 1];
|
||||
if (lastBubbleItem) {
|
||||
bubbleItems.value[bubbleItems.value.length - 1] = {
|
||||
...lastBubbleItem,
|
||||
content: lastBubbleItem.content,
|
||||
loading: false,
|
||||
typing: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const clearSenderFiles = () => {
|
||||
files.value = [];
|
||||
attachmentsRef.value?.clearFiles();
|
||||
openCloseHeader();
|
||||
};
|
||||
const handleSubmit = async (refreshContent: string) => {
|
||||
const attachments = attachmentsRef.value?.getFileList();
|
||||
const currentPrompt = refreshContent || senderValue.value.trim();
|
||||
if (!currentPrompt) {
|
||||
return;
|
||||
}
|
||||
sending.value = true;
|
||||
lastUserMessage.value = currentPrompt;
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: currentPrompt,
|
||||
});
|
||||
const copyMessages = [...messages.value];
|
||||
const data = {
|
||||
botId: botId.value,
|
||||
prompt: currentPrompt,
|
||||
conversationId: localeConversationId.value,
|
||||
messages: copyMessages,
|
||||
attachments,
|
||||
};
|
||||
clearSenderFiles();
|
||||
messages.value.pop();
|
||||
const mockMessages = generateMockMessages(refreshContent);
|
||||
bubbleItems.value.push(...mockMessages);
|
||||
senderRef.value?.clear();
|
||||
sseClient.post('/api/v1/bot/chat', data, {
|
||||
onMessage(message) {
|
||||
const event = message.event;
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
const lastBubbleItem = bubbleItems.value[lastIndex];
|
||||
|
||||
// finish
|
||||
if (event === 'done') {
|
||||
sending.value = false;
|
||||
return;
|
||||
}
|
||||
if (!message.data) {
|
||||
return;
|
||||
}
|
||||
// 处理系统错误
|
||||
const sseData = JSON.parse(message.data);
|
||||
if (
|
||||
sseData?.domain === 'SYSTEM' &&
|
||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||
) {
|
||||
const errorMessage = sseData.payload.message;
|
||||
if (!lastBubbleItem) return;
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...lastBubbleItem,
|
||||
content: errorMessage,
|
||||
loading: false,
|
||||
typing: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastIndex >= 0 && sseData?.domain === 'TOOL') {
|
||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
||||
const index = chains.findIndex(
|
||||
(chain) =>
|
||||
isTool(chain) && chain.id === sseData?.payload?.tool_call_id,
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
chains.push({
|
||||
id: sseData?.payload?.tool_call_id,
|
||||
name: sseData?.payload?.name,
|
||||
status: sseData?.type,
|
||||
result:
|
||||
sseData?.type === 'TOOL_CALL'
|
||||
? sseData?.payload?.arguments
|
||||
: sseData?.payload?.result,
|
||||
});
|
||||
} else {
|
||||
chains[index] = {
|
||||
...chains[index]!,
|
||||
status: sseData?.type,
|
||||
result:
|
||||
sseData?.type === 'TOOL_CALL'
|
||||
? sseData?.payload?.arguments
|
||||
: sseData?.payload?.result,
|
||||
};
|
||||
}
|
||||
bubbleItems.value[lastIndex]!.chains = chains;
|
||||
stopThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理流式消息
|
||||
const delta = sseData.payload?.delta;
|
||||
const role = sseData.payload?.role;
|
||||
|
||||
if (lastBubbleItem && delta) {
|
||||
if (sseData.type === 'THINKING') {
|
||||
const chains = cloneDeep(lastBubbleItem?.chains ?? []);
|
||||
const index = chains.findIndex(
|
||||
(chain) => isThink(chain) && chain.thinkingStatus === 'thinking',
|
||||
);
|
||||
|
||||
if (index === -1) {
|
||||
chains.push({
|
||||
thinkingStatus: 'thinking',
|
||||
thinlCollapse: true,
|
||||
reasoning_content: delta,
|
||||
});
|
||||
} else {
|
||||
const think = chains[index]! as Think;
|
||||
chains[index] = {
|
||||
...think,
|
||||
reasoning_content: think.reasoning_content + delta,
|
||||
};
|
||||
}
|
||||
bubbleItems.value[lastIndex]!.chains = chains;
|
||||
} else if (sseData.type === 'MESSAGE') {
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...lastBubbleItem,
|
||||
content: (lastBubbleItem.content + delta).replaceAll(
|
||||
'```echartsoption',
|
||||
'```echarts\noption',
|
||||
),
|
||||
loading: false,
|
||||
typing: true,
|
||||
};
|
||||
stopThinking();
|
||||
}
|
||||
}
|
||||
|
||||
// 是否需要保存聊天记录
|
||||
if (event === 'needSaveMessage') {
|
||||
messages.value.push({
|
||||
role,
|
||||
content: sseData.payload?.content,
|
||||
});
|
||||
}
|
||||
},
|
||||
onFinished() {
|
||||
sending.value = false;
|
||||
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
if (lastIndex) {
|
||||
bubbleItems.value[lastIndex] = {
|
||||
...bubbleItems.value[lastIndex]!,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
stopThinking();
|
||||
},
|
||||
onError(err) {
|
||||
console.error(err);
|
||||
sending.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isTool = (item: Think | Tool) => {
|
||||
return 'id' in item;
|
||||
};
|
||||
const isThink = (item: Think | Tool): item is Think => {
|
||||
return !('id' in item);
|
||||
};
|
||||
const stopThinking = () => {
|
||||
const lastIndex = bubbleItems.value.length - 1;
|
||||
|
||||
if (lastIndex >= 0 && bubbleItems.value[lastIndex]?.chains) {
|
||||
const chains = cloneDeep(bubbleItems.value[lastIndex].chains);
|
||||
|
||||
for (const chain of chains) {
|
||||
if (isThink(chain) && chain.thinkingStatus === 'thinking') {
|
||||
chain.thinkingStatus = 'end';
|
||||
}
|
||||
}
|
||||
|
||||
bubbleItems.value[lastIndex].chains = chains;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = (_: TypewriterInstance, index: number) => {
|
||||
if (
|
||||
index === bubbleItems.value.length - 1 &&
|
||||
props.conversationId &&
|
||||
props.conversationId.length <= 0 &&
|
||||
sending.value === false
|
||||
) {
|
||||
setTimeout(() => {
|
||||
router.replace({
|
||||
params: { conversationId: localeConversationId.value },
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const generateMockMessages = (refreshContent: string) => {
|
||||
const userMessage: MessageItem = {
|
||||
role: 'user',
|
||||
id: Date.now().toString(),
|
||||
fileList: [],
|
||||
content: refreshContent || senderValue.value,
|
||||
created: Date.now(),
|
||||
updateAt: Date.now(),
|
||||
placement: 'end',
|
||||
};
|
||||
|
||||
const assistantMessage: MessageItem = {
|
||||
role: 'assistant',
|
||||
id: Date.now().toString(),
|
||||
content: '',
|
||||
loading: true,
|
||||
created: Date.now(),
|
||||
updateAt: Date.now(),
|
||||
placement: 'start',
|
||||
};
|
||||
|
||||
return [userMessage, assistantMessage];
|
||||
};
|
||||
|
||||
const handleCopy = (content: string) => {
|
||||
navigator.clipboard
|
||||
.writeText(content)
|
||||
.then(() => ElMessage.success($t('message.copySuccess')))
|
||||
.catch(() => ElMessage.error($t('message.copyFail')));
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
handleSubmit(lastUserMessage.value);
|
||||
};
|
||||
const showHeaderFlog = ref(false);
|
||||
function openCloseHeader() {
|
||||
if (showHeaderFlog.value) {
|
||||
senderRef.value?.closeHeader();
|
||||
files.value = [];
|
||||
} else {
|
||||
senderRef.value?.openHeader();
|
||||
}
|
||||
showHeaderFlog.value = !showHeaderFlog.value;
|
||||
}
|
||||
const attachmentsRef = ref();
|
||||
const files = ref<any[]>([]);
|
||||
function handlePasteFile(_: any, fileList: FileList) {
|
||||
showHeaderFlog.value = true;
|
||||
senderRef.value?.openHeader();
|
||||
files.value = [...fileList];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto h-full max-w-[780px]">
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex h-full w-full flex-col gap-3',
|
||||
!localeConversationId && 'items-center justify-center gap-8',
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- 对话列表 -->
|
||||
<div
|
||||
v-if="localeConversationId || bubbleItems.length > 0"
|
||||
class="message-container w-full flex-1 overflow-hidden"
|
||||
>
|
||||
<ElBubbleList
|
||||
class="!h-full"
|
||||
max-height="none"
|
||||
:list="bubbleItems"
|
||||
@complete="handleComplete"
|
||||
>
|
||||
<template #header="{ item }">
|
||||
<div class="flex flex-col">
|
||||
<span class="chat-bubble-item-time-style">
|
||||
{{ new Date(item.created).toLocaleString() }}
|
||||
</span>
|
||||
|
||||
<template v-if="item.chains">
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ElThinking
|
||||
v-if="isThink(chain)"
|
||||
v-model="chain.thinlCollapse"
|
||||
:content="chain.reasoning_content"
|
||||
:status="chain.thinkingStatus"
|
||||
/>
|
||||
<ElCollapse v-else class="mb-2">
|
||||
<ElCollapseItem :title="chain.name" :name="chain.id">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-5">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span>{{ chain.name }}</span>
|
||||
<template v-if="chain.status === 'TOOL_CALL'">
|
||||
<div
|
||||
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
|
||||
>
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon
|
||||
icon="mdi:clock-time-five-outline"
|
||||
/>
|
||||
</ElIcon>
|
||||
<span>{{ $t('bot.Running') }}...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="bg-secondary flex items-center gap-1 rounded-full px-2 py-0.5 leading-none"
|
||||
>
|
||||
<ElIcon size="16" color="var(--el-color-success)">
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
<span>{{ $t('bot.Completed') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="chain.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- <ElThinking
|
||||
v-if="item.reasoning_content"
|
||||
v-model="item.thinlCollapse"
|
||||
:content="item.reasoning_content"
|
||||
:status="item.thinkingStatus"
|
||||
class="mb-3"
|
||||
/> -->
|
||||
<!-- <ElCollapse v-if="item.tools" class="mb-2">
|
||||
<ElCollapseItem
|
||||
class="mb-2"
|
||||
v-for="tool in item.tools"
|
||||
:key="tool.id"
|
||||
:title="tool.name"
|
||||
:name="tool.id"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2 pl-5">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:wrench" />
|
||||
</ElIcon>
|
||||
<span>{{ tool.name }}</span>
|
||||
<template v-if="tool.status === 'TOOL_CALL'">
|
||||
<ElIcon size="16">
|
||||
<IconifyIcon icon="svg:spinner" />
|
||||
</ElIcon>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElIcon size="16" color="var(--el-color-success)">
|
||||
<CircleCheck />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<ShowJson :value="tool.result" />
|
||||
</ElCollapseItem>
|
||||
</ElCollapse> -->
|
||||
</div>
|
||||
</template>
|
||||
<!-- 自定义头像 -->
|
||||
<template #avatar="{ item }">
|
||||
<BotAvatar
|
||||
v-if="item.role === 'assistant'"
|
||||
:src="bot?.icon"
|
||||
:size="40"
|
||||
/>
|
||||
</template>
|
||||
<template #content="{ item }">
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
</template>
|
||||
<!-- 自定义底部 -->
|
||||
<template #footer="{ item }">
|
||||
<ElSpace :size="10">
|
||||
<ElSpace>
|
||||
<span @click="handleRefresh()" style="cursor: pointer">
|
||||
<ElIcon>
|
||||
<RefreshRight />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<span @click="handleCopy(item.content)" style="cursor: pointer">
|
||||
<ElIcon>
|
||||
<CopyDocument />
|
||||
</ElIcon>
|
||||
</span>
|
||||
</ElSpace>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</ElBubbleList>
|
||||
</div>
|
||||
|
||||
<!-- 新对话显示bot信息 -->
|
||||
<div v-else class="flex flex-col items-center gap-3.5">
|
||||
<BotAvatar :src="bot?.icon" :size="88" />
|
||||
<h1 class="text-base font-medium text-black/85">
|
||||
{{ bot?.title }}
|
||||
</h1>
|
||||
<span class="text-sm text-[#757575]">{{ bot?.description }}</span>
|
||||
</div>
|
||||
|
||||
<!--问题预设-->
|
||||
<div
|
||||
class="questions-preset-container"
|
||||
v-if="botStore.presetQuestions.length > 0"
|
||||
>
|
||||
<ElButton
|
||||
v-for="item in getPerQuestions(botStore.presetQuestions)"
|
||||
:key="item.key"
|
||||
@click="handleSubmit(item.description)"
|
||||
>
|
||||
{{ item.description }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<!-- Sender -->
|
||||
<ElSender
|
||||
ref="senderRef"
|
||||
class="w-full"
|
||||
v-model="senderValue"
|
||||
:placeholder="$t('message.pleaseInputContent')"
|
||||
variant="updown"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
allow-speech
|
||||
@submit="handleSubmit"
|
||||
@paste-file="handlePasteFile"
|
||||
>
|
||||
<!-- 自定义头部内容 -->
|
||||
<template #header>
|
||||
<ChatFileUploader
|
||||
ref="attachmentsRef"
|
||||
:external-files="files"
|
||||
:max-size="10"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #action-list>
|
||||
<ElSpace>
|
||||
<ElButton circle @click="openCloseHeader">
|
||||
<ElIcon><Paperclip /></ElIcon>
|
||||
</ElButton>
|
||||
<!--<ElButton circle @click="uploadRef.triggerFileSelect()">
|
||||
<ElIcon><Paperclip /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton circle>
|
||||
<ElIcon><Microphone /></ElIcon>
|
||||
<!– <ElIcon color="#0066FF"><RecordingIcon /></ElIcon> –>
|
||||
</ElButton>-->
|
||||
<ElButton v-if="sending" circle @click="stopSse">
|
||||
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
|
||||
</ElButton>
|
||||
<template v-else>
|
||||
<ElButton v-if="!senderValue" circle disabled>
|
||||
<SendIcon />
|
||||
</ElButton>
|
||||
<ElButton v-else circle @click="handleSubmit('')">
|
||||
<SendEnableIcon />
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</ElSender>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.questions-preset-container {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
padding: 8px;
|
||||
background-color: var(--bot-chat-message-container);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dark .message-container {
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
:deep(.el-bubble-content-wrapper .el-bubble-content-filled[data-v-a52d8fe0]) {
|
||||
background-color: var(--bot-chat-message-item-back);
|
||||
}
|
||||
|
||||
.chat-bubble-item-time-style {
|
||||
font-size: 12px;
|
||||
color: var(--common-font-placeholder-color);
|
||||
}
|
||||
|
||||
.el-bubble-list :deep(.el-bubble.el-bubble-start) {
|
||||
--bubble-content-max-width: calc(
|
||||
100% - var(--el-bubble-avatar-placeholder-gap)
|
||||
) !important;
|
||||
}
|
||||
|
||||
.el-bubble-list :deep(.el-bubble.el-bubble-end) {
|
||||
--bubble-content-max-width: calc(
|
||||
100% -
|
||||
calc(
|
||||
var(--el-bubble-avatar-placeholder-gap) + var(--el-avatar-size, 40px)
|
||||
)
|
||||
) !important;
|
||||
}
|
||||
|
||||
:deep(.el-bubble-header) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-bubble-end .el-bubble-header) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
:deep(.el-thinking) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-thinking .content-wrapper) {
|
||||
--el-thinking-content-wrapper-width: var(--bubble-content-max-width);
|
||||
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item) {
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
309
easyflow-ui-admin/app/src/components/collapse/CustomCoolapse.vue
Normal file
309
easyflow-ui-admin/app/src/components/collapse/CustomCoolapse.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
interface AccordionItem {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const accordionData = ref<AccordionItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Vue.js 简介',
|
||||
content:
|
||||
'Vue.js 是一套用于构建用户界面的渐进式框架。与其它大型框架不同的是,Vue 被设计为可以自底向上逐层应用。',
|
||||
isOpen: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Composition API',
|
||||
content:
|
||||
'Composition API 是 Vue 3 中引入的一组 API,允许您使用函数而不是通过选项来组织组件的逻辑。',
|
||||
isOpen: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '响应式原理',
|
||||
content:
|
||||
'Vue 3 使用 Proxy 对象来实现响应式,相比 Vue 2 的 Object.defineProperty,Proxy 可以监听动态添加的属性。',
|
||||
isOpen: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const allowMultiple = ref(false);
|
||||
|
||||
const togglePanel = (index: number) => {
|
||||
const item = accordionData.value[index];
|
||||
|
||||
if (!item) return;
|
||||
|
||||
if (!allowMultiple.value) {
|
||||
accordionData.value.forEach((otherItem, i) => {
|
||||
if (i !== index && otherItem) {
|
||||
otherItem.isOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
item.isOpen = !item.isOpen;
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
accordionData.value.forEach((item) => {
|
||||
if (item) {
|
||||
item.isOpen = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
accordionData.value.forEach((item) => {
|
||||
if (item) {
|
||||
item.isOpen = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="accordion-container">
|
||||
<h1 class="title">Vue3 折叠面板</h1>
|
||||
<p class="subtitle">使用 Vue3 Composition API 实现</p>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="allowMultiple" class="checkbox" />
|
||||
允许多个同时展开
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button @click="expandAll" class="control-btn">展开全部</button>
|
||||
<button @click="collapseAll" class="control-btn">收起全部</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 折叠面板列表 -->
|
||||
<div class="accordion-list">
|
||||
<div
|
||||
v-for="(item, index) in accordionData"
|
||||
:key="item.id"
|
||||
class="accordion-item"
|
||||
:class="{ 'accordion-item--active': item.isOpen }"
|
||||
>
|
||||
<!-- 面板头部 -->
|
||||
<div class="accordion-header" @click="togglePanel(index)">
|
||||
<div class="column-header-container">
|
||||
<div
|
||||
class="accordion-icon"
|
||||
:class="{ 'accordion-icon--rotated': item.isOpen }"
|
||||
>
|
||||
▼
|
||||
</div>
|
||||
<h3 class="accordion-title">{{ item.title }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 面板内容 -->
|
||||
<div
|
||||
class="accordion-content"
|
||||
:class="{ 'accordion-content--open': item.isOpen }"
|
||||
>
|
||||
<div class="accordion-content-inner">
|
||||
<p>{{ item.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.accordion-container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 30px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* 控制面板样式 */
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #3498db;
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: #3498db;
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
/* 折叠面板列表 */
|
||||
.accordion-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border: 1px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--el-bg-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.accordion-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.accordion-item--active {
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
/* 面板头部 */
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.accordion-icon {
|
||||
transition: transform 0.3s ease;
|
||||
color: #7f8c8d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.accordion-icon--rotated {
|
||||
transform: rotate(180deg);
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.accordion-content--open {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.accordion-content-inner {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #e1e5e9;
|
||||
}
|
||||
|
||||
.accordion-content-inner p {
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
.column-header-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.accordion-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import { Delete } from '@element-plus/icons-vue';
|
||||
import { ElAvatar, ElIcon, ElMessageBox } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Array as any,
|
||||
default: () => [],
|
||||
},
|
||||
titleKey: {
|
||||
type: String,
|
||||
default: 'title',
|
||||
},
|
||||
descriptionKey: {
|
||||
type: String,
|
||||
default: 'description',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['delete']);
|
||||
const handleDelete = (item: any) => {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('button.confirm'),
|
||||
cancelButtonText: $t('button.cancel'),
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
emits('delete', item);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="collapse-item-container">
|
||||
<div
|
||||
v-for="(item, index) in props.data"
|
||||
:key="index"
|
||||
class="el-list-item-max-container"
|
||||
>
|
||||
<div class="el-list-item-container">
|
||||
<div class="flex-center">
|
||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
||||
</div>
|
||||
<div class="el-list-item-content">
|
||||
<div class="title">{{ item[titleKey] }}</div>
|
||||
<div class="description">{{ item[descriptionKey] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElIcon
|
||||
color="var(--el-color-danger)"
|
||||
size="20px"
|
||||
@click="handleDelete(item)"
|
||||
class="el-list-item-delete-container"
|
||||
>
|
||||
<Delete />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-list-item-max-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px 12px 12px;
|
||||
background-color: hsl(var(--background));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collapse-item-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background-color: var(--bot-collapse-itme-back);
|
||||
}
|
||||
|
||||
.el-list-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.el-list-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.el-list-item-delete-container {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: PingFangSC, 'PingFang SC', sans-serif;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: PingFangSC, 'PingFang SC', sans-serif;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component, PropType } from 'vue';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCheckbox,
|
||||
ElCollapse,
|
||||
ElCollapseItem,
|
||||
ElDialog,
|
||||
ElMessage,
|
||||
ElText,
|
||||
} from 'element-plus';
|
||||
|
||||
import HeaderSearch from '#/components/headerSearch/HeaderSearch.vue';
|
||||
import PageData from '#/components/page/PageData.vue';
|
||||
|
||||
export interface ButtonConfig {
|
||||
key?: number | string;
|
||||
text: string;
|
||||
type?: 'danger' | 'default' | 'info' | 'primary' | 'success' | 'warning';
|
||||
icon?: Component | string; // 支持字符串图标或组件图标
|
||||
disabled?: boolean;
|
||||
permission?: string; // 权限编码
|
||||
[key: string]: any; // 允许传递自定义属性
|
||||
}
|
||||
|
||||
interface SelectedMcpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
width: { type: String, default: '80%' },
|
||||
extraQueryParams: { type: Object, default: () => ({}) },
|
||||
searchParams: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
titleKey: { type: String, default: 'name' },
|
||||
pageUrl: { type: String, default: '' },
|
||||
hasParent: { type: Boolean, default: false },
|
||||
isSelectMcp: { type: Boolean, default: false },
|
||||
singleSelect: { type: Boolean, default: false },
|
||||
footerButtons: {
|
||||
type: Array as PropType<ButtonConfig[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['getData', 'buttonClick']);
|
||||
const dialogVisible = ref(false);
|
||||
const pageDataRef = ref();
|
||||
const loading = ref(false);
|
||||
const selectedIds = ref<(number | string)[]>([]);
|
||||
// 存储上一级id与选中tool.name的关联关系
|
||||
const selectedToolMap = ref<Record<number | string, SelectedMcpTool[]>>({});
|
||||
defineExpose({
|
||||
openDialog(defaultSelectedIds: (number | string)[]) {
|
||||
selectedIds.value = defaultSelectedIds ? [...defaultSelectedIds] : [];
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
/**
|
||||
* MCP专属弹窗打开方法(适配MCP回显,传递格式化后的MCP数据)
|
||||
* @param selectMcpMap - MCP已选数据映射(键:MCP父级ID,值:工具名称+描述数组)
|
||||
*/
|
||||
openMcpDialog(selectMcpMap: Record<number | string, SelectedMcpTool[]>) {
|
||||
selectedIds.value = [];
|
||||
selectedToolMap.value = structuredClone(selectMcpMap);
|
||||
dialogVisible.value = true;
|
||||
},
|
||||
});
|
||||
const isSelected = (id: number | string) => {
|
||||
return selectedIds.value.includes(id);
|
||||
};
|
||||
|
||||
const isSelectedMcp = (parentId: number | string, toolName: string) => {
|
||||
// 查找当前parentId下是否存在该tool.name的工具
|
||||
return !!selectedToolMap.value[parentId]?.some(
|
||||
(tool) => tool.name === toolName,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelectionMcp = (
|
||||
mcpId: number | string,
|
||||
toolName: string,
|
||||
toolDescription: string,
|
||||
checked: any,
|
||||
) => {
|
||||
if (checked) {
|
||||
if (!selectedToolMap.value[mcpId]) {
|
||||
selectedToolMap.value[mcpId] = []; // 初始化空数组
|
||||
}
|
||||
const isExisted = selectedToolMap.value[mcpId]?.some(
|
||||
(tool) => tool.name === toolName,
|
||||
);
|
||||
if (!isExisted) {
|
||||
selectedToolMap.value[mcpId]?.push({
|
||||
name: toolName,
|
||||
description: toolDescription,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (selectedToolMap.value[mcpId]) {
|
||||
selectedToolMap.value[mcpId] = selectedToolMap.value[mcpId].filter(
|
||||
(tool) => tool.name !== toolName,
|
||||
);
|
||||
if (selectedToolMap.value[mcpId].length === 0) {
|
||||
delete selectedToolMap.value[mcpId];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelection = (id: number | string, checked: any) => {
|
||||
if (checked) {
|
||||
// 单选模式:先清空已选,再添加当前项
|
||||
if (props.singleSelect) {
|
||||
selectedIds.value = [id];
|
||||
} else {
|
||||
// 多选模式:追加当前项(避免重复)
|
||||
if (!selectedIds.value.includes(id)) {
|
||||
selectedIds.value.push(id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 取消选中:仅多选模式生效,单选模式不允许取消(可选)
|
||||
if (!props.singleSelect) {
|
||||
selectedIds.value = selectedIds.value.filter((i) => i !== id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 封装:获取MCP选中的结构化信息(包含name和description)
|
||||
* @returns {Record<number | string, string[][]>[]} 符合要求的数据:[{ 上一级id: [[name1, description1], [name2, description2]] }]
|
||||
*/
|
||||
const getMcpSelectedInfo = (): Record<number | string, string[][]>[] => {
|
||||
const mcpSelectedResult: Record<number | string, string[][]>[] = [];
|
||||
|
||||
Object.entries(selectedToolMap.value).forEach(([parentId, selectedTools]) => {
|
||||
// 转换每个工具为 [name, description] 一维数组
|
||||
const formattedToolList: string[][] = selectedTools.map((tool) => [
|
||||
tool.name,
|
||||
tool.description,
|
||||
]);
|
||||
|
||||
mcpSelectedResult.push({
|
||||
[parentId]: formattedToolList,
|
||||
});
|
||||
});
|
||||
|
||||
return mcpSelectedResult;
|
||||
};
|
||||
|
||||
const handleSubmitRun = () => {
|
||||
const hasSelected = props.isSelectMcp
|
||||
? Object.keys(selectedToolMap.value).some(
|
||||
(parentId) => (selectedToolMap.value[parentId] ?? []).length > 0,
|
||||
)
|
||||
: selectedIds.value.length > 0;
|
||||
|
||||
// 未选中内容时提示并返回
|
||||
if (!hasSelected) {
|
||||
ElMessage.error($t('message.selectTip'));
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = props.isSelectMcp
|
||||
? getMcpSelectedInfo()
|
||||
: selectedIds.value;
|
||||
emit('getData', submitData);
|
||||
|
||||
// 关闭弹窗并返回数据
|
||||
dialogVisible.value = false;
|
||||
return submitData;
|
||||
};
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
const tempParams = {} as Record<string, string>;
|
||||
props.searchParams.forEach((paramName) => {
|
||||
tempParams[paramName] = query;
|
||||
});
|
||||
|
||||
pageDataRef.value?.setQuery({
|
||||
isQueryOr: true,
|
||||
...tempParams,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDialog
|
||||
v-model="dialogVisible"
|
||||
draggable
|
||||
:close-on-click-modal="false"
|
||||
:width="props.width"
|
||||
align-center
|
||||
>
|
||||
<template #header>
|
||||
<div>
|
||||
<p class="el-dialog__title mb-4">{{ props.title }}</p>
|
||||
<HeaderSearch @search="handleSearch" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="select-modal-container p-5">
|
||||
<PageData
|
||||
ref="pageDataRef"
|
||||
:page-url="pageUrl"
|
||||
:page-size="10"
|
||||
:extra-query-params="extraQueryParams"
|
||||
>
|
||||
<template #default="{ pageList }">
|
||||
<template v-if="hasParent">
|
||||
<div class="container-second">
|
||||
<ElCollapse
|
||||
accordion
|
||||
v-for="(item, index) in pageList"
|
||||
:key="index"
|
||||
>
|
||||
<ElCollapseItem>
|
||||
<template #title="{ isActive }">
|
||||
<div
|
||||
class="title-wrapper"
|
||||
:class="[{ 'is-active': isActive }]"
|
||||
>
|
||||
<div>
|
||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
||||
</div>
|
||||
<div class="title-right-container">
|
||||
<ElText truncated class="title">
|
||||
{{ item[titleKey] }}
|
||||
</ElText>
|
||||
<div class="desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!--选择插件-->
|
||||
<div v-if="!isSelectMcp">
|
||||
<div v-for="tool in item.tools" :key="tool.id">
|
||||
<div
|
||||
class="content-title-wrapper"
|
||||
@click="toggleSelection(tool.id, !isSelected(tool.id))"
|
||||
:class="{ 'item-selected': isSelected(tool.id) }"
|
||||
>
|
||||
<div class="content-left-container">
|
||||
<div class="title-right-container">
|
||||
<ElText truncated class="title">
|
||||
{{ tool.name }}
|
||||
</ElText>
|
||||
<div class="desc">{{ tool.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElCheckbox
|
||||
:model-value="isSelected(tool.id)"
|
||||
@change="(val) => toggleSelection(tool.id, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--选择MCP-->
|
||||
<div v-if="isSelectMcp">
|
||||
<div v-for="tool in item.tools" :key="tool.name">
|
||||
<!-- 2. MCP专属:绑定点击事件,取反MCP选中状态 -->
|
||||
<div
|
||||
class="content-title-wrapper"
|
||||
@click="
|
||||
toggleSelectionMcp(
|
||||
item.id,
|
||||
tool.name,
|
||||
tool.description,
|
||||
!isSelectedMcp(item.id, tool.name),
|
||||
)
|
||||
"
|
||||
:class="{
|
||||
'item-selected': isSelectedMcp(item.id, tool.name),
|
||||
}"
|
||||
>
|
||||
<div class="content-left-container">
|
||||
<div class="title-right-container">
|
||||
<ElText truncated class="title">
|
||||
{{ tool.name }}
|
||||
</ElText>
|
||||
<div class="desc">{{ tool.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElCheckbox
|
||||
:model-value="isSelectedMcp(item.id, tool.name)"
|
||||
@change="
|
||||
(val) =>
|
||||
toggleSelectionMcp(
|
||||
item.id,
|
||||
tool.name,
|
||||
tool.description,
|
||||
val,
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="container-second">
|
||||
<div v-for="(item, index) in pageList" :key="index">
|
||||
<div
|
||||
class="content-title-wrapper"
|
||||
@click="toggleSelection(item.id, !isSelected(item.id))"
|
||||
:class="{ 'item-selected': isSelected(item.id) }"
|
||||
>
|
||||
<div class="content-sec-left-container">
|
||||
<div>
|
||||
<ElAvatar :src="item.icon" v-if="item.icon" />
|
||||
<ElAvatar v-else src="/favicon.svg" shape="circle" />
|
||||
</div>
|
||||
<div class="title-sec-right-container">
|
||||
<ElText truncated class="title">
|
||||
{{ item.title }}
|
||||
</ElText>
|
||||
<div class="desc">{{ item.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElCheckbox
|
||||
:model-value="isSelected(item.id)"
|
||||
@change="(val) => toggleSelection(item.id, val)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</PageData>
|
||||
</div>
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleSubmitRun" :loading="loading">
|
||||
{{ $t('button.confirm') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-modal-container {
|
||||
/* height: 100%;
|
||||
overflow: auto; */
|
||||
background-color: var(--bot-collapse-itme-back);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-title-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 113px;
|
||||
padding: 20px 50px 20px 20px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: var(--el-bg-color);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: PingFangSC, 'PingFang SC';
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.content-left-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-sec-left-container {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.desc {
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
|
||||
/* height: 42px;
|
||||
min-height: 42px; */
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
font-family: PingFangSC, 'PingFang SC';
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
text-align: left;
|
||||
text-transform: none;
|
||||
opacity: 0.65;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.title-right-container {
|
||||
/* display: flex;
|
||||
flex-direction: column;
|
||||
align-items: first baseline;
|
||||
justify-content: center; */
|
||||
padding-right: 10px;
|
||||
margin-left: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-sec-right-container {
|
||||
/* display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start; */
|
||||
padding-right: 10px;
|
||||
margin-left: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container-second {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
/* padding: 20px 20px; */
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item__header .el-collapse-item__arrow) {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item.is-active .el-collapse-item__arrow) {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item__content)
|
||||
.content-title-wrapper:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse-item__header) {
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
line-height: normal;
|
||||
color: #333;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse-item__header:hover) {
|
||||
background-color: hsl(var(--background));
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item.is-active .el-collapse-item__header) {
|
||||
color: #1976d2;
|
||||
background-color: hsl(var(--background));
|
||||
border: none;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse-item__content) {
|
||||
padding: 12px;
|
||||
background-color: hsl(var(--background));
|
||||
border: none;
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse-item__wrap) {
|
||||
background-color: hsl(var(--background));
|
||||
border: none;
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse-item) {
|
||||
margin-bottom: 8px;
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item__content)
|
||||
.content-title-wrapper {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: var(--bot-collapse-itme-back);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.select-modal-container
|
||||
:deep(.el-collapse-item__content)
|
||||
.content-title-wrapper:hover {
|
||||
background-color: var(--bot-collapse-itme-back);
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-collapse) {
|
||||
overflow: hidden;
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.select-modal-container :deep(.el-checkbox__inner) {
|
||||
--el-checkbox-input-border: 1px solid hsl(var(--border));
|
||||
}
|
||||
</style>
|
||||
281
easyflow-ui-admin/app/src/components/cron/CronGenerator.vue
Normal file
281
easyflow-ui-admin/app/src/components/cron/CronGenerator.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<script setup lang="ts">
|
||||
import type { CronItemState } from './CronTabPane.vue';
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import {
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElInput,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTabPane,
|
||||
ElTabs,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import CrontabPane from './CronTabPane.vue';
|
||||
|
||||
const emit = defineEmits(['useCron']);
|
||||
const activeTab = ref('second');
|
||||
|
||||
// 默认状态工厂函数
|
||||
const defaultState = (min: number, _: number): CronItemState => ({
|
||||
type: 'every',
|
||||
rangeStart: min,
|
||||
rangeEnd: min + 1,
|
||||
loopStart: min,
|
||||
loopStep: 1,
|
||||
specificList: [],
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
second: defaultState(0, 59),
|
||||
minute: defaultState(0, 59),
|
||||
hour: defaultState(0, 23),
|
||||
day: { ...defaultState(1, 31), type: 'every' } as CronItemState,
|
||||
month: defaultState(1, 12),
|
||||
week: { ...defaultState(1, 7), type: 'none' } as CronItemState, // 默认为?
|
||||
});
|
||||
|
||||
const weekAlias: Record<number, string> = {
|
||||
1: $t('common.Sun'),
|
||||
2: $t('common.Mon'),
|
||||
3: $t('common.Tue'),
|
||||
4: $t('common.Wed'),
|
||||
5: $t('common.Thu'),
|
||||
6: $t('common.Fri'),
|
||||
7: $t('common.Sat'),
|
||||
};
|
||||
|
||||
// 核心:格式化单个字段
|
||||
const formatItem = (item: CronItemState): string => {
|
||||
switch (item.type) {
|
||||
case 'every': {
|
||||
return '*';
|
||||
}
|
||||
case 'loop': {
|
||||
return `${item.loopStart}/${item.loopStep}`;
|
||||
}
|
||||
case 'none': {
|
||||
return '?';
|
||||
}
|
||||
case 'range': {
|
||||
return `${item.rangeStart}-${item.rangeEnd}`;
|
||||
}
|
||||
case 'specific': {
|
||||
if (item.specificList.length === 0) return '*';
|
||||
return item.specificList.sort((a, b) => a - b).join(',');
|
||||
}
|
||||
default: {
|
||||
return '*';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 互斥逻辑
|
||||
const handleDayChange = () => {
|
||||
if (state.day.type !== 'none') {
|
||||
state.week.type = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const handleWeekChange = () => {
|
||||
if (state.week.type !== 'none') {
|
||||
state.day.type = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// 计算最终 Cron 字符串
|
||||
const cronResult = computed(() => {
|
||||
const s = formatItem(state.second);
|
||||
const m = formatItem(state.minute);
|
||||
const h = formatItem(state.hour);
|
||||
const d = formatItem(state.day);
|
||||
const M = formatItem(state.month);
|
||||
const w = formatItem(state.week);
|
||||
return `${s} ${m} ${h} ${d} ${M} ${w}`;
|
||||
});
|
||||
|
||||
// 表格展示数据
|
||||
const resultTableData = computed(() => [
|
||||
{
|
||||
second: formatItem(state.second),
|
||||
minute: formatItem(state.minute),
|
||||
hour: formatItem(state.hour),
|
||||
day: formatItem(state.day),
|
||||
month: formatItem(state.month),
|
||||
week: formatItem(state.week),
|
||||
},
|
||||
]);
|
||||
|
||||
const copyCron = () => {
|
||||
// if (navigator.clipboard) {
|
||||
// navigator.clipboard.writeText(cronResult.value).then(() => {
|
||||
// ElMessage.success('Cron 表达式已复制');
|
||||
// });
|
||||
// } else {
|
||||
// ElMessage.warning('浏览器不支持剪贴板 API');
|
||||
// }
|
||||
emit('useCron', cronResult.value);
|
||||
};
|
||||
const nextTimes = ref<any[]>([]);
|
||||
function getNextTimes() {
|
||||
api
|
||||
.get('/api/v1/sysJob/getNextTimes', {
|
||||
params: {
|
||||
cronExpression: cronResult.value,
|
||||
},
|
||||
})
|
||||
.then((res: any) => {
|
||||
nextTimes.value = res.errorCode === 0 ? res.data : [];
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cron-generator">
|
||||
<ElCard class="box-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ $t('cron.cronExpressionGenerator') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTabs v-model="activeTab" type="border-card">
|
||||
<!-- 秒 -->
|
||||
<ElTabPane :label="$t('common.Second')" name="second">
|
||||
<CrontabPane
|
||||
v-model="state.second"
|
||||
:min="0"
|
||||
:max="59"
|
||||
:label="$t('common.Second')"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 分 -->
|
||||
<ElTabPane :label="$t('common.Min')" name="minute">
|
||||
<CrontabPane
|
||||
v-model="state.minute"
|
||||
:min="0"
|
||||
:max="59"
|
||||
:label="$t('common.Min')"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 时 -->
|
||||
<ElTabPane :label="$t('common.Hour')" name="hour">
|
||||
<CrontabPane
|
||||
v-model="state.hour"
|
||||
:min="0"
|
||||
:max="23"
|
||||
:label="$t('common.Hour')"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 日 -->
|
||||
<ElTabPane :label="$t('common.Day')" name="day">
|
||||
<CrontabPane
|
||||
v-model="state.day"
|
||||
:min="1"
|
||||
:max="31"
|
||||
:label="$t('common.Day')"
|
||||
week-mode-check
|
||||
@change="handleDayChange"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 月 -->
|
||||
<ElTabPane :label="$t('common.Month')" name="month">
|
||||
<CrontabPane
|
||||
v-model="state.month"
|
||||
:min="1"
|
||||
:max="12"
|
||||
:label="$t('common.Month')"
|
||||
/>
|
||||
</ElTabPane>
|
||||
|
||||
<!-- 周 -->
|
||||
<ElTabPane :label="$t('common.Week')" name="week">
|
||||
<CrontabPane
|
||||
v-model="state.week"
|
||||
:min="1"
|
||||
:max="7"
|
||||
:label="$t('common.Week')"
|
||||
:alias-map="weekAlias"
|
||||
day-mode-check
|
||||
@change="handleWeekChange"
|
||||
/>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
|
||||
<!-- 结果展示区域 -->
|
||||
<div class="result-area">
|
||||
<ElDivider content-position="left">
|
||||
{{ $t('cron.GenerateResult') }}
|
||||
</ElDivider>
|
||||
<div class="result-row">
|
||||
<ElInput
|
||||
v-model="cronResult"
|
||||
readonly
|
||||
:placeholder="$t('cron.CronExpression')"
|
||||
>
|
||||
<template #prepend>{{ $t('cron.CronExpression') }}</template>
|
||||
</ElInput>
|
||||
<ElButton type="primary" @click="copyCron" style="margin-left: 10px">
|
||||
{{ $t('cron.UseThisValue') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
type="primary"
|
||||
@click="getNextTimes"
|
||||
style="margin-left: 10px"
|
||||
>
|
||||
{{ $t('cron.CheckLast5ExecutionTimes') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="preview-table">
|
||||
<ElTable
|
||||
:data="resultTableData"
|
||||
border
|
||||
style="width: 100%"
|
||||
size="small"
|
||||
>
|
||||
<ElTableColumn prop="second" :label="$t('common.Second')" />
|
||||
<ElTableColumn prop="minute" :label="$t('common.Min')" />
|
||||
<ElTableColumn prop="hour" :label="$t('common.Hour')" />
|
||||
<ElTableColumn prop="day" :label="$t('common.Day')" />
|
||||
<ElTableColumn prop="month" :label="$t('common.Month')" />
|
||||
<ElTableColumn prop="week" :label="$t('common.Week')" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<ElDivider content-position="left">
|
||||
{{ $t('cron.Last5ExecutionTimes') }}
|
||||
</ElDivider>
|
||||
<div v-for="(item, idx) in nextTimes" :key="idx">
|
||||
{{ item }}
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cron-generator {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.result-area {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: flex;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
44
easyflow-ui-admin/app/src/components/cron/CronPicker.vue
Normal file
44
easyflow-ui-admin/app/src/components/cron/CronPicker.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { ElButton, ElDialog } from 'element-plus';
|
||||
|
||||
import CronGenerator from './CronGenerator.vue';
|
||||
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
// 定义 emit 事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
// 使用本地变量来管理对话框显示
|
||||
const showCron = ref(false);
|
||||
function useCron(value: string) {
|
||||
emit('update:modelValue', value);
|
||||
showCron.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElDialog
|
||||
draggable
|
||||
title="Cron"
|
||||
v-model="showCron"
|
||||
width="60%"
|
||||
append-to-body
|
||||
>
|
||||
<CronGenerator @use-cron="useCron" />
|
||||
</ElDialog>
|
||||
<ElButton class="mt-2" @click="showCron = true">
|
||||
{{ $t('cron.ClickGenerate') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
174
easyflow-ui-admin/app/src/components/cron/CronTabPane.vue
Normal file
174
easyflow-ui-admin/app/src/components/cron/CronTabPane.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import {
|
||||
ElInputNumber,
|
||||
ElOption,
|
||||
ElRadio,
|
||||
ElRadioGroup,
|
||||
ElSelect,
|
||||
} from 'element-plus';
|
||||
// 定义接口,方便父组件引用(如果使用 TS)
|
||||
export interface CronItemState {
|
||||
type: 'every' | 'loop' | 'none' | 'range' | 'specific';
|
||||
rangeStart: number;
|
||||
rangeEnd: number;
|
||||
loopStart: number;
|
||||
loopStep: number;
|
||||
specificList: number[];
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as () => CronItemState,
|
||||
required: true,
|
||||
},
|
||||
label: { type: String, default: '' },
|
||||
min: { type: Number, default: 0 },
|
||||
max: { type: Number, default: 59 },
|
||||
aliasMap: {
|
||||
type: Object as () => Record<number, string>,
|
||||
default: () => ({}),
|
||||
}, // 用于周的转换
|
||||
weekModeCheck: { type: Boolean, default: false }, // 是否是日Tab
|
||||
dayModeCheck: { type: Boolean, default: false }, // 是否是周Tab
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
// 计算属性处理单选框的 v-model
|
||||
const radioType = computed({
|
||||
get: () => props.modelValue.type,
|
||||
set: (val) => {
|
||||
// 更新 type 时,触发 update
|
||||
emit('update:modelValue', { ...props.modelValue, type: val });
|
||||
emit('change', val);
|
||||
},
|
||||
});
|
||||
|
||||
// 通用更新函数
|
||||
const updateVal = (key: keyof CronItemState, val: any) => {
|
||||
emit('update:modelValue', { ...props.modelValue, [key]: val });
|
||||
// 这里是否触发 change 取决于你的需求,通常数值改变不需要触发 tab 互斥检查,所以这里不一定非要 emit('change')
|
||||
// 但为了保险起见,可以保留
|
||||
};
|
||||
|
||||
// 生成下拉选项
|
||||
const specificOptions = computed(() => {
|
||||
const options = [];
|
||||
for (let i = props.min; i <= props.max; i++) {
|
||||
const label = props.aliasMap[i] ? `${props.aliasMap[i]} (${i})` : `${i}`;
|
||||
options.push({ value: i, label });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crontab-pane">
|
||||
<ElRadioGroup class="cron-radio-group" v-model="radioType">
|
||||
<!-- 1. 每 xxx -->
|
||||
<div class="radio-line">
|
||||
<ElRadio value="every">{{ $t('cron.Per') }}{{ label }} (*)</ElRadio>
|
||||
</div>
|
||||
|
||||
<!-- 2. 不指定 (?) - 仅用于日和周 -->
|
||||
<div class="radio-line" v-if="weekModeCheck || dayModeCheck">
|
||||
<ElRadio value="none">{{ $t('cron.NotSpecified') }} (?)</ElRadio>
|
||||
</div>
|
||||
|
||||
<!-- 3. 周期 (Loop) -->
|
||||
<div class="radio-line">
|
||||
<ElRadio value="loop">{{ $t('cron.Cycle') }}</ElRadio>
|
||||
<span class="text">{{ $t('cron.From') }}</span>
|
||||
<ElInputNumber
|
||||
:model-value="modelValue.loopStart"
|
||||
@update:model-value="(v) => updateVal('loopStart', v)"
|
||||
:min="min"
|
||||
:max="max"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="text">{{ label }}{{ $t('cron.StartPer') }}</span>
|
||||
<ElInputNumber
|
||||
:model-value="modelValue.loopStep"
|
||||
@update:model-value="(v) => updateVal('loopStep', v)"
|
||||
:min="1"
|
||||
:max="max"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="text">{{ label }}{{ $t('cron.ExecuteOnce') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 4. 区间 (Range) -->
|
||||
<div class="radio-line">
|
||||
<ElRadio value="range">{{ $t('cron.Rang') }}</ElRadio>
|
||||
<span class="text">{{ $t('cron.From') }}</span>
|
||||
<ElInputNumber
|
||||
:model-value="modelValue.rangeStart"
|
||||
@update:model-value="(v) => updateVal('rangeStart', v)"
|
||||
:min="min"
|
||||
:max="max"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="text">{{ $t('cron.To') }}</span>
|
||||
<ElInputNumber
|
||||
:model-value="modelValue.rangeEnd"
|
||||
@update:model-value="(v) => updateVal('rangeEnd', v)"
|
||||
:min="min"
|
||||
:max="max"
|
||||
size="small"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="text">{{ label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 5. 指定 (Specific) -->
|
||||
<div class="radio-line">
|
||||
<ElRadio value="specific">{{ $t('cron.Specify') }}</ElRadio>
|
||||
<ElSelect
|
||||
:model-value="modelValue.specificList"
|
||||
@update:model-value="(v) => updateVal('specificList', v)"
|
||||
multiple
|
||||
:placeholder="$t('dictSelect.placeholder')"
|
||||
style="width: 100%; min-width: 200px; margin-left: 10px"
|
||||
size="small"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in specificOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</ElRadioGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cron-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radio-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ElInputNumber) {
|
||||
width: 100px;
|
||||
}
|
||||
</style>
|
||||
190
easyflow-ui-admin/app/src/components/dict/DictSelect.vue
Normal file
190
easyflow-ui-admin/app/src/components/dict/DictSelect.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { ElMessage, ElOption, ElSelect } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
// 字典项接口
|
||||
interface DictItem {
|
||||
value: number | string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: Array<number | string> | null | number | string | undefined;
|
||||
dictCode: string; // 字典编码
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
disabled?: boolean;
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
showCode?: boolean; // 是否显示字典编码前缀
|
||||
immediate?: boolean; // 是否立即加载
|
||||
extraOptions?: DictItem[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(
|
||||
e: 'update:modelValue',
|
||||
value: Array<number | string> | null | number | string,
|
||||
): void;
|
||||
(
|
||||
e: 'change',
|
||||
value: Array<number | string> | null | number | string,
|
||||
dictItem?: DictItem | DictItem[],
|
||||
): void;
|
||||
(e: 'blur'): void;
|
||||
(e: 'dictLoaded', options: DictItem[]): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: undefined,
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
disabled: false,
|
||||
multiple: false,
|
||||
collapseTags: false,
|
||||
collapseTagsTooltip: false,
|
||||
showCode: false,
|
||||
immediate: true,
|
||||
extraOptions: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
// 使用计算属性处理placeholder
|
||||
const placeholderText = computed(() => {
|
||||
// 如果父组件传入了placeholder,优先使用
|
||||
if (props.placeholder !== undefined) {
|
||||
return props.placeholder;
|
||||
}
|
||||
// 否则使用默认的国际化文本
|
||||
return $t('dictSelect.placeholder');
|
||||
});
|
||||
// 响应式数据
|
||||
const dictOptions = ref<DictItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadedCodes = ref<Set<string>>(new Set()); // 已加载的字典编码缓存
|
||||
|
||||
// 处理值变化
|
||||
const handleChange = (
|
||||
value: Array<number | string> | null | number | string,
|
||||
) => {
|
||||
emit('update:modelValue', value);
|
||||
|
||||
// 找到对应的字典项
|
||||
const selectedItems: DictItem | DictItem[] | undefined =
|
||||
props.multiple && Array.isArray(value)
|
||||
? (value
|
||||
.map((v) => dictOptions.value.find((item) => item.value === v))
|
||||
.filter(Boolean) as DictItem[])
|
||||
: dictOptions.value.find((item) => item.value === value);
|
||||
|
||||
emit('change', value, selectedItems);
|
||||
};
|
||||
|
||||
// 处理失去焦点
|
||||
const handleBlur = () => {
|
||||
emit('blur');
|
||||
};
|
||||
|
||||
// 获取字典数据
|
||||
const fetchDictData = async (code: string) => {
|
||||
// 如果已经加载过,直接返回缓存
|
||||
if (loadedCodes.value.has(code)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里调用你的后端API
|
||||
const data = await getDictListByCode(code);
|
||||
// extraOptions 放最前面
|
||||
dictOptions.value = [...props.extraOptions, ...data];
|
||||
loadedCodes.value.add(code);
|
||||
emit('dictLoaded', data);
|
||||
} catch (error) {
|
||||
console.error(`${$t('dictSelect.getError')}: ${code}`, error);
|
||||
ElMessage.error(`${$t('dictSelect.getError')}: ${code}`);
|
||||
dictOptions.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟后端API调用 - 实际项目中替换为你的真实API
|
||||
const getDictListByCode = async (code: string): Promise<DictItem[]> => {
|
||||
const requestPromise = api.get(`/api/v1/dict/items/${code}`);
|
||||
const dictData = await requestPromise;
|
||||
return dictData.data;
|
||||
};
|
||||
|
||||
// 重新加载字典
|
||||
const reloadDict = () => {
|
||||
if (props.dictCode) {
|
||||
fetchDictData(props.dictCode);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听字典编码变化
|
||||
watch(
|
||||
() => props.dictCode,
|
||||
(newCode) => {
|
||||
if (newCode) {
|
||||
fetchDictData(newCode);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 组件挂载时加载字典
|
||||
onMounted(() => {
|
||||
if (props.immediate && props.dictCode) {
|
||||
fetchDictData(props.dictCode);
|
||||
}
|
||||
});
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
reloadDict,
|
||||
getOptions: () => dictOptions.value,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
:model-value="modelValue"
|
||||
@update:model-value="handleChange"
|
||||
@blur="handleBlur"
|
||||
:placeholder="placeholderText"
|
||||
:clearable="clearable"
|
||||
:filterable="filterable"
|
||||
:disabled="disabled || loading"
|
||||
:loading="loading"
|
||||
:multiple="multiple"
|
||||
:collapse-tags="collapseTags"
|
||||
:collapse-tags-tooltip="collapseTagsTooltip"
|
||||
>
|
||||
<ElOption
|
||||
v-for="item in dictOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
:disabled="item.disabled"
|
||||
/>
|
||||
<template #prefix v-if="showCode && dictCode">
|
||||
<span class="dict-select__prefix">{{ dictCode }}</span>
|
||||
</template>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dict-select__prefix {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
} from 'element-plus';
|
||||
|
||||
import { hasPermission } from '#/api/common/hasPermission.ts';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 按钮配置数组
|
||||
buttons: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
validator: (value) => {
|
||||
return value.every((button) => {
|
||||
return (
|
||||
typeof button.text === 'string' &&
|
||||
(button.key || typeof button.key === 'string')
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
// 最大显示按钮数量(不包括下拉菜单)
|
||||
maxVisibleButtons: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
// 搜索框占位符
|
||||
searchPlaceholder: {
|
||||
type: String,
|
||||
default: $t('common.searchPlaceholder'),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'button-click', 'buttonClick']);
|
||||
|
||||
// 搜索值
|
||||
const searchValue = ref('');
|
||||
|
||||
// 计算显示的按钮
|
||||
const visibleButtons = computed(() => {
|
||||
return props.buttons.slice(0, props.maxVisibleButtons);
|
||||
});
|
||||
|
||||
// 计算下拉菜单中的按钮
|
||||
const dropdownButtons = computed(() => {
|
||||
const dropdownButtonsTemp = props.buttons.slice(props.maxVisibleButtons);
|
||||
if (dropdownButtonsTemp.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return dropdownButtonsTemp.value.filter((action) => {
|
||||
return hasPermission([action.permission]);
|
||||
});
|
||||
});
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
emit('search', searchValue.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchValue.value = '';
|
||||
emit('search', '');
|
||||
};
|
||||
|
||||
// 处理按钮点击
|
||||
const handleButtonClick = (button) => {
|
||||
emit('buttonClick', {
|
||||
type: 'button',
|
||||
key: button.key,
|
||||
button,
|
||||
data: button.data,
|
||||
});
|
||||
};
|
||||
|
||||
// 处理下拉菜单点击
|
||||
const handleDropdownClick = (button) => {
|
||||
emit('buttonClick', {
|
||||
type: 'dropdown',
|
||||
key: button.key,
|
||||
button,
|
||||
data: button.data,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-header">
|
||||
<!-- 左侧搜索区域 -->
|
||||
<div class="header-left">
|
||||
<div class="search-container">
|
||||
<div>
|
||||
<ElInput
|
||||
v-model="searchValue"
|
||||
:placeholder="$t('common.searchPlaceholder')"
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ElButton type="primary" auto-insert-space @click="handleSearch">
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div>
|
||||
<ElButton auto-insert-space @click="handleReset">
|
||||
{{ $t('button.reset') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧按钮区域 -->
|
||||
<div class="header-right">
|
||||
<!-- 显示的按钮(最多3个) -->
|
||||
<template
|
||||
v-for="(button, index) in visibleButtons"
|
||||
:key="button.key || index"
|
||||
>
|
||||
<ElButton
|
||||
:type="button.type || 'default'"
|
||||
:icon="button.icon"
|
||||
:disabled="button.disabled"
|
||||
v-access:code="button.permission"
|
||||
@click="handleButtonClick(button)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<!-- 下拉菜单(隐藏的按钮) -->
|
||||
<ElDropdown
|
||||
v-if="dropdownButtons.length > 0"
|
||||
@command="handleDropdownClick"
|
||||
>
|
||||
<ElButton>
|
||||
{{ $t('button.more')
|
||||
}}<ElIcon class="el-icon--right"><ArrowDown /></ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="button in dropdownButtons"
|
||||
:key="button.key"
|
||||
:command="button"
|
||||
:disabled="button.disabled"
|
||||
>
|
||||
<ElIcon v-if="button.icon">
|
||||
<component :is="button.icon" />
|
||||
</ElIcon>
|
||||
<span style="margin-left: 8px">{{ button.text }}</span>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.custom-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="props.className"
|
||||
width="13.6333333px"
|
||||
height="13.6333333px"
|
||||
viewBox="0 0 13.6333333 13.6333333"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.66166667,10.1666667 C7.66166667,11.55014 8.78319337,12.6716667 10.1666667,12.6716667 C11.55014,12.6716667 12.6716667,11.55014 12.6716667,10.1666667 C12.6716667,8.78319337 11.55014,7.66166667 10.1666667,7.66166667 L8.04,7.66166667 C7.82966667,7.66166667 7.662,7.82966667 7.662,8.03966667 L7.662,10.1666667 L7.66166667,10.1666667 Z M10.1666667,6.99999866 C11.9155684,6.99999866 13.3333333,8.41776496 13.3333333,10.1666667 C13.3333333,11.9155684 11.9155684,13.3333333 10.1666667,13.3333333 C8.41776496,13.3333333 6.99999807,11.9155684 6.99999807,10.1666667 L6.99999807,8.04 C6.99946799,7.76406988 7.10882245,7.49928316 7.3039036,7.30413947 C7.49898474,7.10899578 7.7637364,6.99955644 8.03966667,6.99999866 L10.1666667,6.99999866 L10.1666667,6.99999866 Z M6.33333469,10.1666667 C6.33333469,11.9155684 4.91556838,13.3333333 3.16666667,13.3333333 C1.41776496,13.3333333 0,11.9155684 0,10.1666667 C0,8.41776496 1.41776496,6.99999866 3.16666667,6.99999866 L5.29366667,6.99999866 C5.56953908,6.99955669 5.83424002,7.10895032 6.02931152,7.30402182 C6.22438303,7.49909332 6.33377666,7.76379426 6.33333469,8.03966667 L6.33333469,10.1666667 Z M3.16666667,7.66166667 C1.78319337,7.66166667 0.661666672,8.78319337 0.661666672,10.1666667 C0.661666672,11.55014 1.78319337,12.6716667 3.16666667,12.6716667 C4.55013997,12.6716667 5.67166667,11.55014 5.67166667,10.1666667 L5.67166667,8.04 C5.67166667,7.83 5.504,7.662 5.29366667,7.662 L3.16666667,7.662 L3.16666667,7.66166667 Z M6.99999866,3.16633334 C7.00018409,1.41747504 8.41803055,-0.000122713956 10.1668889,7.79661757e-09 C11.9157472,0.000122729549 13.3333333,1.41791945 13.3333333,3.16677777 C13.3333333,4.91563609 11.915525,6.33333528 10.1666667,6.33333528 L8.04,6.33333528 C7.76406988,6.33386536 7.49928316,6.22451089 7.30413947,6.02942974 C7.10899578,5.8343486 6.99955644,5.56959695 6.99999866,5.29366667 L6.99999866,3.16666667 L6.99999866,3.16633334 Z M10.1666667,5.67166667 C11.55014,5.67166667 12.6716667,4.55013997 12.6716667,3.16666667 C12.6716667,1.78319337 11.55014,0.661666672 10.1666667,0.661666672 C8.78319337,0.661666672 7.66166667,1.78319337 7.66166667,3.16666667 L7.66166667,5.29366667 C7.66166667,5.50366667 7.82933334,5.67166667 8.03966667,5.67166667 L10.1666667,5.67166667 L10.1666667,5.67166667 Z M5.67166667,3.16666667 C5.67166667,1.78319337 4.55013997,0.661666672 3.16666667,0.661666672 C1.78319337,0.661666672 0.661666672,1.78319337 0.661666672,3.16666667 C0.661666672,4.55013997 1.78319337,5.67166667 3.16666667,5.67166667 L5.29366667,5.67166667 C5.504,5.67166667 5.67166667,5.50366667 5.67166667,5.29366667 L5.67166667,3.16666667 Z M3.16666667,6.33333469 C1.41776496,6.33333469 0,4.91556838 0,3.16666667 C0,1.41776496 1.41776496,0 3.16666667,0 C4.91556838,0 6.33333469,1.41776496 6.33333469,3.16666667 L6.33333469,5.29366667 C6.33377666,5.56953908 6.22438303,5.83424002 6.02931152,6.02931152 C5.83424002,6.22438303 5.56953908,6.33377666 5.29366667,6.33333469 L3.16666667,6.33333469 Z"
|
||||
id="形状"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
18
easyflow-ui-admin/app/src/components/icons/DesignIcon.vue
Normal file
18
easyflow-ui-admin/app/src/components/icons/DesignIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
t="1765503751020"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="9797"
|
||||
width="1em"
|
||||
height="1em"
|
||||
>
|
||||
<path
|
||||
d="M827.712 341.312H452.288a25.6 25.6 0 0 1-25.6-25.6V256H320c-29.44 0-54.592 10.432-75.456 31.232a102.784 102.784 0 0 0-31.232 75.456c0 29.44 10.432 54.592 31.232 75.392A102.784 102.784 0 0 0 320 469.312h384c53.056 0 98.24 18.816 135.744 56.256A184.96 184.96 0 0 1 896 661.312c0 53.056-18.752 98.304-56.256 135.808a184.96 184.96 0 0 1-135.744 56.192H554.688v59.776a25.6 25.6 0 0 1-25.6 25.6H153.6a25.6 25.6 0 0 1-25.6-25.6v-204.8a25.6 25.6 0 0 1 25.6-25.6h375.488a25.6 25.6 0 0 1 25.6 25.6V768H704c29.44 0 54.592-10.432 75.456-31.232a102.912 102.912 0 0 0 31.232-75.456c0-29.44-10.432-54.592-31.232-75.392A102.848 102.848 0 0 0 704 554.688H320a185.088 185.088 0 0 1-135.744-56.256A184.96 184.96 0 0 1 128 362.688c0-53.056 18.752-98.304 56.256-135.808A184.96 184.96 0 0 1 320 170.688h106.688v-59.776a25.6 25.6 0 0 1 25.6-25.6h375.424a25.6 25.6 0 0 1 25.6 25.6v204.8a25.6 25.6 0 0 1-25.6 25.6zM537.6 256h204.8a25.6 25.6 0 0 0 25.6-25.6v-34.112a25.6 25.6 0 0 0-25.6-25.6H537.6a25.6 25.6 0 0 0-25.6 25.6V230.4a25.6 25.6 0 0 0 25.6 25.6z m-68.288 571.712V793.6a25.6 25.6 0 0 0-25.6-25.6h-204.8a25.6 25.6 0 0 0-25.6 25.6v34.112a25.6 25.6 0 0 0 25.6 25.6h204.8a25.6 25.6 0 0 0 25.6-25.6z"
|
||||
p-id="9798"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
33
easyflow-ui-admin/app/src/components/icons/EditIcon.vue
Normal file
33
easyflow-ui-admin/app/src/components/icons/EditIcon.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="props.className"
|
||||
width="18px"
|
||||
height="18px"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="16" height="16" />
|
||||
<path
|
||||
d="M8.93,2.138 C9.12713347,1.95017863 9.4383122,1.95462404 9.63,2.148 L12.355,4.8965 C12.4522665,4.99447053 12.5046974,5.12829734 12.499868,5.26626717 C12.4950385,5.404237 12.4333787,5.53407024 12.3295,5.625 L5.7635,11.376 C5.66962616,11.4583382 5.54834097,11.5025716 5.4235,11.4999997 L2.9895,11.448 C2.71746669,11.442286 2.49999999,11.2200933 2.49999999,10.948 L2.49999999,8.476 C2.49999999,8.33921115 2.55599094,8.20838431 2.655,8.114 L8.93,2.138 Z M13.341,10.25 C13.5196328,10.2534833 13.682838,10.3520015 13.7691377,10.5084437 C13.8554375,10.664886 13.8517208,10.8554849 13.7593877,11.0084438 C13.6670547,11.1614026 13.5001328,11.2534833 13.3215,11.25 L9.574,11.177 C9.39536721,11.1734273 9.23220968,11.0748266 9.1459873,10.9183397 C9.05976491,10.7618529 9.06357689,10.571254 9.1559873,10.4183397 C9.24839771,10.2654255 9.41536721,10.1734273 9.594,10.177 L13.341,10.25 Z M9.265,3.2 L3.5,8.69 L3.5,10.4585 L5.25,10.496 L11.2705,5.223 L9.265,3.2 Z M13.3165,12.677 C13.5926424,12.6749289 13.8181789,12.8971076 13.8202644,13.17325 C13.8223211,13.4493924 13.6001424,13.6749289 13.324,13.677 L3.504,13.75 C3.22785763,13.7520711 3.00232108,13.5298924 3.00023565,13.25375 C2.99817894,12.9776076 3.22035763,12.7520711 3.4965,12.75 L13.3165,12.677 Z"
|
||||
id="形状"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title>wand</title>
|
||||
<defs>
|
||||
<linearGradient
|
||||
x1="99.8250786%"
|
||||
y1="6.53996342e-13%"
|
||||
x2="12.639287%"
|
||||
y2="77.6796996%"
|
||||
id="linearGradient-1"
|
||||
>
|
||||
<stop stop-color="#F17E47" offset="0%" />
|
||||
<stop stop-color="#D85ABF" offset="49.9043925%" />
|
||||
<stop stop-color="#717AFF" offset="100%" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="设置bot" transform="translate(-444, -80)" fill-rule="nonzero">
|
||||
<g id="编组-5备份-2" transform="translate(96, 64)">
|
||||
<g id="编组-3" transform="translate(336, 10)">
|
||||
<g id="wand" transform="translate(12, 6)">
|
||||
<rect
|
||||
id="矩形"
|
||||
fill="#000000"
|
||||
opacity="0"
|
||||
x="0"
|
||||
y="0"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<path
|
||||
d="M7.06,7.81 C7.28982243,8.20717228 7.64510124,8.51672214 8.07,8.69 L3.89,13.75 C3.77849314,13.8884057 3.61587696,13.9759881 3.43895054,13.9929279 C3.26202413,14.0098676 3.08574352,13.9547328 2.95,13.84 C2.67186371,13.6019746 2.63208206,13.1864774 2.86,12.9 Z M10.0751606,10.342259 C10.1007529,10.3593205 10.1142973,10.3895436 10.11,10.42 L10.05,11 C10.0349786,11.0209157 10.0349786,11.0490843 10.05,11.07 L10.5,11.46 C10.5414214,11.4627614 10.5727614,11.4985786 10.57,11.54 C10.5672386,11.5814214 10.5314214,11.6127614 10.49,11.61 L9.92,11.73 L9.86,11.73 L9.63,12.27 C9.61591049,12.2954653 9.58910316,12.3112702 9.56,12.3112702 C9.53089684,12.3112702 9.50408951,12.2954653 9.49,12.27 L9.2,11.75 C9.18101383,11.7409941 9.15898617,11.7409941 9.14,11.75 L8.55,11.69 C8.52147194,11.6857655 8.49743138,11.6665036 8.4870782,11.6395853 C8.47672503,11.612667 8.48166207,11.5822599 8.5,11.56 L8.9,11.13 C8.90944584,11.1076226 8.90944584,11.0823774 8.9,11.06 L8.77,10.48 C8.76794796,10.4523802 8.78032765,10.4256638 8.80272638,10.4093738 C8.82512511,10.3930838 8.85435607,10.3895379 8.88,10.4 L9.42,10.64 C9.44091574,10.6550214 9.46908426,10.6550214 9.49,10.64 L9.99,10.34 C10.0164608,10.3243193 10.0495684,10.3251975 10.0751606,10.342259 Z M11.6310879,2.06789708 C11.723319,2.12580967 11.773603,2.23194717 11.76,2.34 L11.52,4.48 C11.5119641,4.57445213 11.5490415,4.66714566 11.62,4.73 L13.24,6.14 C13.3130482,6.21823611 13.3351022,6.33127196 13.2968207,6.4312292 C13.2585392,6.53118644 13.1666211,6.6005734 13.06,6.61 L11,7 C10.9100357,7.02152566 10.835738,7.0846787 10.8,7.17 L9.96,9.17 C9.91461323,9.27007429 9.81488549,9.33434966 9.705,9.33434966 C9.59511451,9.33434966 9.49538677,9.27007429 9.45,9.17 L8.34,7.35 C8.29373222,7.26570734 8.2061264,7.21238206 8.11,7.21 L6,7 C5.89344307,6.98628907 5.8031975,6.91479202 5.76547441,6.8141971 C5.72775131,6.71360218 5.74873279,6.60039499 5.82,6.52 L7.23,5 C7.31119031,4.92297534 7.33871717,4.80500308 7.3,4.7 L6.82,2.61 C6.79547628,2.50301623 6.8334527,2.39135455 6.91811751,2.32150608 C7.00278233,2.2516576 7.11962606,2.23559266 7.22,2.28 L9.22,3.17 C9.30451125,3.21445166 9.40548875,3.21445166 9.49,3.17 L11.33,2.07 C11.4214132,2.01080476 11.5388567,2.00998449 11.6310879,2.06789708 Z M4.38174625,4.39636653 C4.40885368,4.41513321 4.42353002,4.44721982 4.42,4.48 L4.35,5.09 C4.33924996,5.11209516 4.33924996,5.13790484 4.35,5.16 L4.82,5.57 C4.84386802,5.58382847 4.86007635,5.60783875 4.8639774,5.63514609 C4.86787845,5.66245342 4.85904132,5.69004162 4.84,5.71 L4.23,5.83 L4.17,5.83 L3.93,6.4 C3.91332529,6.42513807 3.88516568,6.44025063 3.855,6.44025063 C3.82483432,6.44025063 3.79667471,6.42513807 3.78,6.4 L3.47,5.86 C3.45075739,5.85221598 3.42924261,5.85221598 3.41,5.86 L2.79,5.8 C2.75945815,5.7951621 2.73353862,5.77498342 2.72135759,5.74656103 C2.70917657,5.71813864 2.71243996,5.68545299 2.73,5.66 L3.15,5.21 C3.16429062,5.18524791 3.16429062,5.15475209 3.15,5.13 L3,4.55 C2.99439094,4.51822247 3.00622377,4.48587394 3.03101332,4.46521598 C3.05580287,4.44455803 3.08975488,4.43875251 3.12,4.45 L3.68,4.71 C3.70475209,4.72429062 3.73524791,4.72429062 3.76,4.71 L4.29,4.39 C4.31943968,4.37515728 4.35463882,4.37759984 4.38174625,4.39636653 Z"
|
||||
id="形状结合"
|
||||
fill="url(#linearGradient-1)"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
40
easyflow-ui-admin/app/src/components/icons/ManageIcon.vue
Normal file
40
easyflow-ui-admin/app/src/components/icons/ManageIcon.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
width="16px"
|
||||
height="16px"
|
||||
:class="props.className"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-483, -587)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<g transform="translate(468, 579)">
|
||||
<g transform="translate(15, 8)">
|
||||
<rect opacity="0" x="0" y="0" width="16" height="16" />
|
||||
<path
|
||||
d="M2.08296875,4.633625 L13.9143281,4.633625 C14.2234219,4.633625 14.4760625,4.38098438 14.4760625,4.07190625 C14.4760625,3.7628125 14.2261094,3.51015625 13.9143438,3.51015625 L2.08296875,3.51015625 C1.773875,3.51015625 1.52123437,3.7628125 1.52123437,4.07189063 C1.52123437,4.38098438 1.77389062,4.633625 2.08295313,4.633625 L2.08296875,4.633625 Z M13.9143281,7.43960938 L2.08298438,7.43960938 C1.77389063,7.43960938 1.52125,7.69226563 1.52125,8.00134375 C1.52125,8.3104375 1.77390625,8.56307813 2.08296875,8.56307813 L13.9143281,8.56307813 C14.2234219,8.56307813 14.4760625,8.31042188 14.4760625,8.00134375 C14.4760625,7.69225 14.2234063,7.439625 13.9143437,7.439625 L13.9143281,7.43960938 Z M13.9143281,11.366375 L2.08298438,11.366375 C1.77389063,11.366375 1.52125,11.6163281 1.52125,11.9280938 C1.52125,12.2398594 1.77390625,12.4898438 2.08296875,12.4898438 L13.9143281,12.4898438 C14.2234219,12.4898438 14.4760625,12.239875 14.4760625,11.9281094 C14.4760625,11.6163438 14.2234063,11.366375 13.9143437,11.366375 L13.9143281,11.366375 Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
46
easyflow-ui-admin/app/src/components/icons/PluginIcon.vue
Normal file
46
easyflow-ui-admin/app/src/components/icons/PluginIcon.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:class="props.className"
|
||||
width="18px"
|
||||
height="18px"
|
||||
viewBox="0 0 18 18"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>编组 12</title>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="currentColor"
|
||||
>
|
||||
<g id="知识库" transform="translate(-25, -161)">
|
||||
<g id="编组-12" transform="translate(24, 160)">
|
||||
<rect id="矩形" x="0" y="0" width="20" height="20" />
|
||||
<path
|
||||
d="M16.4912888,10.7980951 C15.7969114,10.7980951 15.3264751,11.0844769 15.3264751,11.0844769 C14.9748359,11.2984761 14.687007,11.1435874 14.687007,10.7402321 L14.687007,6.42579246 C14.687007,6.0224371 14.3475878,5.6924189 13.9327401,5.6924189 L8.1353671,5.6924189 C7.72051942,5.6924189 7.59855437,5.4390385 7.86435829,5.12933468 C7.86435829,5.12933468 8.43699845,4.46211097 8.43699845,3.61543104 C8.43699845,2.17097789 7.23265922,1 5.74698006,1 C4.26137547,1 3.05703726,2.17097789 3.05703726,3.61543104 C3.05703726,4.46211097 3.62960182,5.12933468 3.62960182,5.12933468 C3.89540472,5.4390385 3.77344069,5.6924189 3.35859301,5.6924189 L1.75426685,5.6924189 C1.33942018,5.6924189 1,6.0224371 1,6.42579246 L1,9.6803585 C1,10.0837149 1.29439047,10.2495305 1.65425121,10.0487328 C1.65425121,10.0487328 1.86348477,9.93197883 2.39124538,9.93197883 C3.60735002,9.93197883 4.59317781,10.8904256 4.59317781,12.0728439 C4.59317781,13.2552633 3.60735002,14.2137826 2.39124538,14.2137826 C1.86348477,14.2137826 1.65425121,14.097103 1.65425121,14.097103 C1.29439047,13.8965249 1,14.0621945 1,14.4655509 L1,18.2666264 C1,18.6699818 1.33942018,19 1.75426685,19 L4.66566218,19 C5.08050986,19 5.24682528,18.7161113 5.03517879,18.3690793 C5.03517879,18.3690793 4.90536911,18.1563257 4.90536911,17.6363653 C4.90536911,16.4705942 5.87716669,15.525935 7.07584745,15.525935 C8.27460483,15.525935 9.24640241,16.4708137 9.24640241,17.6363653 C9.24640241,18.1563257 9.11659274,18.3690793 9.11659274,18.3690793 C8.90502081,18.7158908 9.07133623,19 9.4861829,19 L13.9327401,19 C14.3475878,19 14.687007,18.6699818 14.687007,18.2666264 L14.687007,16.0863791 C14.687007,15.6830238 14.9746101,15.5281351 15.3263239,15.7422803 C15.3263239,15.7422803 15.7969114,16.0285896 16.4912888,16.0285896 C17.9767432,16.0285896 18.8728875,14.0825824 18.8728875,14.0825824 C19.0423708,13.7144286 19.0423708,13.1120347 18.8728875,12.7438818 C18.8728875,12.7441013 17.9767432,10.7980951 16.4912888,10.7980951 Z"
|
||||
id="Fill-257"
|
||||
fill="#ACB7C6"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
:class="props.className"
|
||||
>
|
||||
<path
|
||||
d="M9.702 14L1.335 14C0.586 14 0 13.393 0 12.69L0 10.676C0 10.516 0.065 10.388 0.195 10.292C0.326 10.196 0.488 10.164 0.619 10.196C0.781 10.228 0.944 10.26 1.074 10.26C2.019 10.26 2.8 9.493 2.8 8.566C2.8 7.639 2.019 6.872 1.074 6.872C0.944 6.872 0.781 6.904 0.619 6.936C0.456 6.968 0.326 6.936 0.195 6.84C0.065 6.744 0 6.616 0 6.457L0 4.443C0 3.708 0.619 3.132 1.335 3.132L2.833 3.132C2.8 2.973 2.8 2.813 2.8 2.653C2.8 1.183 4.005 0 5.502 0C7 0 8.205 1.183 8.205 2.653C8.205 2.813 8.205 2.973 8.172 3.132L9.67 3.132C10.419 3.132 11.005 3.74 11.005 4.443L11.005 5.913L11.298 5.913C12.795 5.913 14 7.096 14 8.566C14 10.037 12.795 11.219 11.298 11.219L11.005 11.219L11.005 12.69C11.07 13.393 10.451 14 9.702 14ZM0.977 11.219L0.977 12.658C0.977 12.849 1.14 13.009 1.335 13.009L9.702 13.009C9.898 13.009 10.06 12.849 10.06 12.658L10.06 10.58C10.06 10.42 10.126 10.26 10.256 10.196C10.386 10.1 10.549 10.1 10.712 10.132C10.907 10.196 11.102 10.26 11.298 10.26C12.242 10.26 13.023 9.493 13.023 8.566C13.023 7.639 12.242 6.872 11.298 6.872C11.102 6.872 10.907 6.904 10.712 7C10.549 7.064 10.386 7.032 10.256 6.936C10.126 6.84 10.06 6.712 10.06 6.553L10.06 4.443C10.06 4.251 9.898 4.091 9.702 4.091L7.521 4.091C7.358 4.091 7.195 3.995 7.098 3.868C7 3.74 7 3.548 7.065 3.42C7.195 3.164 7.228 2.941 7.228 2.685C7.228 1.758 6.447 0.991 5.502 0.991C4.558 0.991 3.777 1.758 3.777 2.685C3.777 2.941 3.842 3.196 3.94 3.42C4.005 3.58 4.005 3.74 3.907 3.868C3.81 3.995 3.647 4.091 3.484 4.091L1.335 4.091C1.14 4.091 0.977 4.251 0.977 4.443L0.977 5.881C2.507 5.817 3.777 7.032 3.777 8.534C3.777 10.005 2.572 11.187 1.074 11.187C1.042 11.219 1.009 11.219 0.977 11.219Z"
|
||||
fill="currentColor"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
129
easyflow-ui-admin/app/src/components/icons/RecordingIcon.vue
Normal file
129
easyflow-ui-admin/app/src/components/icons/RecordingIcon.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<svg
|
||||
data-v-dd795da4=""
|
||||
data-v-5179693f=""
|
||||
class="loading-svg"
|
||||
color="currentColor"
|
||||
viewBox="0 0 1000 1000"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title data-v-dd795da4="">Speech Recording</title>
|
||||
<rect
|
||||
data-v-dd795da4=""
|
||||
fill="currentColor"
|
||||
rx="70"
|
||||
ry="70"
|
||||
height="250"
|
||||
width="140"
|
||||
x="0"
|
||||
y="375"
|
||||
>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="height"
|
||||
values="250; 500; 250"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="y"
|
||||
values="375; 250; 375"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
data-v-dd795da4=""
|
||||
fill="currentColor"
|
||||
rx="70"
|
||||
ry="70"
|
||||
height="250"
|
||||
width="140"
|
||||
x="286.66666666666663"
|
||||
y="375"
|
||||
>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="height"
|
||||
values="250; 500; 250"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="y"
|
||||
values="375; 250; 375"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
data-v-dd795da4=""
|
||||
fill="currentColor"
|
||||
rx="70"
|
||||
ry="70"
|
||||
height="250"
|
||||
width="140"
|
||||
x="573.3333333333333"
|
||||
y="375"
|
||||
>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="height"
|
||||
values="250; 500; 250"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="y"
|
||||
values="375; 250; 375"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.4s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
<rect
|
||||
data-v-dd795da4=""
|
||||
fill="currentColor"
|
||||
rx="70"
|
||||
ry="70"
|
||||
height="250"
|
||||
width="140"
|
||||
x="859.9999999999999"
|
||||
y="375"
|
||||
>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="height"
|
||||
values="250; 500; 250"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.6000000000000001s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animate
|
||||
data-v-dd795da4=""
|
||||
attributeName="y"
|
||||
values="375; 250; 375"
|
||||
keyTimes="0; 0.5; 1"
|
||||
dur="0.8s"
|
||||
begin="0.6000000000000001s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</rect>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<svg
|
||||
width="30px"
|
||||
height="30px"
|
||||
viewBox="0 0 30 30"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="运行Bot备份-2" transform="translate(-1185, -791)">
|
||||
<g id="编组-11" transform="translate(1185, 791)">
|
||||
<g id="矩形-2" fill="#0066FF">
|
||||
<rect id="矩形" x="0" y="0" width="30" height="30" rx="15" />
|
||||
</g>
|
||||
<g
|
||||
id="发送-实色"
|
||||
transform="translate(9, 10)"
|
||||
fill="#FFFFFF"
|
||||
fill-rule="nonzero"
|
||||
>
|
||||
<path
|
||||
d="M11.6691674,0.239363769 C11.3947722,0.00461392042 11.0102658,-0.0631322672 10.6652017,0.0613280807 L0.636969787,3.6945732 C0.263984798,3.83008097 0.0191472071,4.15148685 0.00107884118,4.53436129 C-0.0170055712,4.91722033 0.193315985,5.25912086 0.553174932,5.42769394 L2.11910533,6.16191613 C2.15033183,6.17608698 2.1782688,6.19658162 2.20291623,6.21863029 C2.36557966,6.62039919 3.00806572,8.19439408 3.29396632,8.76316664 C3.43856139,9.05307662 3.70475688,9.25631519 3.95123121,9.34455602 C3.93479959,9.34297122 3.91673122,9.3398324 3.89701007,9.3366782 C3.94466819,9.35400325 3.99396306,9.36818948 4.04489467,9.37763671 C4.37023758,9.44065929 4.7037963,9.34455602 4.93712855,9.1192534 L5.52045114,8.55992807 C5.62396715,8.46065521 5.78664663,8.43860655 5.91481008,8.50792214 L8.42721154,9.87552427 C8.57836963,9.9574413 8.74597539,10 8.91358115,10 C9.04503412,10 9.17813989,9.97476635 9.3030138,9.92279119 C9.58893045,9.80462387 9.79596247,9.57142805 9.87154152,9.28153345 L11.9698789,1.1705315 C12.0602529,0.830200382 11.9435948,0.472544209 11.6691835,0.239363769 L11.6691674,0.239363769 Z M2.82731788,6.02483896 L8.20373185,3.07537951 L5.16882405,6.05635025 C5.11952919,6.10518658 5.08337641,6.16033134 5.05872897,6.2249387 C5.05707618,6.22809291 5.05707618,6.22966232 5.05543944,6.23281652 C5.04886037,6.25172637 4.44910616,7.94859682 4.09418954,8.71589971 C4.02188398,8.67020219 3.93479959,8.59616298 3.88386798,8.4937513 C3.62096202,7.96435246 3.00804967,6.46599696 2.82731788,6.02483896 L2.82731788,6.02483896 Z"
|
||||
id="形状"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
38
easyflow-ui-admin/app/src/components/icons/SendIcon.vue
Normal file
38
easyflow-ui-admin/app/src/components/icons/SendIcon.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<svg
|
||||
width="30px"
|
||||
height="30px"
|
||||
viewBox="0 0 30 30"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="设置bot" transform="translate(-1370, -830)">
|
||||
<g id="编组-7" transform="translate(992, 64)">
|
||||
<g id="编组-11" transform="translate(378, 766)">
|
||||
<g id="矩形-2" fill="#DFDFDF">
|
||||
<rect id="矩形" x="0" y="0" width="30" height="30" rx="15" />
|
||||
</g>
|
||||
<g
|
||||
id="发送-实色"
|
||||
transform="translate(9, 10)"
|
||||
fill="#FFFFFF"
|
||||
fill-rule="nonzero"
|
||||
>
|
||||
<path
|
||||
d="M11.6691674,0.239363769 C11.3947722,0.00461392042 11.0102658,-0.0631322672 10.6652017,0.0613280807 L0.636969787,3.6945732 C0.263984798,3.83008097 0.0191472071,4.15148685 0.00107884118,4.53436129 C-0.0170055712,4.91722033 0.193315985,5.25912086 0.553174932,5.42769394 L2.11910533,6.16191613 C2.15033183,6.17608698 2.1782688,6.19658162 2.20291623,6.21863029 C2.36557966,6.62039919 3.00806572,8.19439408 3.29396632,8.76316664 C3.43856139,9.05307662 3.70475688,9.25631519 3.95123121,9.34455602 C3.93479959,9.34297122 3.91673122,9.3398324 3.89701007,9.3366782 C3.94466819,9.35400325 3.99396306,9.36818948 4.04489467,9.37763671 C4.37023758,9.44065929 4.7037963,9.34455602 4.93712855,9.1192534 L5.52045114,8.55992807 C5.62396715,8.46065521 5.78664663,8.43860655 5.91481008,8.50792214 L8.42721154,9.87552427 C8.57836963,9.9574413 8.74597539,10 8.91358115,10 C9.04503412,10 9.17813989,9.97476635 9.3030138,9.92279119 C9.58893045,9.80462387 9.79596247,9.57142805 9.87154152,9.28153345 L11.9698789,1.1705315 C12.0602529,0.830200382 11.9435948,0.472544209 11.6691835,0.239363769 L11.6691674,0.239363769 Z M2.82731788,6.02483896 L8.20373185,3.07537951 L5.16882405,6.05635025 C5.11952919,6.10518658 5.08337641,6.16033134 5.05872897,6.2249387 C5.05707618,6.22809291 5.05707618,6.22966232 5.05543944,6.23281652 C5.04886037,6.25172637 4.44910616,7.94859682 4.09418954,8.71589971 C4.02188398,8.67020219 3.93479959,8.59616298 3.88386798,8.4937513 C3.62096202,7.96435246 3.00804967,6.46599696 2.82731788,6.02483896 L2.82731788,6.02483896 Z"
|
||||
id="形状"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
47
easyflow-ui-admin/app/src/components/icons/SendingIcon.vue
Normal file
47
easyflow-ui-admin/app/src/components/icons/SendingIcon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<svg
|
||||
data-v-cabe7c8e=""
|
||||
viewBox="0 0 1000 1000"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
class="loading-svg"
|
||||
>
|
||||
<title>Loading</title>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height="250"
|
||||
rx="24"
|
||||
ry="24"
|
||||
width="250"
|
||||
x="375"
|
||||
y="375"
|
||||
/>
|
||||
<circle
|
||||
cx="500"
|
||||
cy="500"
|
||||
fill="none"
|
||||
r="450"
|
||||
stroke="currentColor"
|
||||
stroke-width="100"
|
||||
opacity="0.45"
|
||||
/>
|
||||
<circle
|
||||
cx="500"
|
||||
cy="500"
|
||||
fill="none"
|
||||
r="450"
|
||||
stroke="currentColor"
|
||||
stroke-width="100"
|
||||
stroke-dasharray="600 9999999"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="1s"
|
||||
from="0 500 500"
|
||||
repeatCount="indefinite"
|
||||
to="360 500 500"
|
||||
type="rotate"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
</template>
|
||||
42
easyflow-ui-admin/app/src/components/json/ShowJson.vue
Normal file
42
easyflow-ui-admin/app/src/components/json/ShowJson.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty } from 'element-plus';
|
||||
import { JsonViewer } from 'vue3-json-viewer';
|
||||
|
||||
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
|
||||
|
||||
defineProps({
|
||||
value: {
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const themeMode = ref(preferences.theme.mode);
|
||||
watch(
|
||||
() => preferences.theme.mode,
|
||||
(newVal) => {
|
||||
themeMode.value = newVal;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="res-container">
|
||||
<JsonViewer v-if="value" :value="value" copyable :theme="themeMode" />
|
||||
<ElEmpty
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.res-container {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
}
|
||||
</style>
|
||||
201
easyflow-ui-admin/app/src/components/page/CardList.vue
Normal file
201
easyflow-ui-admin/app/src/components/page/CardList.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { useAccess } from '@easyflow/access';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElText,
|
||||
} from 'element-plus';
|
||||
|
||||
export interface ActionButton {
|
||||
icon: any;
|
||||
text: string;
|
||||
className: string;
|
||||
permission: string;
|
||||
onClick: (row: any) => void;
|
||||
}
|
||||
|
||||
export interface CardListProps {
|
||||
iconField?: string;
|
||||
titleField?: string;
|
||||
descField?: string;
|
||||
actions?: ActionButton[];
|
||||
defaultIcon: any;
|
||||
data: any[];
|
||||
}
|
||||
const props = withDefaults(defineProps<CardListProps>(), {
|
||||
iconField: 'icon',
|
||||
titleField: 'title',
|
||||
descField: 'description',
|
||||
actions: () => [],
|
||||
});
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const filterActions = computed(() => {
|
||||
return props.actions.filter((action) => {
|
||||
return hasAccessByCodes([action.permission]);
|
||||
});
|
||||
});
|
||||
const visibleActions = computed(() => {
|
||||
return filterActions.value.length <= 3
|
||||
? filterActions.value
|
||||
: filterActions.value.slice(0, 3);
|
||||
});
|
||||
const hiddenActions = computed(() => {
|
||||
return filterActions.value.length > 3 ? filterActions.value.slice(3) : [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-grid">
|
||||
<ElCard
|
||||
v-for="(item, index) in props.data"
|
||||
:key="index"
|
||||
shadow="hover"
|
||||
footer-class="foot-c"
|
||||
:style="{
|
||||
'--el-box-shadow-light': '0px 2px 12px 0px rgb(100 121 153 10%)',
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElAvatar
|
||||
class="shrink-0"
|
||||
:src="item[iconField] || defaultIcon"
|
||||
:size="36"
|
||||
/>
|
||||
<ElText truncated size="large" class="font-medium">
|
||||
{{ item[titleField] }}
|
||||
</ElText>
|
||||
</div>
|
||||
<ElText line-clamp="2" class="item-desc w-full">
|
||||
{{ item[titleField] }}
|
||||
</ElText>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div :class="visibleActions.length > 2 ? 'footer-div' : ''">
|
||||
<template v-for="(action, idx) in visibleActions" :key="idx">
|
||||
<ElButton
|
||||
:icon="typeof action.icon === 'string' ? undefined : action.icon"
|
||||
size="small"
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
link
|
||||
@click="action.onClick(item)"
|
||||
>
|
||||
<template v-if="typeof action.icon === 'string'" #icon>
|
||||
<IconifyIcon :icon="action.icon" />
|
||||
</template>
|
||||
{{ action.text }}
|
||||
</ElButton>
|
||||
<ElDivider
|
||||
v-if="
|
||||
filterActions.length <= 3
|
||||
? idx < filterActions.length - 1
|
||||
: true
|
||||
"
|
||||
direction="vertical"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ElDropdown v-if="hiddenActions.length > 0" trigger="click">
|
||||
<ElButton
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
:icon="MoreFilled"
|
||||
link
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="(action, idx) in hiddenActions"
|
||||
:key="idx"
|
||||
@click="action.onClick(item)"
|
||||
>
|
||||
<template #default>
|
||||
<div :class="`${action.className} handle-div`">
|
||||
<ElIcon v-if="action.icon">
|
||||
<component :is="action.icon" />
|
||||
</ElIcon>
|
||||
{{ action.text }}
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-card__footer) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.footer-div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 20px;
|
||||
background-color: hsl(var(--background-deep));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.handle-div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
min-width: max(100%, 600px); /* 确保至少显示2个卡片 */
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
height: 40px;
|
||||
font-size: clamp(8px, 1vw, 14px);
|
||||
line-height: 20px;
|
||||
color: #75808d;
|
||||
}
|
||||
|
||||
.item-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
129
easyflow-ui-admin/app/src/components/page/PageData.vue
Normal file
129
easyflow-ui-admin/app/src/components/page/PageData.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
|
||||
import { ElEmpty, ElPagination } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
interface PageDataProps {
|
||||
pageUrl: string;
|
||||
pageSize?: number;
|
||||
pageSizes?: number[];
|
||||
extraQueryParams?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<PageDataProps>(), {
|
||||
pageSize: 10,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
extraQueryParams: () => ({}),
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const pageList = ref([]);
|
||||
const loading = ref(false);
|
||||
const queryParams = ref({});
|
||||
|
||||
const pageInfo = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: props.pageSize,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
// 模拟 API 调用 - 这里需要根据你的实际 API 调用方式调整
|
||||
const doGet = async (params: any) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 这里替换为你的实际 API 调用
|
||||
// 例如:return await api.get(props.pageUrl, { params })
|
||||
const response = await api.get(`${props.pageUrl}`, {
|
||||
params,
|
||||
});
|
||||
const data = await response.data;
|
||||
return { data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取页面数据
|
||||
const getPageList = async () => {
|
||||
try {
|
||||
const res = await doGet({
|
||||
pageNumber: pageInfo.pageNumber,
|
||||
pageSize: pageInfo.pageSize,
|
||||
...props.extraQueryParams,
|
||||
...queryParams.value,
|
||||
});
|
||||
pageList.value = res.data?.records || [];
|
||||
pageInfo.total = res.data?.totalRow || 0;
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
pageList.value = [];
|
||||
pageInfo.total = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 分页事件处理
|
||||
const handleSizeChange = (newSize: number) => {
|
||||
pageInfo.pageSize = newSize;
|
||||
pageInfo.pageNumber = 1; // 重置到第一页
|
||||
};
|
||||
|
||||
const handleCurrentChange = (newPage: number) => {
|
||||
pageInfo.pageNumber = newPage;
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法 (替代 useImperativeHandle)
|
||||
const setQuery = (newQueryParams: string) => {
|
||||
pageInfo.pageNumber = 1;
|
||||
pageInfo.pageSize = props.pageSize;
|
||||
queryParams.value = newQueryParams;
|
||||
getPageList();
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
setQuery,
|
||||
});
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
[() => pageInfo.pageNumber, () => pageInfo.pageSize],
|
||||
() => {
|
||||
getPageList();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
getPageList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-data-container" v-loading="loading">
|
||||
<template v-if="pageList.length > 0">
|
||||
<div>
|
||||
<slot :page-list="pageList"></slot>
|
||||
</div>
|
||||
<div v-if="pageInfo.total > pageInfo.pageSize" class="mx-auto mt-8 w-fit">
|
||||
<ElPagination
|
||||
v-model:current-page="pageInfo.pageNumber"
|
||||
v-model:page-size="pageInfo.pageSize"
|
||||
:total="pageInfo.total"
|
||||
:page-sizes="pageSizes"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
236
easyflow-ui-admin/app/src/components/page/PageSide.vue
Normal file
236
easyflow-ui-admin/app/src/components/page/PageSide.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<script setup lang="ts" generic="T extends { icon?: any; [key: string]: any }">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
menus: T[];
|
||||
labelKey: string;
|
||||
valueKey: string;
|
||||
iconSize?: number;
|
||||
controlBtns?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: (_: T) => void;
|
||||
type?: any;
|
||||
}[];
|
||||
footerButton?: {
|
||||
icon?: any;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
defaultSelected?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
iconSize: 16,
|
||||
controlBtns: () => [],
|
||||
footerButton: undefined,
|
||||
defaultSelected: '',
|
||||
});
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', item: T): void;
|
||||
}>();
|
||||
const panelWidth = ref(225);
|
||||
const selected = ref<string>(props.defaultSelected ?? '');
|
||||
const hoverId = ref<string>();
|
||||
|
||||
const handleChange = (item: T) => {
|
||||
selected.value = item[props.valueKey];
|
||||
emits('change', item);
|
||||
};
|
||||
// 监听 defaultSelected 的变化
|
||||
watch(
|
||||
() => props.defaultSelected,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
selected.value = newVal;
|
||||
const item = props.menus.find((menu) => menu[props.valueKey] === newVal);
|
||||
if (item) {
|
||||
emits('change', item);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const isComponent = (icon: any) => {
|
||||
return typeof icon !== 'string';
|
||||
};
|
||||
const isSvgString = (icon: any) => {
|
||||
if (typeof icon !== 'string') return false;
|
||||
// 简单判断:是否包含 SVG 根标签
|
||||
return icon.trim().startsWith('<svg') && icon.trim().endsWith('</svg>');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full w-[225px] 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 overflow-hidden">
|
||||
<h3 v-if="title && title.length > 0" class="text-base font-medium">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div
|
||||
v-for="item in menus"
|
||||
:key="item[valueKey]"
|
||||
class="group list-item"
|
||||
:class="{
|
||||
selected: selected === item[valueKey],
|
||||
}"
|
||||
@click="handleChange(item)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="item.icon"
|
||||
class="ml-[-3px] flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
v-if="isSvgString(item.icon)"
|
||||
v-html="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
class="svg-container"
|
||||
></div>
|
||||
<img
|
||||
v-else-if="
|
||||
typeof item.icon === 'string' && !isComponent(item.icon)
|
||||
"
|
||||
:src="item.icon"
|
||||
:style="{
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
/>
|
||||
<ElIcon v-else>
|
||||
<component :is="item.icon as Component" v-bind="$attrs" />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div>
|
||||
{{ item[labelKey] }}
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="controlBtns.length > 0 && !['', '0'].includes(item[valueKey])"
|
||||
@click.stop
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || item.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
<ElIcon>
|
||||
<MoreFilled />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<div
|
||||
@mouseenter="handleMouseEvent(item.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="btn in controlBtns"
|
||||
:key="btn.label"
|
||||
@click="btn.onClick(item)"
|
||||
>
|
||||
<ElButton :type="btn.type" :icon="btn.icon" link>
|
||||
{{ btn.label }}
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<ElEmpty
|
||||
v-if="menus.length <= 0"
|
||||
:image="`/empty${preferences.theme.mode === 'dark' ? '-dark' : ''}.png`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElButton
|
||||
v-if="footerButton"
|
||||
@click="footerButton.onClick"
|
||||
:icon="footerButton.icon"
|
||||
plain
|
||||
>
|
||||
{{ footerButton.label }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.list-item:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.list-item.selected {
|
||||
color: hsl(var(--primary));
|
||||
background-color: hsl(var(--primary) / 15%);
|
||||
}
|
||||
|
||||
.list-item.selected:where(.dark, .dark *) {
|
||||
color: hsl(var(--accent-foreground));
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.svg-container :deep(svg) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
226
easyflow-ui-admin/app/src/components/tag/Tag.vue
Normal file
226
easyflow-ui-admin/app/src/components/tag/Tag.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
/** 背景颜色 */
|
||||
backgroundColor?: string;
|
||||
/** 文字颜色 */
|
||||
textColor?: string;
|
||||
/** 标签文本内容 */
|
||||
text: number | string;
|
||||
/** 标签尺寸 */
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
/** 是否可关闭 */
|
||||
closable?: boolean;
|
||||
/** 是否为圆形标签 */
|
||||
round?: boolean;
|
||||
/** 边框类型 */
|
||||
border?: 'dashed' | 'none' | 'solid';
|
||||
/** 边框颜色 */
|
||||
borderColor?: string;
|
||||
/** 主题类型 */
|
||||
type?: 'danger' | 'info' | 'primary' | 'success' | 'warning';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
backgroundColor: 'rgb(236, 245, 255)',
|
||||
textColor: '#409eff',
|
||||
size: 'medium',
|
||||
closable: false,
|
||||
round: false,
|
||||
border: 'solid',
|
||||
borderColor: '',
|
||||
type: 'primary',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [text: number | string];
|
||||
}>();
|
||||
|
||||
// 根据类型自动设置颜色
|
||||
const tagStyle = computed(() => {
|
||||
const style: Record<string, string> = {};
|
||||
|
||||
switch (props.type) {
|
||||
case 'danger': {
|
||||
style.backgroundColor = 'rgb(254, 240, 240)';
|
||||
style.color = '#f56c6c';
|
||||
style.borderColor = props.borderColor || '#f56c6c';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'info': {
|
||||
style.backgroundColor = 'rgb(244, 244, 245)';
|
||||
style.color = '#909399';
|
||||
style.borderColor = props.borderColor || '#909399';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'primary': {
|
||||
style.backgroundColor = 'rgb(236, 245, 255)';
|
||||
style.color = '#409eff';
|
||||
style.borderColor = props.borderColor || '#409eff';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'success': {
|
||||
style.backgroundColor = 'rgb(240, 249, 235)';
|
||||
style.color = '#67c23a';
|
||||
style.borderColor = props.borderColor || '#67c23a';
|
||||
|
||||
break;
|
||||
}
|
||||
case 'warning': {
|
||||
style.backgroundColor = 'rgb(253, 246, 236)';
|
||||
style.color = '#e6a23c';
|
||||
style.borderColor = props.borderColor || '#e6a23c';
|
||||
|
||||
break;
|
||||
}
|
||||
// No default
|
||||
}
|
||||
|
||||
// 自定义颜色优先级高于类型预设
|
||||
if (props.backgroundColor) {
|
||||
style.backgroundColor = props.backgroundColor;
|
||||
}
|
||||
if (props.textColor) {
|
||||
style.color = props.textColor;
|
||||
}
|
||||
if (props.borderColor) {
|
||||
style.borderColor = props.borderColor;
|
||||
}
|
||||
|
||||
return style;
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close', props.text);
|
||||
};
|
||||
|
||||
// 尺寸映射
|
||||
const sizeMap = {
|
||||
small: {
|
||||
fontSize: '12px',
|
||||
padding: '0 8px',
|
||||
height: '24px',
|
||||
},
|
||||
medium: {
|
||||
fontSize: '14px',
|
||||
padding: '0 12px',
|
||||
height: '32px',
|
||||
},
|
||||
large: {
|
||||
fontSize: '16px',
|
||||
padding: '0 16px',
|
||||
height: '40px',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tag"
|
||||
:class="[`tag--${size}`, { 'tag--round': round }, `tag--border-${border}`]"
|
||||
:style="[
|
||||
tagStyle,
|
||||
{
|
||||
fontSize: sizeMap[size].fontSize,
|
||||
padding: sizeMap[size].padding,
|
||||
height: sizeMap[size].height,
|
||||
lineHeight: sizeMap[size].height,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<span class="tag__content">
|
||||
<slot>{{ text }}</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="closable" class="tag__close" @click.stop="handleClose">
|
||||
<slot name="close-icon"> × </slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.tag--small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tag--medium {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tag--large {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tag--round {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.tag--border-solid {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.tag--border-dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.tag--border-none {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tag__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tag__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tag__close:hover {
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
/* 为可关闭标签调整内边距 */
|
||||
.tag:has(.tag__close) {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.tag:has(.tag__close).tag--small {
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.tag:has(.tag__close).tag--large {
|
||||
padding-right: 12px;
|
||||
}
|
||||
</style>
|
||||
262
easyflow-ui-admin/app/src/components/tree/Tree.vue
Normal file
262
easyflow-ui-admin/app/src/components/tree/Tree.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeV2Instance } from 'element-plus';
|
||||
|
||||
import { nextTick, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { ElMessage, ElTreeV2 } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 获取树数据的URL
|
||||
dataUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// 已选择的节点数组(支持双向绑定)
|
||||
modelValue: {
|
||||
type: Array<any>,
|
||||
default: () => [],
|
||||
},
|
||||
// 节点键名
|
||||
nodeKey: {
|
||||
type: String,
|
||||
default: 'id',
|
||||
},
|
||||
// 树形配置
|
||||
defaultProps: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
children: 'children',
|
||||
label: 'label',
|
||||
}),
|
||||
},
|
||||
// 是否默认展开所有节点
|
||||
defaultExpandAll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 是否严格遵循父子不互相关联
|
||||
checkStrictly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// 树的高度
|
||||
height: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
// 是否显示子节点数量
|
||||
showCount: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 是否显示已选择区域
|
||||
showSelected: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 加载提示文本
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: $t('message.loading'),
|
||||
},
|
||||
});
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'check']);
|
||||
|
||||
// 响应式数据
|
||||
const treeData = ref([]);
|
||||
const loading = ref(false);
|
||||
const treeRef = ref<TreeV2Instance>();
|
||||
const nodeMap = ref(new Map()); // 用于存储节点键到节点数据的映射
|
||||
|
||||
watch(
|
||||
[() => props.modelValue, () => treeData.value],
|
||||
([newVal, treeDataVal]) => {
|
||||
const value = newVal || [];
|
||||
if (treeDataVal && treeDataVal.length > 0) {
|
||||
nextTick(() => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// 过滤节点方法
|
||||
const filterNode = (value: any, data: any) => {
|
||||
if (!value) return true;
|
||||
return data[props.defaultProps.label]?.toString().includes(value);
|
||||
};
|
||||
|
||||
// 处理节点选择变化
|
||||
const handleCheck = (_: any, checkedInfo: any) => {
|
||||
const checkedKeys = checkedInfo.checkedKeys;
|
||||
emit('update:modelValue', checkedKeys);
|
||||
emit('change', checkedKeys);
|
||||
emit('check', {
|
||||
checkedNodes: checkedInfo.checkedNodes,
|
||||
checkedKeys,
|
||||
halfCheckedNodes: checkedInfo.halfCheckedNodes,
|
||||
halfCheckedKeys: checkedInfo.halfCheckedKeys,
|
||||
});
|
||||
};
|
||||
|
||||
// 构建节点映射
|
||||
const buildNodeMap = (nodes: any) => {
|
||||
nodes.forEach((node: any) => {
|
||||
nodeMap.value.set(node[props.nodeKey], node);
|
||||
if (node[props.defaultProps.children]) {
|
||||
buildNodeMap(node[props.defaultProps.children]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 获取树数据
|
||||
const fetchTreeData = async () => {
|
||||
if (!props.dataUrl) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get(props.dataUrl);
|
||||
|
||||
treeData.value = res.data;
|
||||
// 构建节点映射
|
||||
nodeMap.value.clear();
|
||||
buildNodeMap(res.data);
|
||||
|
||||
// 数据加载完成后,如果有选中值则设置
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
nextTick(() => {
|
||||
if (treeRef.value) {
|
||||
treeRef.value.setCheckedKeys(props.modelValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('get data error:', error);
|
||||
ElMessage.error($t('message.getDataError'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前选中的节点
|
||||
const getCheckedNodes = () => {
|
||||
return treeRef.value?.getCheckedNodes() || [];
|
||||
};
|
||||
|
||||
// 获取当前选中的叶子节点
|
||||
const getCheckedLeafNodes = () => {
|
||||
return treeRef.value?.getCheckedNodes(true) || [];
|
||||
};
|
||||
|
||||
// 获取半选中的节点
|
||||
const getHalfCheckedNodes = () => {
|
||||
return treeRef.value?.getHalfCheckedNodes() || [];
|
||||
};
|
||||
|
||||
// 清空选择
|
||||
const clearChecked = () => {
|
||||
treeRef.value?.setCheckedKeys([]);
|
||||
};
|
||||
|
||||
// 设置选中节点
|
||||
const setCheckedKeys = (keys: any) => {
|
||||
treeRef.value?.setCheckedKeys(keys);
|
||||
};
|
||||
|
||||
// 根据键值获取节点数据
|
||||
const getNodeByKey = (key: any) => {
|
||||
return nodeMap.value.get(key);
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
getCheckedNodes,
|
||||
getCheckedLeafNodes,
|
||||
getHalfCheckedNodes,
|
||||
clearChecked,
|
||||
setCheckedKeys,
|
||||
getNodeByKey,
|
||||
refresh: fetchTreeData,
|
||||
});
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchTreeData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tree-select">
|
||||
<div class="tree-header"></div>
|
||||
<div class="tree-wrapper">
|
||||
<ElTreeV2
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
:props="defaultProps"
|
||||
:node-key="nodeKey"
|
||||
:default-expand-all="defaultExpandAll"
|
||||
:filter-node-method="filterNode"
|
||||
:highlight-current="true"
|
||||
show-checkbox
|
||||
:check-strictly="checkStrictly"
|
||||
:height="height"
|
||||
@check="handleCheck"
|
||||
v-loading="loading"
|
||||
:element-loading-text="loadingText"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="tree-node">
|
||||
<span class="node-label">{{ $t(node.label) }}</span>
|
||||
<span
|
||||
v-if="showCount && data[defaultProps.children]"
|
||||
class="node-count"
|
||||
>
|
||||
({{ data[defaultProps.children].length }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ElTreeV2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-select {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.tree-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-count {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
158
easyflow-ui-admin/app/src/components/upload/ChatFileUploader.vue
Normal file
158
easyflow-ui-admin/app/src/components/upload/ChatFileUploader.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
import type { FilesCardProps } from 'vue-element-plus-x/types/FilesCard';
|
||||
|
||||
import { defineExpose, defineProps, nextTick, ref, watch } from 'vue';
|
||||
import { Attachments } from 'vue-element-plus-x';
|
||||
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
const props = defineProps({
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
default: '/api/v1/commons/upload',
|
||||
},
|
||||
externalFiles: {
|
||||
type: Array as () => File[],
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['deleteAll']);
|
||||
type SelfFilesCardProps = {
|
||||
fileSize: number;
|
||||
id?: number;
|
||||
name: string;
|
||||
uid: number | string;
|
||||
url: string; // 上传后的文件地址
|
||||
} & FilesCardProps;
|
||||
|
||||
const files = ref<SelfFilesCardProps[]>([]);
|
||||
/**
|
||||
* 上传前校验
|
||||
*/
|
||||
function handleBeforeUpload(file: File) {
|
||||
const maxSizeBytes = props.maxSize * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽上传处理
|
||||
*/
|
||||
async function handleUploadDrop(dropFiles: File[]) {
|
||||
if (dropFiles?.length) {
|
||||
if (dropFiles[0]?.type === '') {
|
||||
ElMessage.error('禁止上传文件夹!');
|
||||
return false;
|
||||
}
|
||||
for (const file of dropFiles) {
|
||||
if (handleBeforeUpload(file)) {
|
||||
await handleHttpRequest({ file });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义上传请求
|
||||
*/
|
||||
async function handleHttpRequest(options: { file: File }) {
|
||||
const { file } = options;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const res = await api.upload(props.action, { file }, {});
|
||||
|
||||
const fileUrl = res.data.path;
|
||||
const fileItem: SelfFilesCardProps = {
|
||||
id: files.value.length,
|
||||
uid: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
fileSize: file.size,
|
||||
url: fileUrl,
|
||||
showDelIcon: true,
|
||||
imgVariant: 'square',
|
||||
};
|
||||
|
||||
files.value.push(fileItem);
|
||||
} catch (error) {
|
||||
ElMessage.error(`${file.name} 上传失败`);
|
||||
console.error('上传失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传外部文件(父组件传递的文件数组)
|
||||
*/
|
||||
async function uploadExternalFiles(fileList: File[]) {
|
||||
if (fileList.length === 0) return;
|
||||
|
||||
for (const file of fileList) {
|
||||
if (handleBeforeUpload(file)) {
|
||||
await handleHttpRequest({ file });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件内部完成删除逻辑
|
||||
*/
|
||||
function handleDeleteCard(item: FilesCardProps) {
|
||||
const targetItem = item as SelfFilesCardProps;
|
||||
files.value = files.value.filter((file) => file.id !== targetItem.id);
|
||||
if (files.value.length === 0) {
|
||||
emit('deleteAll');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 暴露给父组件的核心方法:仅返回URL字符串数组
|
||||
* @returns string[] 纯文件地址列表
|
||||
*/
|
||||
function getFileList(): string[] {
|
||||
return files.value.map((file) => file.url);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.externalFiles,
|
||||
async (newFiles) => {
|
||||
if (newFiles.length > 0) {
|
||||
await nextTick();
|
||||
await uploadExternalFiles(newFiles);
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
getFileList,
|
||||
clearFiles() {
|
||||
files.value = [];
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display: flex; flex-direction: column; gap: 12px">
|
||||
<Attachments
|
||||
:http-request="handleHttpRequest"
|
||||
:items="files"
|
||||
drag
|
||||
:before-upload="handleBeforeUpload"
|
||||
:hide-upload="false"
|
||||
@upload-drop="handleUploadDrop"
|
||||
@delete-card="handleDeleteCard"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
461
easyflow-ui-admin/app/src/components/upload/Cropper.vue
Normal file
461
easyflow-ui-admin/app/src/components/upload/Cropper.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps, UploadRequestHandler } from 'element-plus';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { VueCropper } from 'vue-cropper';
|
||||
import 'vue-cropper/dist/index.css';
|
||||
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
|
||||
import { Delete, Plus, Refresh } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElIcon,
|
||||
ElImage,
|
||||
ElMessage,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
// 定义组件props
|
||||
interface Props {
|
||||
modelValue?: string; // 双向绑定的图片URL
|
||||
crop?: boolean; // 是否启用裁剪
|
||||
action?: string; // 上传地址
|
||||
headers?: Record<string, string>; // 上传请求头
|
||||
data?: Record<string, any>; // 上传额外数据
|
||||
cropConfig?: Partial<CropConfig>; // 裁剪配置
|
||||
limit?: number; // 文件大小限制(MB)
|
||||
accept?: string; // 文件类型
|
||||
}
|
||||
|
||||
interface CropConfig {
|
||||
title: string;
|
||||
outputSize: number;
|
||||
outputType: string;
|
||||
info: boolean;
|
||||
full: boolean;
|
||||
fixed: boolean;
|
||||
fixedNumber: [number, number];
|
||||
canMove: boolean;
|
||||
canMoveBox: boolean;
|
||||
fixedBox: boolean;
|
||||
original: boolean;
|
||||
autoCrop: boolean;
|
||||
autoCropWidth: number;
|
||||
autoCropHeight: number;
|
||||
centerBox: boolean;
|
||||
high: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
action: '/api/v1/commons/upload',
|
||||
crop: false,
|
||||
headers: () => ({}),
|
||||
data: () => ({}),
|
||||
cropConfig: () => ({}),
|
||||
limit: 5,
|
||||
accept: 'image/*',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
uploadError: [error: Error];
|
||||
uploadSuccess: [url: string];
|
||||
}>();
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const headers = computed(() => ({
|
||||
'easyflow-token': accessStore.accessToken,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...props.headers,
|
||||
}));
|
||||
|
||||
// 默认裁剪配置
|
||||
const defaultCropConfig: CropConfig = {
|
||||
title: $t('cropper.ImageCropping'),
|
||||
outputSize: 1,
|
||||
outputType: 'png',
|
||||
info: true,
|
||||
full: false,
|
||||
fixed: false,
|
||||
fixedNumber: [1, 1],
|
||||
canMove: false,
|
||||
canMoveBox: true,
|
||||
fixedBox: false,
|
||||
original: false,
|
||||
autoCrop: true,
|
||||
autoCropWidth: 200,
|
||||
autoCropHeight: 200,
|
||||
centerBox: true,
|
||||
high: true,
|
||||
};
|
||||
|
||||
// refs
|
||||
const uploadRef = ref<InstanceType<typeof ElUpload>>();
|
||||
const cropperRef = ref<InstanceType<typeof VueCropper>>();
|
||||
const showCropDialog = ref(false);
|
||||
const cropImageUrl = ref('');
|
||||
const uploading = ref(false);
|
||||
const currentFile = ref<File | null>(null);
|
||||
|
||||
// 合并裁剪配置
|
||||
const mergedCropConfig = computed(() => ({
|
||||
...defaultCropConfig,
|
||||
...props.cropConfig,
|
||||
}));
|
||||
|
||||
// 触发上传 - 修复:直接触发上传组件的点击事件
|
||||
const triggerUpload = () => {
|
||||
// 创建隐藏的input元素来触发文件选择
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = props.accept;
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
input.remove();
|
||||
});
|
||||
|
||||
document.body.append(input);
|
||||
input.click();
|
||||
};
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = (file: File) => {
|
||||
// 验证文件
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
ElMessage.error($t('cropper.message.onlyImage'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isLtLimit = file.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
||||
return;
|
||||
}
|
||||
|
||||
currentFile.value = file;
|
||||
|
||||
// 如果需要裁剪,显示裁剪对话框
|
||||
if (props.crop) {
|
||||
cropImageUrl.value = URL.createObjectURL(file);
|
||||
showCropDialog.value = true;
|
||||
} else {
|
||||
// 直接上传
|
||||
uploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传前验证
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
const isImage = rawFile.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
ElMessage.error($t('cropper.message.onlyImage'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果需要裁剪,显示裁剪对话框
|
||||
if (props.crop) {
|
||||
currentFile.value = rawFile;
|
||||
cropImageUrl.value = URL.createObjectURL(rawFile);
|
||||
showCropDialog.value = true;
|
||||
return false; // 阻止自动上传
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 统一上传方法
|
||||
const uploadFile = async (file: File) => {
|
||||
try {
|
||||
uploading.value = true;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
Object.entries(props.data).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = await api.post(props.action, formData, {
|
||||
headers: headers.value,
|
||||
});
|
||||
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(
|
||||
`${$t('cropper.message.uploadFailed')}: ${response.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = response.data.path;
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error($t('cropper.message.notUrl'));
|
||||
}
|
||||
|
||||
emit('update:modelValue', imageUrl);
|
||||
emit('uploadSuccess', imageUrl);
|
||||
|
||||
ElMessage.success($t('cropper.message.uploadSuccessful'));
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error($t('cropper.message.uploadFailed'));
|
||||
emit('uploadError', err);
|
||||
ElMessage.error(err.message);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
currentFile.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理上传
|
||||
const handleUpload: UploadRequestHandler = async (options) => {
|
||||
const { file, onSuccess } = options;
|
||||
await uploadFile(file);
|
||||
onSuccess({}); // 调用成功回调
|
||||
};
|
||||
|
||||
// 处理裁剪 - 修复:使用正确的裁剪逻辑
|
||||
const handleCrop = () => {
|
||||
if (!cropperRef.value) {
|
||||
ElMessage.error($t('cropper.message.notInitialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
ElMessage.error($t('cropper.message.cropFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading.value = true;
|
||||
|
||||
// 创建文件对象,保留原始文件名但使用裁剪后的内容
|
||||
const originalName = currentFile.value?.name || 'cropped-image';
|
||||
const fileExtension = originalName.split('.').pop() || 'png';
|
||||
const fileName = `cropped-${Date.now()}.${fileExtension}`;
|
||||
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
|
||||
await uploadFile(file);
|
||||
showCropDialog.value = false;
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error($t('cropper.message.uploadFailed'));
|
||||
emit('uploadError', err);
|
||||
ElMessage.error(err.message);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemove = () => {
|
||||
emit('update:modelValue', '');
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
};
|
||||
|
||||
// 清理URL对象
|
||||
watch(showCropDialog, (newVal) => {
|
||||
if (!newVal && cropImageUrl.value) {
|
||||
URL.revokeObjectURL(cropImageUrl.value);
|
||||
cropImageUrl.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-upload-container">
|
||||
<!-- 上传按钮 -->
|
||||
<div v-if="!modelValue" class="upload-area">
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
class="avatar-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="handleUpload"
|
||||
:accept="accept"
|
||||
>
|
||||
<ElIcon class="avatar-uploader-icon"><Plus /></ElIcon>
|
||||
</ElUpload>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<div v-else class="preview-area">
|
||||
<div class="preview-container">
|
||||
<ElImage
|
||||
:src="modelValue"
|
||||
:preview-src-list="[modelValue]"
|
||||
fit="cover"
|
||||
class="preview-image"
|
||||
:zoom-rate="1.2"
|
||||
:max-scale="7"
|
||||
:min-scale="0.2"
|
||||
hide-on-click-modal
|
||||
/>
|
||||
<div class="preview-actions">
|
||||
<ElButton @click="triggerUpload">
|
||||
<ElIcon><Refresh /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton @click="handleRemove">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 裁剪对话框 -->
|
||||
<ElDialog
|
||||
v-model="showCropDialog"
|
||||
:title="mergedCropConfig.title"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="cropper-container">
|
||||
<VueCropper
|
||||
ref="cropperRef"
|
||||
:img="cropImageUrl"
|
||||
:output-size="mergedCropConfig.outputSize"
|
||||
:output-type="mergedCropConfig.outputType"
|
||||
:info="mergedCropConfig.info"
|
||||
:full="mergedCropConfig.full"
|
||||
:fixed="mergedCropConfig.fixed"
|
||||
:fixed-number="mergedCropConfig.fixedNumber"
|
||||
:can-move="mergedCropConfig.canMove"
|
||||
:can-move-box="mergedCropConfig.canMoveBox"
|
||||
:fixed-box="mergedCropConfig.fixedBox"
|
||||
:original="mergedCropConfig.original"
|
||||
:auto-crop="mergedCropConfig.autoCrop"
|
||||
:auto-crop-width="mergedCropConfig.autoCropWidth"
|
||||
:auto-crop-height="mergedCropConfig.autoCropHeight"
|
||||
:center-box="mergedCropConfig.centerBox"
|
||||
:high="mergedCropConfig.high"
|
||||
mode="cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton @click="showCropDialog = false" :disabled="uploading">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleCrop" :loading="uploading">
|
||||
{{
|
||||
uploading ? $t('cropper.Uploading') : $t('cropper.ConfirmCrop')
|
||||
}}
|
||||
</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 样式保持不变 */
|
||||
.image-upload-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.avatar-uploader:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.edit-actions .el-button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
603
easyflow-ui-admin/app/src/components/upload/CropperMulti.vue
Normal file
603
easyflow-ui-admin/app/src/components/upload/CropperMulti.vue
Normal file
@@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps, UploadRequestHandler } from 'element-plus';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { VueCropper } from 'vue-cropper';
|
||||
import 'vue-cropper/dist/index.css';
|
||||
|
||||
import { $t } from '@easyflow/locales';
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
|
||||
import { Delete, Edit, Plus } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDialog,
|
||||
ElIcon,
|
||||
ElImage,
|
||||
ElMessage,
|
||||
ElTag,
|
||||
ElUpload,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
|
||||
// 定义组件props
|
||||
interface Props {
|
||||
modelValue?: string[]; // 双向绑定的图片URL数组
|
||||
crop?: boolean; // 是否启用裁剪
|
||||
action?: string; // 上传地址
|
||||
headers?: Record<string, string>; // 上传请求头
|
||||
data?: Record<string, any>; // 上传额外数据
|
||||
cropConfig?: Partial<CropConfig>; // 裁剪配置
|
||||
limit?: number; // 文件大小限制(MB)
|
||||
maxCount?: number; // 最大文件数量
|
||||
}
|
||||
|
||||
interface CropConfig {
|
||||
title: string;
|
||||
outputSize: number;
|
||||
outputType: string;
|
||||
info: boolean;
|
||||
full: boolean;
|
||||
fixed: boolean;
|
||||
fixedNumber: [number, number];
|
||||
canMove: boolean;
|
||||
canMoveBox: boolean;
|
||||
fixedBox: boolean;
|
||||
original: boolean;
|
||||
autoCrop: boolean;
|
||||
autoCropWidth: number;
|
||||
autoCropHeight: number;
|
||||
centerBox: boolean;
|
||||
high: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
id: string; // 唯一标识
|
||||
url: string; // 图片URL
|
||||
name?: string; // 文件名
|
||||
uploading?: boolean; // 上传状态
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
action: '/api/v1/commons/upload',
|
||||
crop: false,
|
||||
headers: () => ({}),
|
||||
data: () => ({}),
|
||||
cropConfig: () => ({}),
|
||||
limit: 5,
|
||||
maxCount: 5,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [url: string, fileList: string[]];
|
||||
'update:modelValue': [value: string[]];
|
||||
uploadError: [error: Error];
|
||||
uploadSuccess: [url: string, fileList: string[]];
|
||||
}>();
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const headers = computed(() => ({
|
||||
'easyflow-token': accessStore.accessToken,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...props.headers,
|
||||
}));
|
||||
|
||||
// 默认裁剪配置
|
||||
const defaultCropConfig: CropConfig = {
|
||||
title: $t('cropper.ImageCropping'),
|
||||
outputSize: 1,
|
||||
outputType: 'png',
|
||||
info: true,
|
||||
full: false,
|
||||
fixed: false,
|
||||
fixedNumber: [1, 1],
|
||||
canMove: false,
|
||||
canMoveBox: true,
|
||||
fixedBox: false,
|
||||
original: false,
|
||||
autoCrop: true,
|
||||
autoCropWidth: 200,
|
||||
autoCropHeight: 200,
|
||||
centerBox: true,
|
||||
high: true,
|
||||
};
|
||||
|
||||
// refs
|
||||
const uploadRef = ref<InstanceType<typeof ElUpload>>();
|
||||
const cropperRef = ref<InstanceType<typeof VueCropper>>();
|
||||
const showCropDialog = ref(false);
|
||||
const cropImageUrl = ref('');
|
||||
const uploading = ref(false);
|
||||
const currentFile = ref<File | null>(null);
|
||||
const currentCropIndex = ref<number>(-1); // 当前裁剪的文件索引,-1表示新增文件
|
||||
|
||||
// 文件列表
|
||||
const fileList = ref<FileItem[]>([]);
|
||||
|
||||
// 合并裁剪配置
|
||||
const mergedCropConfig = computed(() => ({
|
||||
...defaultCropConfig,
|
||||
...props.cropConfig,
|
||||
}));
|
||||
|
||||
// 从URL中提取文件名
|
||||
const getFileNameFromUrl = (url: string): string => {
|
||||
try {
|
||||
return url.split('/').pop() || 'image';
|
||||
} catch {
|
||||
return 'image';
|
||||
}
|
||||
};
|
||||
// 生成唯一ID
|
||||
const generateId = () => {
|
||||
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
||||
};
|
||||
// 从modelValue初始化文件列表
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(urls) => {
|
||||
if (
|
||||
JSON.stringify(urls) !==
|
||||
JSON.stringify(fileList.value.map((item) => item.url))
|
||||
) {
|
||||
fileList.value = urls.map((url) => ({
|
||||
id: generateId(),
|
||||
url,
|
||||
name: getFileNameFromUrl(url),
|
||||
}));
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
// 处理单个文件重新上传
|
||||
const triggerReupload = (index: number) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
currentCropIndex.value = index; // 标记为重新上传
|
||||
handleFileSelect(file, index);
|
||||
}
|
||||
input.remove();
|
||||
});
|
||||
|
||||
document.body.append(input);
|
||||
input.click();
|
||||
};
|
||||
|
||||
// 处理单个文件选择
|
||||
const handleFileSelect = (file: File, index: number) => {
|
||||
// 验证文件
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
ElMessage.error($t('cropper.message.onlyImage'));
|
||||
return;
|
||||
}
|
||||
|
||||
const isLtLimit = file.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
||||
return;
|
||||
}
|
||||
|
||||
currentFile.value = file;
|
||||
currentCropIndex.value = index;
|
||||
|
||||
// 如果需要裁剪,显示裁剪对话框
|
||||
if (props.crop) {
|
||||
cropImageUrl.value = URL.createObjectURL(file);
|
||||
showCropDialog.value = true;
|
||||
} else {
|
||||
// 直接上传
|
||||
uploadFile(file, index);
|
||||
}
|
||||
};
|
||||
|
||||
// 上传前验证
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
const isImage = rawFile.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
ElMessage.error($t('cropper.message.onlyImage'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error($t('cropper.message.imgSize', { limit: props.limit }));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileList.value.length >= props.maxCount) {
|
||||
ElMessage.error($t('cropper.message.fileCount', { count: props.maxCount }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果需要裁剪,显示裁剪对话框
|
||||
if (props.crop) {
|
||||
currentFile.value = rawFile;
|
||||
currentCropIndex.value = -1; // 新增文件
|
||||
cropImageUrl.value = URL.createObjectURL(rawFile);
|
||||
showCropDialog.value = true;
|
||||
return false; // 阻止自动上传
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 统一上传方法
|
||||
const uploadFile = async (file: File, index: number) => {
|
||||
try {
|
||||
// 如果是重新上传,标记为上传中状态
|
||||
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
|
||||
fileList.value[index].uploading = true;
|
||||
} else {
|
||||
uploading.value = true;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
Object.entries(props.data).forEach(([key, value]) => {
|
||||
formData.append(key, value);
|
||||
});
|
||||
|
||||
const response = await api.post(props.action, formData, {
|
||||
headers: headers.value,
|
||||
});
|
||||
|
||||
if (response.errorCode !== 0) {
|
||||
throw new Error(
|
||||
`${$t('cropper.message.uploadFailed')}: ${response.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const imageUrl = response.data.path;
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error($t('cropper.message.notUrl'));
|
||||
}
|
||||
|
||||
// 更新文件列表
|
||||
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
|
||||
// 重新上传,替换原有文件
|
||||
fileList.value[index].url = imageUrl;
|
||||
fileList.value[index].name = file.name;
|
||||
fileList.value[index].uploading = false;
|
||||
} else {
|
||||
// 新增文件
|
||||
fileList.value.push({
|
||||
id: generateId(),
|
||||
url: imageUrl,
|
||||
name: file.name,
|
||||
uploading: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新modelValue
|
||||
const urls = fileList.value.map((item) => item.url);
|
||||
emit('update:modelValue', urls);
|
||||
emit('uploadSuccess', imageUrl, urls);
|
||||
|
||||
ElMessage.success(
|
||||
index >= 0
|
||||
? $t('cropper.message.reuploadSuccessful')
|
||||
: $t('cropper.message.uploadSuccessful'),
|
||||
);
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error($t('cropper.message.uploadFailed'));
|
||||
|
||||
// 重置上传状态
|
||||
if (index >= 0 && index < fileList.value.length && fileList.value[index]) {
|
||||
fileList.value[index].uploading = false;
|
||||
}
|
||||
|
||||
emit('uploadError', err);
|
||||
ElMessage.error(err.message);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
currentFile.value = null;
|
||||
currentCropIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理上传
|
||||
const handleUpload: UploadRequestHandler = async (options) => {
|
||||
const { file, onSuccess } = options;
|
||||
await uploadFile(file, -1);
|
||||
onSuccess({});
|
||||
};
|
||||
|
||||
// 处理裁剪
|
||||
const handleCrop = () => {
|
||||
if (!cropperRef.value) {
|
||||
ElMessage.error($t('cropper.message.notInitialized'));
|
||||
return;
|
||||
}
|
||||
|
||||
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
ElMessage.error($t('cropper.message.cropFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading.value = true;
|
||||
|
||||
// 创建文件对象
|
||||
const originalName = currentFile.value?.name || 'cropped-image';
|
||||
const fileExtension = originalName.split('.').pop() || 'png';
|
||||
const fileName = `cropped-${Date.now()}.${fileExtension}`;
|
||||
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
|
||||
await uploadFile(file, currentCropIndex.value);
|
||||
showCropDialog.value = false;
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error($t('cropper.message.uploadFailed'));
|
||||
emit('uploadError', err);
|
||||
ElMessage.error(err.message);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemove = (index: number) => {
|
||||
const removedUrl = fileList.value[index]?.url;
|
||||
fileList.value.splice(index, 1);
|
||||
|
||||
const urls = fileList.value.map((item) => item.url);
|
||||
emit('update:modelValue', urls);
|
||||
emit('remove', removedUrl || '', urls);
|
||||
|
||||
ElMessage.success($t('message.deleteOkMessage'));
|
||||
};
|
||||
|
||||
// 清理URL对象
|
||||
watch(showCropDialog, (newVal) => {
|
||||
if (!newVal && cropImageUrl.value) {
|
||||
URL.revokeObjectURL(cropImageUrl.value);
|
||||
cropImageUrl.value = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="multi-image-upload-container">
|
||||
<!-- 文件列表展示 -->
|
||||
<div class="file-list">
|
||||
<div v-for="(file, index) in fileList" :key="file.id" class="file-item">
|
||||
<div class="preview-container">
|
||||
<ElImage
|
||||
:src="file.url"
|
||||
:preview-src-list="fileList.map((f) => f.url)"
|
||||
fit="cover"
|
||||
class="preview-image"
|
||||
:zoom-rate="1.2"
|
||||
:max-scale="7"
|
||||
:min-scale="0.2"
|
||||
hide-on-click-modal
|
||||
/>
|
||||
<div class="preview-actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
text
|
||||
:loading="file.uploading"
|
||||
@click="triggerReupload(index)"
|
||||
>
|
||||
<ElIcon><Edit /></ElIcon>
|
||||
{{
|
||||
file.uploading
|
||||
? $t('cropper.Uploading')
|
||||
: $t('cropper.Re-upload')
|
||||
}}
|
||||
</ElButton>
|
||||
<ElButton type="danger" text @click="handleRemove(index)">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<ElTag v-if="file.name" class="file-name" size="small">
|
||||
{{ file.name }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<div v-if="fileList.length < maxCount" class="upload-area file-item">
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
class="avatar-uploader"
|
||||
action="#"
|
||||
:show-file-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:http-request="handleUpload"
|
||||
accept="image/*"
|
||||
:multiple="true"
|
||||
>
|
||||
<ElIcon class="avatar-uploader-icon"><Plus /></ElIcon>
|
||||
<div class="upload-text">{{ $t('cropper.ClickToUpload') }}</div>
|
||||
<div class="upload-hint">
|
||||
{{ $t('cropper.message.fileCount', { count: maxCount }) }}
|
||||
</div>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 裁剪对话框 -->
|
||||
<ElDialog
|
||||
v-model="showCropDialog"
|
||||
:title="mergedCropConfig.title"
|
||||
width="800px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="cropper-container">
|
||||
<VueCropper
|
||||
ref="cropperRef"
|
||||
:img="cropImageUrl"
|
||||
:output-size="mergedCropConfig.outputSize"
|
||||
:output-type="mergedCropConfig.outputType"
|
||||
:info="mergedCropConfig.info"
|
||||
:full="mergedCropConfig.full"
|
||||
:fixed="mergedCropConfig.fixed"
|
||||
:fixed-number="mergedCropConfig.fixedNumber"
|
||||
:can-move="mergedCropConfig.canMove"
|
||||
:can-move-box="mergedCropConfig.canMoveBox"
|
||||
:fixed-box="mergedCropConfig.fixedBox"
|
||||
:original="mergedCropConfig.original"
|
||||
:auto-crop="mergedCropConfig.autoCrop"
|
||||
:auto-crop-width="mergedCropConfig.autoCropWidth"
|
||||
:auto-crop-height="mergedCropConfig.autoCropHeight"
|
||||
:center-box="mergedCropConfig.centerBox"
|
||||
:high="mergedCropConfig.high"
|
||||
mode="cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<ElButton @click="showCropDialog = false" :disabled="uploading">
|
||||
{{ $t('button.cancel') }}
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleCrop" :loading="uploading">
|
||||
{{
|
||||
uploading ? $t('cropper.Uploading') : $t('cropper.ClickToUpload')
|
||||
}}
|
||||
</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.multi-image-upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.preview-container:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 8px rgb(64 158 255 / 10%);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 140px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.avatar-uploader:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 10px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@easyflow/hooks';
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
|
||||
import { UploadFilled } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElUpload } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: String,
|
||||
default: '/api/v1/commons/upload',
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['success', 'onChange']);
|
||||
const accessStore = useAccessStore();
|
||||
const headers = ref({
|
||||
'easyflow-token': accessStore.accessToken,
|
||||
});
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
// 核心:获取ElUpload组件实例
|
||||
const uploadRef = ref<InstanceType<typeof ElUpload>>();
|
||||
|
||||
// 上传成功回调
|
||||
const handleSuccess: UploadProps['onSuccess'] = (response) => {
|
||||
emit('success', response.data.path);
|
||||
};
|
||||
|
||||
// 文件状态变化回调
|
||||
const handleChange: UploadProps['onChange'] = (file, fileList) => {
|
||||
emit('onChange', file, fileList);
|
||||
};
|
||||
|
||||
// 暴露给父组件的方法:手动触发文件选择
|
||||
const triggerFileSelect = () => {
|
||||
if (uploadRef.value) {
|
||||
// 调用ElUpload内部的上传按钮点击事件
|
||||
const uploadInput = uploadRef.value.$el.querySelector('input[type="file"]');
|
||||
if (uploadInput) {
|
||||
uploadInput.click(); // 触发原生文件选择框
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 对外暴露方法(父组件可通过ref调用)
|
||||
defineExpose({
|
||||
triggerFileSelect,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 给ElUpload添加ref引用 -->
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
class="upload-demo"
|
||||
drag
|
||||
:headers="headers"
|
||||
:action="`${apiURL}${props.action}`"
|
||||
:on-success="handleSuccess"
|
||||
:on-change="handleChange"
|
||||
multiple
|
||||
:style="{ display: props.visible ? 'block' : 'none' }"
|
||||
>
|
||||
<ElIcon size="48" color="hsl(var(--primary))">
|
||||
<UploadFilled />
|
||||
</ElIcon>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-base">{{ $t('message.upload.title') }}</span>
|
||||
<span class="text-sm text-[#75808d]">{{
|
||||
$t('message.upload.description')
|
||||
}}</span>
|
||||
</div>
|
||||
</ElUpload>
|
||||
</template>
|
||||
72
easyflow-ui-admin/app/src/components/upload/Upload.vue
Normal file
72
easyflow-ui-admin/app/src/components/upload/Upload.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadProps, UploadUserFile } from 'element-plus';
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@easyflow/hooks';
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
|
||||
import { ElButton, ElUpload } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: String,
|
||||
default: '/api/v1/commons/upload',
|
||||
},
|
||||
tips: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'success', // 文件上传成功
|
||||
'handleDelete',
|
||||
'handlePreview',
|
||||
'beforeUpload',
|
||||
]);
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const headers = ref({
|
||||
'easyflow-token': accessStore.accessToken,
|
||||
});
|
||||
const fileList = ref<UploadUserFile[]>([]);
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
emit('beforeUpload', rawFile);
|
||||
};
|
||||
const handleRemove: UploadProps['onRemove'] = (file, uploadFiles) => {
|
||||
emit('handleDelete', file, uploadFiles);
|
||||
};
|
||||
const handleSuccess: UploadProps['onSuccess'] = (response) => {
|
||||
emit('success', response.data.path);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
v-model:file-list="fileList"
|
||||
class="upload-demo"
|
||||
:headers="headers"
|
||||
:action="`${apiURL}${props.action}`"
|
||||
:multiple="props.multiple"
|
||||
:before-upload="beforeUpload"
|
||||
:on-remove="handleRemove"
|
||||
:limit="props.limit"
|
||||
:on-success="handleSuccess"
|
||||
>
|
||||
<ElButton type="primary">{{ $t('button.upload') }}</ElButton>
|
||||
</ElUpload>
|
||||
</template>
|
||||
125
easyflow-ui-admin/app/src/components/upload/UploadAvatar.vue
Normal file
125
easyflow-ui-admin/app/src/components/upload/UploadAvatar.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UploadProps } from 'element-plus';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { useAppConfig } from '@easyflow/hooks';
|
||||
import { useAccessStore } from '@easyflow/stores';
|
||||
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { ElIcon, ElImage, ElMessage, ElUpload } from 'element-plus';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const props = defineProps({
|
||||
action: {
|
||||
type: String,
|
||||
default: '/api/v1/commons/upload',
|
||||
},
|
||||
fileSize: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
allowedImageTypes: {
|
||||
type: Array<string>,
|
||||
default: () => ['image/gif', 'image/jpeg', 'image/png', 'image/webp'],
|
||||
},
|
||||
modelValue: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['success', 'update:modelValue']);
|
||||
const accessStore = useAccessStore();
|
||||
const headers = ref({
|
||||
'easyflow-token': accessStore.accessToken,
|
||||
});
|
||||
|
||||
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
const localImageUrl = ref(props.modelValue);
|
||||
const handleAvatarSuccess: UploadProps['onSuccess'] = (
|
||||
_response,
|
||||
uploadFile,
|
||||
) => {
|
||||
localImageUrl.value = URL.createObjectURL(uploadFile.raw!);
|
||||
emit('success', _response.data.path);
|
||||
emit('update:modelValue', _response.data.path);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
localImageUrl.value = newVal;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const beforeAvatarUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||
if (!props.allowedImageTypes.includes(rawFile.type)) {
|
||||
const formatTypes = props.allowedImageTypes
|
||||
.map((type: string) => {
|
||||
const parts = type.split('/');
|
||||
return parts[1] ? parts[1].toUpperCase() : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
ElMessage.error(
|
||||
$t('cropper.message.avatarFormat', { format: formatTypes.join(', ') }),
|
||||
);
|
||||
return false;
|
||||
} else if (rawFile.size / 1024 / 1024 > props.fileSize) {
|
||||
ElMessage.error(
|
||||
$t('cropper.message.avatarSize', { limit: props.fileSize }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
class="avatar-uploader"
|
||||
:action="`${apiURL}${props.action}`"
|
||||
:headers="headers"
|
||||
:show-file-list="false"
|
||||
:on-success="handleAvatarSuccess"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
>
|
||||
<ElImage
|
||||
v-if="localImageUrl"
|
||||
:src="localImageUrl"
|
||||
class="avatar"
|
||||
fit="cover"
|
||||
/>
|
||||
<ElIcon v-else class="avatar-uploader-icon"><Plus /></ElIcon>
|
||||
</ElUpload>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.avatar-uploader .avatar {
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.avatar-uploader .el-upload {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e6e9ee;
|
||||
border-radius: 50%;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
}
|
||||
|
||||
.avatar-uploader .el-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.el-icon.avatar-uploader-icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user