初始化
This commit is contained in:
23
easyflow-ui-usercenter/app/src/components/card/Card.vue
Normal file
23
easyflow-ui-usercenter/app/src/components/card/Card.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
interface CardProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'Card' });
|
||||
const props = defineProps<CardProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full max-w-60 cursor-pointer items-start gap-2.5 rounded-lg pb-2.5 pl-2.5 pr-5 pt-3.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { Avatar } from '@element-plus/icons-vue';
|
||||
import { ElAvatar } from 'element-plus';
|
||||
|
||||
interface CardAvatarProps {
|
||||
class?: string;
|
||||
size?: number;
|
||||
src?: string;
|
||||
defaultAvatar?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CardAvatar' });
|
||||
const props = defineProps<CardAvatarProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="props.defaultAvatar">
|
||||
<ElAvatar
|
||||
:class="cn('shrink-0', props.class)"
|
||||
:size="props.size ?? 36"
|
||||
:src="props.src ?? props.defaultAvatar"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElAvatar
|
||||
:class="cn('shrink-0', props.class)"
|
||||
:size="props.size ?? 36"
|
||||
:icon="Avatar"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
interface CardContentProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CardContent' });
|
||||
const props = defineProps<CardContentProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex w-full flex-col gap-1 overflow-hidden text-nowrap', props.class)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
interface CardDescriptionProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CardDescription' });
|
||||
const props = defineProps<CardDescriptionProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground/50 overflow-hidden text-ellipsis text-xs',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
20
easyflow-ui-usercenter/app/src/components/card/CardTitle.vue
Normal file
20
easyflow-ui-usercenter/app/src/components/card/CardTitle.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
interface CardTitleProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CardTitle' });
|
||||
const props = defineProps<CardTitleProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn('overflow-hidden text-ellipsis text-base font-semibold', props.class)
|
||||
"
|
||||
>
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
5
easyflow-ui-usercenter/app/src/components/card/index.ts
Normal file
5
easyflow-ui-usercenter/app/src/components/card/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as Card } from './Card.vue';
|
||||
export { default as CardAvatar } from './CardAvatar.vue';
|
||||
export { default as CardContent } from './CardContent.vue';
|
||||
export { default as CardDescription } from './CardDescription.vue';
|
||||
export { default as CardTitle } from './CardTitle.vue';
|
||||
219
easyflow-ui-usercenter/app/src/components/cardPage/CardPage.vue
Normal file
219
easyflow-ui-usercenter/app/src/components/cardPage/CardPage.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
} from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
titleKey: {
|
||||
type: String,
|
||||
default: 'title',
|
||||
},
|
||||
avatarKey: {
|
||||
type: String,
|
||||
default: 'avatar',
|
||||
},
|
||||
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 });
|
||||
};
|
||||
</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]" />
|
||||
<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"
|
||||
:type="action.type || 'primary'"
|
||||
:icon="action.icon"
|
||||
size="small"
|
||||
@click="handleActionClick(action, item)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</ElButton>
|
||||
|
||||
<!-- 更多操作下拉菜单 -->
|
||||
<ElDropdown
|
||||
v-if="dropdownActions.length > 0"
|
||||
@command="(command) => handleActionClick(command, item)"
|
||||
>
|
||||
<ElButton size="small" style="margin-left: 8px">
|
||||
更多
|
||||
<ElIcon class="el-icon--right">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="action in dropdownActions"
|
||||
:key="action.name"
|
||||
:command="action"
|
||||
:icon="action.icon"
|
||||
>
|
||||
{{ action.label }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<!-- 空状态(保留) -->
|
||||
<div v-if="data.length === 0" class="empty-state">
|
||||
<ElEmpty description="暂无数据" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-list-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
min-width: 300px;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
transition: all 0.3s ease;
|
||||
border-radius: 8px;
|
||||
width: 330px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 响应式设计(移除分页相关样式,保留卡片适配) */
|
||||
@media (max-width: 768px) {
|
||||
.card-list {
|
||||
justify-content: center; /* 小屏幕下卡片居中 */
|
||||
}
|
||||
.card-item {
|
||||
width: 100%;
|
||||
max-width: 330px;
|
||||
}
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,345 @@
|
||||
<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,
|
||||
},
|
||||
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', // 面板收缩状态改变事件
|
||||
]);
|
||||
|
||||
// -------------------------- 核心工具函数 --------------------------
|
||||
/**
|
||||
* 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) => {
|
||||
selectedCategory.value = category[props.titleKey];
|
||||
emit('click', category);
|
||||
};
|
||||
|
||||
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[titleKey] }"
|
||||
@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; /* 相对定位,用于按钮绝对定位 */
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); /* 平滑宽度过渡 */
|
||||
box-sizing: border-box;
|
||||
background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
/* 右上角收缩/展开按钮 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
}
|
||||
|
||||
.category-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.category-item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.category-item-content:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.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;
|
||||
padding: 12px 16px;
|
||||
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;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
/* 收缩状态样式 */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsed .category-item-content {
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* 收缩状态下文字强制隐藏(避免无图标时文字溢出) */
|
||||
.collapsed .category-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 新增:选中态样式 */
|
||||
.category-item-content.selected {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.category-item-content.selected:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
</style>
|
||||
184
easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue
Normal file
184
easyflow-ui-usercenter/app/src/components/chat/bubbleList.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import { CircleCheck } from '@element-plus/icons-vue';
|
||||
import { ElAvatar, ElCollapse, ElCollapseItem, ElIcon } from 'element-plus';
|
||||
|
||||
import defaultAssistantAvatar from '#/assets/defaultAssistantAvatar.svg';
|
||||
import defaultUserAvatar from '#/assets/defaultUserAvatar.png';
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
|
||||
interface Props {
|
||||
bot: any;
|
||||
messages: any[];
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const store = useUserStore();
|
||||
|
||||
function getAssistantAvatar() {
|
||||
return props.bot.icon || defaultAssistantAvatar;
|
||||
}
|
||||
function getUserAvatar() {
|
||||
return store.userInfo?.avatar || defaultUserAvatar;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElBubbleList :list="messages" max-height="calc(100vh - 345px)">
|
||||
<!-- 自定义头像 -->
|
||||
<template #avatar="{ item }">
|
||||
<ElAvatar
|
||||
:src="
|
||||
item.role === 'assistant' ? getAssistantAvatar() : getUserAvatar()
|
||||
"
|
||||
:size="40"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 自定义头部 -->
|
||||
<template #header="{ item }">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-foreground/50 text-xs">
|
||||
{{ item.created }}
|
||||
</span>
|
||||
|
||||
<template v-if="item.chains">
|
||||
<template
|
||||
v-for="(chain, index) in item.chains"
|
||||
:key="chain.id || index"
|
||||
>
|
||||
<ElThinking
|
||||
v-if="!('id' in 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>工具调用中...</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>调用成功</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"
|
||||
/> -->
|
||||
<!-- <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 #content="{ item }">
|
||||
<ElXMarkdown :markdown="item.content" />
|
||||
</template>
|
||||
|
||||
<!-- 自定义底部 -->
|
||||
<!--<template #footer="{ item }">
|
||||
<div class="flex items-center">
|
||||
<template v-if="item.role === 'assistant'">
|
||||
<ElButton :icon="RefreshRight" link />
|
||||
<ElButton :icon="CopyDocument" link />
|
||||
</template>
|
||||
<template v-else>
|
||||
<ElButton :icon="CopyDocument" link />
|
||||
<ElButton :icon="EditPen" link />
|
||||
</template>
|
||||
</div>
|
||||
</template>-->
|
||||
</ElBubbleList>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
:deep(.el-bubble-header) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-bubble-end .el-bubble-header) {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
:deep(.el-bubble-content-wrapper .el-bubble-content) {
|
||||
--bubble-content-max-width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-thinking) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-thinking .content-wrapper) {
|
||||
--el-thinking-content-wrapper-width: 100%;
|
||||
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse) {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-collapse-border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__content) {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
315
easyflow-ui-usercenter/app/src/components/chat/container.vue
Normal file
315
easyflow-ui-usercenter/app/src/components/chat/container.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, provide, ref, watch } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@easyflow/icons';
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { Delete, Edit, MoreFilled } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElAside,
|
||||
ElButton,
|
||||
ElContainer,
|
||||
ElDialog,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElForm,
|
||||
ElFormItem,
|
||||
ElHeader,
|
||||
ElInput,
|
||||
ElMain,
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
} from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import ChatIcon from '#/components/icons/ChatIcon.vue';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
interface Props {
|
||||
bot: any;
|
||||
isFold: boolean;
|
||||
onMessageList?: (list: any[]) => void;
|
||||
toggleFold: () => void;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const sessionList = ref<any>([]);
|
||||
const currentSession = ref<any>({});
|
||||
const hoverId = ref<string>();
|
||||
const dialogVisible = ref(false);
|
||||
watch(
|
||||
() => props.bot.id,
|
||||
() => {
|
||||
getSessionList(true);
|
||||
},
|
||||
);
|
||||
defineExpose({
|
||||
getSessionList,
|
||||
});
|
||||
|
||||
function getSessionList(resetSession = false) {
|
||||
api
|
||||
.get('/userCenter/botConversation/list', {
|
||||
params: {
|
||||
botId: props.bot.id,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
sessionList.value = res.data;
|
||||
if (resetSession) {
|
||||
currentSession.value = {};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
provide('getSessionList', getSessionList);
|
||||
function addSession() {
|
||||
const newSession = sessionList.value.find(
|
||||
(session: any) => session.title === '新对话' && !session.created,
|
||||
);
|
||||
|
||||
if (newSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.get('/userCenter/bot/generateConversationId').then((res) => {
|
||||
const data = {
|
||||
botId: props.bot.id,
|
||||
title: '新对话',
|
||||
id: res.data,
|
||||
};
|
||||
sessionList.value.unshift(data);
|
||||
|
||||
nextTick(() => {
|
||||
clickSession(data);
|
||||
});
|
||||
});
|
||||
}
|
||||
function clickSession(session: any) {
|
||||
currentSession.value = session;
|
||||
getMessageList();
|
||||
}
|
||||
function getMessageList() {
|
||||
api
|
||||
.get('/userCenter/botMessage/getMessages', {
|
||||
params: {
|
||||
botId: props.bot.id,
|
||||
conversationId: currentSession.value.id,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.errorCode === 0) {
|
||||
props.onMessageList?.(res.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
function formatCreatedTime(time: string) {
|
||||
if (time) {
|
||||
const createTime = Math.floor(new Date(time).getTime() / 1000);
|
||||
const today = Math.floor(Date.now() / 1000 / 86_400) * 86_400;
|
||||
return time.split(' ')[createTime < today ? 0 : 1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const handleMouseEvent = (id?: string) => {
|
||||
if (id === undefined) {
|
||||
setTimeout(() => {
|
||||
hoverId.value = id;
|
||||
}, 200);
|
||||
} else {
|
||||
hoverId.value = id;
|
||||
}
|
||||
};
|
||||
const updateLoading = ref(false);
|
||||
function updateTitle() {
|
||||
updateLoading.value = true;
|
||||
api
|
||||
.get('/userCenter/botConversation/updateConversation', {
|
||||
params: {
|
||||
botId: props.bot.id,
|
||||
conversationId: currentSession.value.id,
|
||||
title: currentSession.value.title,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
updateLoading.value = false;
|
||||
if (res.errorCode === 0) {
|
||||
dialogVisible.value = false;
|
||||
ElMessage.success('成功');
|
||||
getSessionList();
|
||||
}
|
||||
});
|
||||
}
|
||||
function remove(row: any) {
|
||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||
confirmButtonText: $t('message.ok'),
|
||||
cancelButtonText: $t('message.cancel'),
|
||||
type: 'warning',
|
||||
beforeClose: (action, instance, done) => {
|
||||
if (action === 'confirm') {
|
||||
instance.confirmButtonLoading = true;
|
||||
api
|
||||
.get('/userCenter/botConversation/deleteConversation', {
|
||||
params: {
|
||||
botId: props.bot.id,
|
||||
conversationId: row.id,
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
instance.confirmButtonLoading = false;
|
||||
if (res.errorCode === 0) {
|
||||
props.onMessageList?.([]);
|
||||
currentSession.value = {};
|
||||
ElMessage.success(res.message);
|
||||
done();
|
||||
getSessionList();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
instance.confirmButtonLoading = false;
|
||||
});
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer class="border-border bg-background h-full rounded-lg border">
|
||||
<ElAside width="287px" class="border-border border-r p-6">
|
||||
<!-- <Card class="max-w-max p-0">
|
||||
<CardAvatar :src="bot.icon" :default-avatar="defaultAssistantAvatar" />
|
||||
<CardContent>
|
||||
<CardTitle>{{ bot.title }}</CardTitle>
|
||||
<CardDescription>{{ bot.description }}</CardDescription>
|
||||
</CardContent>
|
||||
</Card> -->
|
||||
<span>会话</span>
|
||||
<ElButton
|
||||
class="mt-6 !h-10 w-full !text-sm"
|
||||
type="primary"
|
||||
:icon="ChatIcon"
|
||||
plain
|
||||
@click="addSession"
|
||||
>
|
||||
新建会话
|
||||
</ElButton>
|
||||
<div class="mt-8">
|
||||
<div
|
||||
v-for="conversation in sessionList"
|
||||
:key="conversation.id"
|
||||
:class="
|
||||
cn(
|
||||
'group flex h-10 cursor-pointer items-center justify-between gap-1 rounded-lg px-5 text-sm',
|
||||
currentSession.id === conversation.id
|
||||
? 'bg-[hsl(var(--primary)/15%)] dark:bg-[hsl(var(--accent))]'
|
||||
: 'hover:bg-[hsl(var(--accent))]',
|
||||
)
|
||||
"
|
||||
@click="clickSession(conversation)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground overflow-hidden text-ellipsis text-nowrap',
|
||||
currentSession.id === conversation.id && 'text-primary',
|
||||
)
|
||||
"
|
||||
:title="conversation.title || '未命名'"
|
||||
>
|
||||
{{ conversation.title || '未命名' }}
|
||||
</span>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-foreground/50 text-nowrap text-xs group-hover:hidden',
|
||||
hoverId === conversation.id && 'hidden',
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ formatCreatedTime(conversation.created) }}
|
||||
</span>
|
||||
<ElDropdown
|
||||
:class="
|
||||
cn(
|
||||
'group-hover:!inline-flex',
|
||||
(!hoverId || conversation.id !== hoverId) && '!hidden',
|
||||
)
|
||||
"
|
||||
@click.stop
|
||||
trigger="click"
|
||||
>
|
||||
<ElButton link :icon="MoreFilled" @click.stop />
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu
|
||||
@mouseenter="handleMouseEvent(conversation.id)"
|
||||
@mouseleave="handleMouseEvent()"
|
||||
>
|
||||
<ElDropdownItem @click="dialogVisible = true">
|
||||
<ElButton link :icon="Edit">编辑</ElButton>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem>
|
||||
<ElButton
|
||||
@click="remove(conversation)"
|
||||
link
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
>
|
||||
删除
|
||||
</ElButton>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ElAside>
|
||||
<ElContainer>
|
||||
<ElHeader class="border-border border-b" height="53">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base/[53px] font-medium">
|
||||
{{ currentSession.title || '未命名' }}
|
||||
</span>
|
||||
<IconifyIcon
|
||||
v-if="isFold"
|
||||
icon="svg:assistant-fold"
|
||||
class="rotate-180 cursor-pointer"
|
||||
@click="toggleFold"
|
||||
/>
|
||||
</div>
|
||||
</ElHeader>
|
||||
<ElMain>
|
||||
<slot :conversation-id="currentSession.id"></slot>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
<ElDialog title="编辑" v-model="dialogVisible">
|
||||
<div class="p-5">
|
||||
<ElForm>
|
||||
<ElFormItem>
|
||||
<ElInput
|
||||
v-model="currentSession.title"
|
||||
placeholder="请输入会话名称"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" @click="updateTitle" :loading="updateLoading">
|
||||
确认
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</ElContainer>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.el-button :deep(.el-icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
3
easyflow-ui-usercenter/app/src/components/chat/index.ts
Normal file
3
easyflow-ui-usercenter/app/src/components/chat/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ChatBubbleList } from './bubbleList.vue';
|
||||
export { default as ChatContainer } from './container.vue';
|
||||
export { default as ChatSender } from './sender.vue';
|
||||
265
easyflow-ui-usercenter/app/src/components/chat/sender.vue
Normal file
265
easyflow-ui-usercenter/app/src/components/chat/sender.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import type { BubbleProps } from 'vue-element-plus-x/types/Bubble';
|
||||
import type { ThinkingStatus } from 'vue-element-plus-x/types/Thinking';
|
||||
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
import { cloneDeep, uuid } from '@easyflow/utils';
|
||||
|
||||
import { Paperclip, Promotion } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElIcon } from 'element-plus';
|
||||
|
||||
import { sseClient } from '#/api/request';
|
||||
import SendingIcon from '#/components/icons/SendingIcon.vue';
|
||||
import ChatFileUploader from '#/components/upload/ChatFileUploader.vue';
|
||||
// import PaperclipIcon from '#/components/icons/PaperclipIcon.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 = BubbleProps & {
|
||||
chains?: (Think | Tool)[];
|
||||
key: string;
|
||||
role: 'assistant' | 'user';
|
||||
};
|
||||
|
||||
interface Props {
|
||||
conversationId: string | undefined;
|
||||
bot: any;
|
||||
addMessage: (message: MessageItem) => void;
|
||||
updateLastMessage: (item: any) => void;
|
||||
stopThinking: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const senderValue = ref('');
|
||||
const btnLoading = ref(false);
|
||||
const getSessionList = inject<any>('getSessionList');
|
||||
const clearSenderFiles = () => {
|
||||
files.value = [];
|
||||
attachmentsRef.value?.clearFiles();
|
||||
openCloseHeader();
|
||||
};
|
||||
function sendMessage() {
|
||||
if (getDisabled()) {
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
conversationId: props.conversationId,
|
||||
prompt: senderValue.value,
|
||||
botId: props.bot.id,
|
||||
attachments: attachmentsRef.value?.getFileList(),
|
||||
};
|
||||
clearSenderFiles();
|
||||
btnLoading.value = true;
|
||||
props.addMessage({
|
||||
key: uuid(),
|
||||
role: 'user',
|
||||
placement: 'end',
|
||||
content: senderValue.value,
|
||||
typing: true,
|
||||
});
|
||||
props.addMessage({
|
||||
key: uuid(),
|
||||
role: 'assistant',
|
||||
placement: 'start',
|
||||
content: '',
|
||||
loading: true,
|
||||
typing: true,
|
||||
});
|
||||
senderValue.value = '';
|
||||
|
||||
let content = '';
|
||||
|
||||
sseClient.post('/userCenter/bot/chat', data, {
|
||||
onMessage(res) {
|
||||
if (!res.data) {
|
||||
return;
|
||||
}
|
||||
const sseData = JSON.parse(res.data);
|
||||
const delta = sseData.payload?.delta;
|
||||
|
||||
if (res.event === 'done') {
|
||||
btnLoading.value = false;
|
||||
getSessionList();
|
||||
}
|
||||
|
||||
// 处理系统错误
|
||||
if (
|
||||
sseData?.domain === 'SYSTEM' &&
|
||||
sseData.payload?.code === 'SYSTEM_ERROR'
|
||||
) {
|
||||
const errorMessage = sseData.payload.message;
|
||||
props.updateLastMessage({
|
||||
content: errorMessage,
|
||||
loading: false,
|
||||
typing: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sseData?.domain === 'TOOL') {
|
||||
props.updateLastMessage((message: MessageItem) => {
|
||||
const chains = cloneDeep(message.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,
|
||||
};
|
||||
}
|
||||
return { chains };
|
||||
});
|
||||
props.stopThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
if (sseData.type === 'THINKING') {
|
||||
props.updateLastMessage((message: MessageItem) => {
|
||||
const chains = cloneDeep(message.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,
|
||||
};
|
||||
}
|
||||
return { chains };
|
||||
});
|
||||
} else if (sseData.type === 'MESSAGE') {
|
||||
props.updateLastMessage({
|
||||
thinkingStatus: 'end',
|
||||
loading: false,
|
||||
content: (content += delta),
|
||||
});
|
||||
props.stopThinking();
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
console.error(err);
|
||||
btnLoading.value = false;
|
||||
},
|
||||
onFinished() {
|
||||
senderValue.value = '';
|
||||
btnLoading.value = false;
|
||||
props.updateLastMessage({ loading: false });
|
||||
props.stopThinking();
|
||||
},
|
||||
});
|
||||
}
|
||||
const isTool = (item: Think | Tool) => {
|
||||
return 'id' in item;
|
||||
};
|
||||
const isThink = (item: Think | Tool): item is Think => {
|
||||
return !('id' in item);
|
||||
};
|
||||
function getDisabled() {
|
||||
return !senderValue.value || !props.conversationId;
|
||||
}
|
||||
const stopSse = () => {
|
||||
sseClient.abort();
|
||||
btnLoading.value = false;
|
||||
};
|
||||
const showHeaderFlog = ref(false);
|
||||
const attachmentsRef = ref();
|
||||
const senderRef = ref();
|
||||
const files = ref<any[]>([]);
|
||||
function handlePasteFile(_: any, fileList: FileList) {
|
||||
showHeaderFlog.value = true;
|
||||
senderRef.value?.openHeader();
|
||||
files.value = [...fileList];
|
||||
}
|
||||
function openCloseHeader() {
|
||||
if (showHeaderFlog.value) {
|
||||
senderRef.value?.closeHeader();
|
||||
files.value = [];
|
||||
} else {
|
||||
senderRef.value?.openHeader();
|
||||
}
|
||||
showHeaderFlog.value = !showHeaderFlog.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSender
|
||||
ref="senderRef"
|
||||
v-model="senderValue"
|
||||
variant="updown"
|
||||
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||||
clearable
|
||||
allow-speech
|
||||
placeholder="发送消息"
|
||||
@keyup.enter="sendMessage"
|
||||
@paste-file="handlePasteFile"
|
||||
>
|
||||
<!-- 自定义 prefix 前缀 -->
|
||||
<!-- <template #prefix>
|
||||
</template> -->
|
||||
<!-- 自定义头部内容 -->
|
||||
<template #header>
|
||||
<ChatFileUploader
|
||||
ref="attachmentsRef"
|
||||
:external-files="files"
|
||||
@delete-all="openCloseHeader"
|
||||
:max-size="10"
|
||||
/>
|
||||
</template>
|
||||
<template #action-list>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElButton circle @click="openCloseHeader">
|
||||
<ElIcon><Paperclip /></ElIcon>
|
||||
</ElButton>
|
||||
<!-- <ElButton :icon="PaperclipIcon" link /> -->
|
||||
<ElButton v-if="btnLoading" circle @click="stopSse">
|
||||
<ElIcon size="30" color="#409eff"><SendingIcon /></ElIcon>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-else
|
||||
type="primary"
|
||||
:icon="Promotion"
|
||||
:disabled="getDisabled()"
|
||||
@click="sendMessage"
|
||||
round
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ElSender>
|
||||
</template>
|
||||
190
easyflow-ui-usercenter/app/src/components/dict/DictSelect.vue
Normal file
190
easyflow-ui-usercenter/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 {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
} from 'element-plus';
|
||||
|
||||
// 定义组件属性
|
||||
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: '请输入搜索内容',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['search', 'button-click', 'buttonClick']);
|
||||
|
||||
// 搜索值
|
||||
const searchValue = ref('');
|
||||
|
||||
// 计算显示的按钮
|
||||
const visibleButtons = computed(() => {
|
||||
return props.buttons.slice(0, props.maxVisibleButtons);
|
||||
});
|
||||
|
||||
// 计算下拉菜单中的按钮
|
||||
const dropdownButtons = computed(() => {
|
||||
return props.buttons.slice(props.maxVisibleButtons);
|
||||
});
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
emit('search', searchValue.value);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
searchValue.value = '';
|
||||
};
|
||||
|
||||
// 处理按钮点击
|
||||
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" @click="handleSearch">
|
||||
{{ $t('button.query') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div>
|
||||
<ElButton @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"
|
||||
@click="handleButtonClick(button)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<!-- 下拉菜单(隐藏的按钮) -->
|
||||
<ElDropdown
|
||||
v-if="dropdownButtons.length > 0"
|
||||
@command="handleDropdownClick"
|
||||
>
|
||||
<ElButton>
|
||||
更多<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>
|
||||
.custom-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border-radius: 4px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.custom-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
easyflow-ui-usercenter/app/src/components/icons/ChatIcon.vue
Normal file
36
easyflow-ui-usercenter/app/src/components/icons/ChatIcon.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 20 20"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title>聊天</title>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g
|
||||
id="聊天助理"
|
||||
transform="translate(-665, -128)"
|
||||
fill="currentColor"
|
||||
fill-rule="nonzero"
|
||||
>
|
||||
<g id="编组-2" transform="translate(587, 118)">
|
||||
<g id="聊天" transform="translate(78, 10)">
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="20" height="20" />
|
||||
<path
|
||||
d="M9.58369141,2.95388672 C14.0753725,2.95388672 17.7682147,5.80492802 17.9985115,9.43806706 L18.0004805,9.48688672 L18.0451616,9.54683168 C18.5191813,10.2146674 18.7903205,10.9649721 18.835701,11.7441169 L18.8425195,11.9786719 C18.8425195,13.3549242 18.1384458,14.6409769 16.9220514,15.5524004 L16.7804805,15.6528867 L16.8050955,15.6856721 L16.8382762,15.7265555 L16.8690426,15.7667797 C17.4098878,16.5545617 16.8687213,17.2724569 15.9571984,16.9732091 C15.8642431,16.9415552 15.8642431,16.9415552 15.7710491,16.9104204 C15.6220312,16.860775 15.4777901,16.8139646 15.3433483,16.7717381 C15.2928614,16.7559124 15.2928614,16.7559124 15.2437809,16.7407435 C15.0678614,16.6865476 14.9227047,16.6449372 14.8148836,16.6178971 C14.7968483,16.6133761 14.7799872,16.6093002 14.7645016,16.6057079 L14.7234805,16.5958867 L14.5046465,16.6525745 C14.084516,16.7488676 13.651768,16.8072691 13.212396,16.8267779 L12.8817187,16.8341016 C12.2807983,16.8341016 11.6894804,16.7618391 11.1229611,16.6200235 L10.8964805,16.5568867 L10.6544807,16.5865773 C10.4186334,16.6108231 10.1811627,16.6270228 9.94250755,16.6351286 L9.58369141,16.6412109 C8.77558458,16.6412109 7.97964018,16.5489412 7.21402919,16.3674942 L6.91548047,16.2898867 L6.84755984,16.3052468 C6.82232182,16.3110111 6.79476708,16.3174849 6.76473886,16.3248639 L6.66742199,16.3496762 C6.4750594,16.4002979 6.2183789,16.4758066 5.90552572,16.5735806 C5.83361971,16.5961023 5.83361971,16.5961023 5.75995684,16.6194104 C5.50976359,16.6988698 5.26015731,16.7801657 5.01112132,16.8633037 L4.81936416,16.9276073 C4.80582608,16.9321754 4.79028932,16.9374202 4.77164522,16.943714 L4.74701067,16.9520621 L4.67837195,16.9753855 C3.7618984,17.2799458 3.23238997,16.5507627 3.80022944,15.7477041 C3.82916944,15.7104932 3.84827678,15.6852432 3.88168805,15.6396486 C3.9483591,15.5485583 4.01134766,15.4568837 4.06769221,15.3684183 C4.11335085,15.2967489 4.15321837,15.2293824 4.18650908,15.1675409 C4.19217329,15.1570245 4.19990192,15.1419287 4.2087555,15.1243507 L4.22848047,15.0848867 L4.21690393,15.0771266 C2.36245034,13.8340367 1.24898298,12.0130046 1.16287272,10.0443576 L1.15748047,9.79753906 C1.15748047,6.00241783 4.93601286,2.95388672 9.58369141,2.95388672 Z M12.8817188,8.15710937 C10.1538598,8.15710937 7.95355469,9.88463621 7.95355469,11.9786719 C7.95355469,13.5435846 9.19776958,14.9615506 11.086977,15.53757 C11.561609,15.6822897 12.06148,15.7682152 12.5732995,15.7928376 L12.8817187,15.8002344 C13.4517579,15.8002344 14.0105873,15.7248455 14.5405134,15.5772212 C14.7383518,15.5221203 14.904422,15.5545219 15.5473738,15.7525791 C15.5993294,15.7686199 15.5993294,15.7686199 15.652589,15.7853188 L15.6914805,15.7978867 L15.6668119,15.719849 C15.5932609,15.4423614 15.6517585,15.2003859 15.8659174,15.0256726 L15.9535697,14.96337 C17.1298176,14.2328612 17.8099023,13.1393491 17.8099023,11.9786719 C17.8099023,11.2731276 17.5612377,10.5904328 17.0871268,9.99055596 L16.9397313,9.8148591 C16.0220968,8.78296977 14.514899,8.15710937 12.8817188,8.15710937 Z M9.58369141,3.98777344 C5.49423672,3.98777344 2.19009766,6.60496037 2.19009766,9.79753906 C2.19009766,11.5751198 3.2213624,13.2441047 4.99832929,14.3518173 C5.43687887,14.6251903 5.43287823,15.0315683 5.09544355,15.6582022 L5.03348047,15.7668867 L5.06608386,15.7566779 L5.44826284,15.6337494 C5.52388404,15.6098474 5.52388404,15.6098474 5.59782273,15.5866828 C6.60057425,15.2732535 6.88382259,15.2099012 7.1115971,15.2735769 C7.6760283,15.4314037 8.26218983,15.5340528 8.86050039,15.5798257 L8.87748047,15.5798867 C7.71852945,14.7241657 6.99581224,13.5140803 6.92643099,12.1888419 L6.9209375,11.9786719 C6.9209375,9.28141712 9.59563849,7.12324219 12.8817188,7.12324219 C14.2700006,7.12324219 15.5864116,7.51243086 16.6314373,8.20022133 L16.7114805,8.25488672 L16.6574186,8.10572539 C15.7581372,5.78986552 13.062144,4.07976862 9.8456072,3.9913673 L9.58369141,3.98777344 Z"
|
||||
id="形状结合"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 13.4769231 20.6769231"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title>编组 5</title>
|
||||
<g
|
||||
id="页面-1"
|
||||
stroke="none"
|
||||
stroke-width="1"
|
||||
fill="none"
|
||||
fill-rule="evenodd"
|
||||
>
|
||||
<g id="聊天助理" transform="translate(-1675.2615, -973.6615)">
|
||||
<g id="编组-7" transform="translate(934, 860)">
|
||||
<g
|
||||
id="编组-5"
|
||||
transform="translate(748, 124) scale(-1, 1) translate(-748, -124)translate(736, 112)"
|
||||
>
|
||||
<rect id="矩形" x="0" y="0" width="24" height="24" />
|
||||
<path
|
||||
d="M6,9.54856308 L6,15.6 C6,18.9137085 8.6862915,21.6 12,21.6 C15.3137085,21.6 18,18.9137085 18,15.6 L18,6.34185886 C18,4.16483033 16.2351697,2.4 14.0581411,2.4 C11.8811126,2.4 10.1162823,4.16483033 10.1162823,6.34185886 L10.1162823,15.6584871 C10.1162823,16.823829 11.0609786,17.7685254 12.2263206,17.7685254 C13.3916625,17.7685254 14.3363588,16.823829 14.3363588,15.6584871 L14.3363588,5.62527693 L14.3363588,5.62527693"
|
||||
id="路径-10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.47692308"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -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>
|
||||
39
easyflow-ui-usercenter/app/src/components/json/ShowJson.vue
Normal file
39
easyflow-ui-usercenter/app/src/components/json/ShowJson.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<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.png" v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.res-container {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
124
easyflow-ui-usercenter/app/src/components/page/PageData.vue
Normal file
124
easyflow-ui-usercenter/app/src/components/page/PageData.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
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.png" v-else />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as RunResult } from './result.vue';
|
||||
export { default as RunSteps } from './steps.vue';
|
||||
25
easyflow-ui-usercenter/app/src/components/runBot/result.vue
Normal file
25
easyflow-ui-usercenter/app/src/components/runBot/result.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { ElEmpty } from 'element-plus';
|
||||
|
||||
interface RunResultProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'RunResult' });
|
||||
const props = defineProps<RunResultProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex flex-1 flex-col gap-6 rounded-lg bg-white p-5', props.class)
|
||||
"
|
||||
>
|
||||
<h1 class="text-base font-medium text-[#1A1A1A]">运行结果</h1>
|
||||
<div class="flex-1 rounded-lg border border-[#F0F0F0] bg-[#F7F7F7] p-4">
|
||||
<ElEmpty />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
57
easyflow-ui-usercenter/app/src/components/runBot/steps.vue
Normal file
57
easyflow-ui-usercenter/app/src/components/runBot/steps.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@easyflow/utils';
|
||||
|
||||
import { ElCollapse, ElCollapseItem } from 'element-plus';
|
||||
|
||||
interface RunStepsProps {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'RunSteps' });
|
||||
const props = defineProps<RunStepsProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('flex h-full flex-col gap-6 rounded-lg bg-white p-5', props.class)
|
||||
"
|
||||
>
|
||||
<h1 class="text-base font-medium text-[#1A1A1A]">执行步骤</h1>
|
||||
<ElCollapse expand-icon-position="left" accordion>
|
||||
<ElCollapseItem title="Consistency" name="1">
|
||||
<div>
|
||||
Consistent with real life: in line with the process and logic of real
|
||||
life, and comply with languages and habits that the users are used to;
|
||||
</div>
|
||||
<div>
|
||||
Consistent within interface: all elements should be consistent, such
|
||||
as: design style, icons and texts, position of elements, etc.
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem title="Feedback" name="2">
|
||||
<div>
|
||||
Operation feedback: enable the users to clearly perceive their
|
||||
operations by style updates and interactive effects;
|
||||
</div>
|
||||
<div>
|
||||
Visual feedback: reflect current state by updating or rearranging
|
||||
elements of the page.
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
<ElCollapseItem title="Efficiency" name="3">
|
||||
<div>
|
||||
Simplify the process: keep operating process simple and intuitive;
|
||||
</div>
|
||||
<div>
|
||||
Definite and clear: enunciate your intentions clearly so that the
|
||||
users can quickly understand and make decisions;
|
||||
</div>
|
||||
<div>
|
||||
Easy to identify: the interface should be straightforward, which helps
|
||||
the users to identify and frees them from memorizing and recalling.
|
||||
</div>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</div>
|
||||
</template>
|
||||
226
easyflow-ui-usercenter/app/src/components/tag/Tag.vue
Normal file
226
easyflow-ui-usercenter/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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.tag__close:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 为可关闭标签调整内边距 */
|
||||
.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>
|
||||
261
easyflow-ui-usercenter/app/src/components/tree/Tree.vue
Normal file
261
easyflow-ui-usercenter/app/src/components/tree/Tree.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<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 {
|
||||
background-color: #fff;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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>
|
||||
448
easyflow-ui-usercenter/app/src/components/upload/Cropper.vue
Normal file
448
easyflow-ui-usercenter/app/src/components/upload/Cropper.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<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';
|
||||
|
||||
// 定义组件props
|
||||
interface Props {
|
||||
modelValue?: string; // 双向绑定的图片URL
|
||||
crop?: boolean; // 是否启用裁剪
|
||||
action?: string; // 上传地址
|
||||
headers?: Record<string, string>; // 上传请求头
|
||||
data?: Record<string, any>; // 上传额外数据
|
||||
cropConfig?: Partial<CropConfig>; // 裁剪配置
|
||||
limit?: number; // 文件大小限制(MB)
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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: '图片裁剪',
|
||||
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 = 'image/*';
|
||||
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('只能上传图片文件!');
|
||||
return;
|
||||
}
|
||||
|
||||
const isLtLimit = file.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
|
||||
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('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
|
||||
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(`上传失败: ${response.message}`);
|
||||
}
|
||||
|
||||
const imageUrl = response.data.path;
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error('上传成功但未返回图片URL');
|
||||
}
|
||||
|
||||
emit('update:modelValue', imageUrl);
|
||||
emit('uploadSuccess', imageUrl);
|
||||
|
||||
ElMessage.success('上传成功!');
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('上传失败');
|
||||
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('裁剪器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('裁剪失败,无法获取裁剪后的图片');
|
||||
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('上传失败');
|
||||
emit('uploadError', err);
|
||||
ElMessage.error(err.message);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemove = () => {
|
||||
emit('update:modelValue', '');
|
||||
ElMessage.success('删除成功!');
|
||||
};
|
||||
|
||||
// 清理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="image/*"
|
||||
>
|
||||
<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">
|
||||
取消
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleCrop" :loading="uploading">
|
||||
{{ uploading ? '上传中...' : '确认裁剪' }}
|
||||
</ElButton>
|
||||
</span>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 样式保持不变 */
|
||||
.image-upload-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-uploader:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
height: 400px;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.edit-actions .el-button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,582 @@
|
||||
<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, 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: '图片裁剪',
|
||||
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('只能上传图片文件!');
|
||||
return;
|
||||
}
|
||||
|
||||
const isLtLimit = file.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
|
||||
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('只能上传图片文件!');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLtLimit = rawFile.size / 1024 / 1024 < props.limit;
|
||||
if (!isLtLimit) {
|
||||
ElMessage.error(`图片大小不能超过 ${props.limit}MB!`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fileList.value.length >= props.maxCount) {
|
||||
ElMessage.error(`最多只能上传 ${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(`上传失败: ${response.message}`);
|
||||
}
|
||||
|
||||
const imageUrl = response.data.path;
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error('上传成功但未返回图片URL');
|
||||
}
|
||||
|
||||
// 更新文件列表
|
||||
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 ? '重新上传成功!' : '上传成功!');
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('上传失败');
|
||||
|
||||
// 重置上传状态
|
||||
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('裁剪器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
cropperRef.value.getCropBlob(async (blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('裁剪失败,无法获取裁剪后的图片');
|
||||
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('上传失败');
|
||||
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('删除成功!');
|
||||
};
|
||||
|
||||
// 清理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 ? '上传中...' : '重新上传' }}
|
||||
</ElButton>
|
||||
<ElButton type="danger" text @click="handleRemove(index)">
|
||||
<ElIcon><Delete /></ElIcon>
|
||||
删除
|
||||
</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">点击上传</div>
|
||||
<div class="upload-hint">最多 {{ 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">
|
||||
取消
|
||||
</ElButton>
|
||||
<ElButton type="primary" @click="handleCrop" :loading="uploading">
|
||||
{{ uploading ? '上传中...' : '确认裁剪' }}
|
||||
</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;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.preview-container:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-uploader {
|
||||
width: 100px;
|
||||
height: 140px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.avatar-uploader:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 10px;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
height: 400px;
|
||||
background: #f5f7fa;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
79
easyflow-ui-usercenter/app/src/components/upload/Upload.vue
Normal file
79
easyflow-ui-usercenter/app/src/components/upload/Upload.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<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 { Upload } from '@element-plus/icons-vue';
|
||||
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);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
clear() {
|
||||
fileList.value = [];
|
||||
},
|
||||
});
|
||||
</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 :icon="Upload">{{ $t('button.upload') }}</ElButton>
|
||||
</ElUpload>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<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';
|
||||
|
||||
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(`头像只能是${formatTypes.join(', ')}格式`);
|
||||
return false;
|
||||
} else if (rawFile.size / 1024 / 1024 > props.fileSize) {
|
||||
ElMessage.error(`头像限制 ${props.fileSize} M`);
|
||||
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 {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.avatar-uploader .el-upload {
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
}
|
||||
|
||||
.avatar-uploader .el-upload:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.el-icon.avatar-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: var(--el-text-color-secondary);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user