feat: 更新数字人创作页面以支持技能选择功能
- 在节点库项中新增技能选择选项,允许用户为节点指定技能 - 更新API请求路径,统一为'/ai-agent'前缀 - 优化动态表单逻辑,确保根据节点类型正确显示技能选择器 - 移除冗余的文件上传函数,改为导入公共上传函数以简化代码结构
This commit is contained in:
32
src/api/common/upload.ts
Normal file
32
src/api/common/upload.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
// OSS 上传响应
|
||||
export interface OssUploadResponse {
|
||||
fileName: string;
|
||||
fileURL: string;
|
||||
fileSize: number;
|
||||
fileFormat: string;
|
||||
fileAddressPrefix: string;
|
||||
files: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共文件上传到 OSS
|
||||
* @param file 文件对象
|
||||
* @param config 请求配置
|
||||
* @returns 返回文件名、文件地址、文件大小等信息
|
||||
*/
|
||||
export function uploadFile(file: File, config?: any) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return request<OssUploadResponse>({
|
||||
url: '/oss/file/uploadFile',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
...config,
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export interface NodeLibraryModelConfig {
|
||||
export interface NodeLibraryItem {
|
||||
nodeCode: string;
|
||||
nodeName: string;
|
||||
skillOption: boolean;
|
||||
formConfig: NodeLibraryFormItem[];
|
||||
modelConfig: NodeLibraryModelConfig[];
|
||||
}
|
||||
@@ -106,7 +107,7 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
|
||||
|
||||
export function getNodeLibraryList(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/node/library/list',
|
||||
url: '/ai-agent/node/library/list',
|
||||
method: 'get',
|
||||
requestOptions,
|
||||
}) as Promise<NodeLibraryListResponse>;
|
||||
@@ -134,7 +135,7 @@ export function downloadToFile(data: DownloadToFileParams, requestOptions?: Requ
|
||||
|
||||
export function saveWorkflow(data: { flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/flow/user/create',
|
||||
url: '/ai-agent/flow/user/create',
|
||||
method: 'post',
|
||||
data,
|
||||
requestOptions,
|
||||
@@ -159,7 +160,7 @@ export interface WorkflowListResponse {
|
||||
|
||||
export function getWorkflowList(requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/flow/user/list',
|
||||
url: '/ai-agent/flow/user/list',
|
||||
method: 'get',
|
||||
requestOptions,
|
||||
}) as Promise<WorkflowListResponse>;
|
||||
@@ -173,7 +174,7 @@ export interface WorkflowDetailResponse {
|
||||
|
||||
export function getWorkflowDetail(id: string, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/flow/user/get',
|
||||
url: '/ai-agent/flow/user/get',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
requestOptions,
|
||||
@@ -182,7 +183,7 @@ export function getWorkflowDetail(id: string, requestOptions?: RequestOptions) {
|
||||
|
||||
export function updateWorkflow(data: { id: string; flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/flow/user/update',
|
||||
url: '/ai-agent/flow/user/update',
|
||||
method: 'put',
|
||||
data,
|
||||
requestOptions,
|
||||
@@ -191,7 +192,7 @@ export function updateWorkflow(data: { id: string; flowName: string; description
|
||||
|
||||
export function deleteWorkflow(id: string, requestOptions?: RequestOptions) {
|
||||
return request({
|
||||
url: '/black-deacon/flow/user/delete',
|
||||
url: '/ai-agent/flow/user/delete',
|
||||
method: 'delete',
|
||||
params: { id },
|
||||
requestOptions,
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface ModelTypeListResponse {
|
||||
*/
|
||||
export function getModelTypeList(params: ModelTypeListParams) {
|
||||
return request({
|
||||
url: '/api/digital-human/model-config/model-type/list',
|
||||
url: '/api/ai-agent/model-config/model-type/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
@@ -41,7 +41,7 @@ export function getModelTypeList(params: ModelTypeListParams) {
|
||||
*/
|
||||
export function addModelType(data: Partial<ModelTypeItem>) {
|
||||
return request({
|
||||
url: '/api/digital-human/model-config/model-type/add',
|
||||
url: '/api/ai-agent/model-config/model-type/add',
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function addModelType(data: Partial<ModelTypeItem>) {
|
||||
*/
|
||||
export function updateModelType(data: Partial<ModelTypeItem>) {
|
||||
return request({
|
||||
url: '/api/digital-human/model-config/model-type/update',
|
||||
url: '/api/ai-agent/model-config/model-type/update',
|
||||
method: 'put',
|
||||
data,
|
||||
});
|
||||
@@ -63,7 +63,7 @@ export function updateModelType(data: Partial<ModelTypeItem>) {
|
||||
*/
|
||||
export function deleteModelType(id: string) {
|
||||
return request({
|
||||
url: `/api/digital-human/model-config/model-type/delete/${id}`,
|
||||
url: `/api/ai-agent/model-config/model-type/delete/${id}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function deleteModelType(id: string) {
|
||||
*/
|
||||
export function getModelTypeDetail(id: string) {
|
||||
return request({
|
||||
url: `/api/digital-human/model-config/model-type/detail/${id}`,
|
||||
url: `/api/ai-agent/model-config/model-type/detail/${id}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
117
src/api/digitalHuman/skill/index.ts
Normal file
117
src/api/digitalHuman/skill/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import request from '/@/utils/request';
|
||||
|
||||
// Skill 技能项
|
||||
export interface SkillItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Skill 列表响应
|
||||
export interface SkillListResponse {
|
||||
list: SkillItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 创建 Skill 参数
|
||||
export interface CreateSkillParams {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
fileName?: string;
|
||||
fileUrl?: string;
|
||||
[property: string]: any;
|
||||
}
|
||||
|
||||
// 系统技能列表查询参数
|
||||
export interface SkillListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
Total?: number;
|
||||
}
|
||||
|
||||
// 用户技能列表查询参数
|
||||
export interface UserSkillListParams {
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
Total?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 系统技能列表
|
||||
*/
|
||||
export function getSkillList(params?: SkillListParams, config?: any) {
|
||||
return request<SkillListResponse>({
|
||||
url: '/ai-agent/skill/template/list',
|
||||
method: 'get',
|
||||
params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Skill 系统技能
|
||||
*/
|
||||
export function createSkill(data: CreateSkillParams, config?: any) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/template/create',
|
||||
method: 'post',
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Skill 系统技能
|
||||
*/
|
||||
export function deleteSkill(id: number, config?: any) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/template/delete',
|
||||
method: 'delete',
|
||||
data: { id },
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Skill 用户技能列表
|
||||
*/
|
||||
export function getUserSkillList(params?: UserSkillListParams, config?: any) {
|
||||
return request<SkillListResponse>({
|
||||
url: '/ai-agent/skill/user/list',
|
||||
method: 'get',
|
||||
params,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Skill 用户技能
|
||||
*/
|
||||
export function createUserSkill(data: CreateSkillParams, config?: any) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/user/create',
|
||||
method: 'post',
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Skill 用户技能
|
||||
*/
|
||||
export function deleteUserSkill(id: number, config?: any) {
|
||||
return request({
|
||||
url: '/ai-agent/skill/user/delete',
|
||||
method: 'delete',
|
||||
data: { id },
|
||||
...config,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import request from '/@/utils/request';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
|
||||
// 导出公共上传函数供其他模块使用
|
||||
export { uploadFile };
|
||||
|
||||
// 文档查询参数
|
||||
export interface DocumentQueryParams {
|
||||
@@ -114,18 +118,6 @@ export function updateDocument(data: UpdateDocumentParams) {
|
||||
});
|
||||
}
|
||||
|
||||
// 公共文件上传(OSS),返回文件路径
|
||||
export function uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request({
|
||||
url: '/oss/file/uploadFile',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
// 上传文档
|
||||
export function uploadDocument(data: FormData) {
|
||||
return request({
|
||||
|
||||
234
src/components/skill/NodeSkillSelector.vue
Normal file
234
src/components/skill/NodeSkillSelector.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="系统技能" name="system">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
|
||||
<div v-else class="skill-grid">
|
||||
<div v-for="skill in skillList" :key="skill.id" class="skill-card" :class="{ selected: selectedSkill?.id === skill.id }" @click="handleSelectSkill(skill)">
|
||||
<div class="skill-card-header">
|
||||
<div class="skill-category">{{ skill.category }}</div>
|
||||
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next" small @current-change="handlePageChange" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="用户技能" name="user">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
|
||||
<div v-else class="skill-grid">
|
||||
<div v-for="skill in skillList" :key="skill.id" class="skill-card" :class="{ selected: selectedSkill?.id === skill.id }" @click="handleSelectSkill(skill)">
|
||||
<div class="skill-card-header">
|
||||
<div class="skill-category">{{ skill.category }}</div>
|
||||
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next" small @current-change="handlePageChange" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getSkillList, getUserSkillList, type SkillItem } from '/@/api/digitalHuman/skill';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultSkill?: SkillItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', skill: SkillItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultSkill: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const activeTab = ref('system');
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const skillList = ref<SkillItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedSkill = ref<SkillItem | null>(null);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedSkill.value = props.defaultSkill || null;
|
||||
fetchSkillList();
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleTabChange = () => {
|
||||
pagination.pageNum = 1;
|
||||
searchParams.keyword = '';
|
||||
selectedSkill.value = null;
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const fetchSkillList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
|
||||
const res = activeTab.value === 'system' ? await getSkillList(params, { errorMode: 'message' }) : await getUserSkillList(params, { errorMode: 'message' });
|
||||
skillList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
} catch (error) {
|
||||
skillList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handleSelectSkill = (skill: SkillItem) => {
|
||||
selectedSkill.value = skill;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSkill.value) {
|
||||
emit('confirm', selectedSkill.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedSkill.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.skill-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skill-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.skill-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.skill-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.skill-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skill-category {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.skill-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.skill-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
196
src/components/skill/SkillSelector.vue
Normal file
196
src/components/skill/SkillSelector.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择技能" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="100" />
|
||||
<div v-else class="skill-grid">
|
||||
<div v-for="skill in skillList" :key="skill.id" class="skill-card" :class="{ selected: selectedSkill?.id === skill.id }" @click="handleSelectSkill(skill)">
|
||||
<div class="skill-card-header">
|
||||
<div class="skill-category">{{ skill.category }}</div>
|
||||
<el-icon v-if="selectedSkill?.id === skill.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination v-model:current-page="pagination.pageNum" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-sizes="[10, 20, 50]" layout="total, prev, pager, next" small @current-change="handlePageChange" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedSkill">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getUserSkillList, type SkillItem } from '/@/api/digitalHuman/skill';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultSkill?: SkillItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', skill: SkillItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultSkill: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const skillList = ref<SkillItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedSkill = ref<SkillItem | null>(null);
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedSkill.value = props.defaultSkill || null;
|
||||
fetchSkillList();
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSkillList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
|
||||
const res = await getUserSkillList(params, { errorMode: 'message' });
|
||||
skillList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
} catch (error) {
|
||||
skillList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handleSelectSkill = (skill: SkillItem) => {
|
||||
selectedSkill.value = skill;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedSkill.value) {
|
||||
emit('confirm', selectedSkill.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedSkill.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.skill-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.skill-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.skill-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.skill-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.skill-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.skill-category {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.skill-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.skill-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -58,6 +58,20 @@
|
||||
<el-form-item v-if="selectedModel" label="模型 API Key">
|
||||
<el-input v-model="dynamicFormValues.modelApiKey" placeholder="请输入模型 API Key" type="password" show-password />
|
||||
</el-form-item>
|
||||
<!-- 技能选择(如果节点支持) -->
|
||||
<el-form-item v-if="currentNodeSkillOption" label="选择技能">
|
||||
<div class="skill-selector-wrapper">
|
||||
<el-button type="primary" @click="showSkillSelector = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
选择技能
|
||||
</el-button>
|
||||
<div v-if="selectedSkill" class="selected-skill-tag">
|
||||
<el-tag type="success" size="large" closable @close="handleRemoveSkill">
|
||||
{{ selectedSkill.name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 基础表单 + 模型表单 -->
|
||||
<el-form-item v-for="fieldItem in allFormFields" :key="fieldItem.field" :label="fieldItem.label">
|
||||
<el-input
|
||||
@@ -189,10 +203,7 @@
|
||||
<template v-if="currentWorkflowForCreation?.nodeInputParams">
|
||||
<div v-for="node in currentWorkflowForCreation.nodeInputParams" :key="node.id" class="node-form-wrapper">
|
||||
<!-- 跳过开始节点 -->
|
||||
<div
|
||||
v-if="node.nodeCode !== '__start__' && (node.config?.formConfig?.length > 0 || hasVisibleFields(node))"
|
||||
class="node-form-section"
|
||||
>
|
||||
<div v-if="node.nodeCode !== '__start__' && (node.formConfig?.length > 0 || hasVisibleFields(node))" class="node-form-section">
|
||||
<div class="node-form-title">
|
||||
<el-icon class="node-icon"><Document /></el-icon>
|
||||
<span>{{ node.name }}</span>
|
||||
@@ -200,9 +211,9 @@
|
||||
|
||||
<div class="form-grid">
|
||||
<!-- 自定义表单字段 -->
|
||||
<template v-if="node.config?.formConfig && node.config.formConfig.length > 0">
|
||||
<template v-if="node.formConfig && node.formConfig.length > 0">
|
||||
<el-form-item
|
||||
v-for="field in node.config.formConfig"
|
||||
v-for="field in node.formConfig"
|
||||
:key="`${node.id}_${field.label}`"
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
@@ -389,17 +400,22 @@
|
||||
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 技能选择器 -->
|
||||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Document } from '@element-plus/icons-vue';
|
||||
import { Document, Plus } from '@element-plus/icons-vue';
|
||||
import LogicFlow from '@logicflow/core';
|
||||
import { Control, SelectionSelect } from '@logicflow/extension';
|
||||
import '@logicflow/core/dist/index.css';
|
||||
import '@logicflow/extension/lib/style/index.css';
|
||||
import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
|
||||
import type { SkillItem } from '/@/api/digitalHuman/skill';
|
||||
import {
|
||||
downloadToFile,
|
||||
getCreationList,
|
||||
@@ -442,6 +458,8 @@ const selectedElement = ref<SelectedState | null>(null);
|
||||
const customFields = ref<Array<{ label: string; value: string; type: string; required: boolean }>>([]);
|
||||
const selectedParentParam = ref('');
|
||||
const selectedModel = ref('');
|
||||
const showSkillSelector = ref(false);
|
||||
const selectedSkill = ref<SkillItem | null>(null);
|
||||
const saving = ref(false);
|
||||
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
|
||||
const saveDialogVisible = ref(false);
|
||||
@@ -489,7 +507,7 @@ const logicFlowRef = ref<HTMLDivElement | null>(null);
|
||||
const logicFlowInstance = ref<LogicFlow | null>(null);
|
||||
const nodeSpawnIndex = ref(0);
|
||||
const formState = reactive({ text: '', nodeCode: '', field: '' });
|
||||
const dynamicFormValues = reactive<Record<string, any>>({});
|
||||
const dynamicFormValues = reactive<Record<string, any>>({ modelApiKey: '' });
|
||||
const nodeSchemaMap = computed(() => {
|
||||
const map: Record<string, NodeLibraryFormItem[]> = {};
|
||||
nodeLibraryGroups.value.forEach((group) => {
|
||||
@@ -512,6 +530,18 @@ const currentNodeModelConfig = computed(() => {
|
||||
});
|
||||
return modelConfigs;
|
||||
});
|
||||
// 获取当前节点是否支持技能选择
|
||||
const currentNodeSkillOption = computed(() => {
|
||||
let skillOption = false;
|
||||
nodeLibraryGroups.value.forEach((group) => {
|
||||
(group.items || []).forEach((item) => {
|
||||
if (item.nodeCode === formState.nodeCode) {
|
||||
skillOption = item.skillOption || false;
|
||||
}
|
||||
});
|
||||
});
|
||||
return skillOption;
|
||||
});
|
||||
// 获取当前选中模型的表单字段
|
||||
const currentModelForm = computed<NodeLibraryFormItem[]>(() => {
|
||||
if (!selectedModel.value) return [];
|
||||
@@ -595,6 +625,7 @@ const workflowDsl = computed(() => ({
|
||||
nodeCode: n.properties?.nodeCode || 'unknown',
|
||||
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
|
||||
type: n.type || 'rect',
|
||||
skillName: n.properties?.skillName || null,
|
||||
config: {
|
||||
nodeCode: n.properties?.nodeCode || 'unknown',
|
||||
width: n.properties?.width || 100,
|
||||
@@ -708,6 +739,38 @@ const handlePageChange = (page: number) => {
|
||||
workflowPagination.pageNum = page;
|
||||
fetchWorkflowList();
|
||||
};
|
||||
// 处理技能选择确认
|
||||
const handleSkillConfirm = (skill: SkillItem) => {
|
||||
selectedSkill.value = skill;
|
||||
// 将技能名称保存到节点属性中(只保存 skillName)
|
||||
if (selectedElement.value && logicFlowInstance.value) {
|
||||
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
|
||||
if (nodeData) {
|
||||
logicFlowInstance.value.setProperties(selectedElement.value.id, {
|
||||
...nodeData.properties,
|
||||
skillName: skill.name,
|
||||
});
|
||||
// 同步更新 selectedElement
|
||||
selectedElement.value.properties.skillName = skill.name;
|
||||
}
|
||||
}
|
||||
ElMessage.success('技能选择成功');
|
||||
};
|
||||
// 移除已选择的技能
|
||||
const handleRemoveSkill = () => {
|
||||
selectedSkill.value = null;
|
||||
// 从节点属性中移除技能信息
|
||||
if (selectedElement.value && logicFlowInstance.value) {
|
||||
const nodeData = logicFlowInstance.value.getNodeModelById(selectedElement.value.id);
|
||||
if (nodeData) {
|
||||
const props = { ...nodeData.properties };
|
||||
delete props.skillName;
|
||||
logicFlowInstance.value.setProperties(selectedElement.value.id, props);
|
||||
// 同步更新 selectedElement
|
||||
delete selectedElement.value.properties.skillName;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 使用工作流
|
||||
const useWorkflow = async (workflow: WorkflowItem) => {
|
||||
try {
|
||||
@@ -724,17 +787,18 @@ const useWorkflow = async (workflow: WorkflowItem) => {
|
||||
// 根据 nodeInputParams 初始化表单默认值
|
||||
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
||||
res.data.nodeInputParams.forEach((node: any) => {
|
||||
if (node.config?.formConfig && Array.isArray(node.config.formConfig)) {
|
||||
node.config.formConfig.forEach((field: any) => {
|
||||
// 从节点根级别的 formConfig 读取(不是 node.config.formConfig)
|
||||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||||
node.formConfig.forEach((field: any) => {
|
||||
const fieldKey = `${node.id}_${field.label}`;
|
||||
creationFormValues[fieldKey] = field.value || '';
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化其他配置字段
|
||||
// 初始化其他配置字段(从 config 中读取)
|
||||
if (node.config) {
|
||||
Object.keys(node.config).forEach((key) => {
|
||||
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource'].includes(key)) {
|
||||
if (!['nodeCode', 'width', 'height', 'x', 'y', 'formConfig', 'inputSource', 'fieldMetadata', 'selectedModel'].includes(key)) {
|
||||
const fieldKey = `${node.id}_${key}`;
|
||||
creationFormValues[fieldKey] = node.config[key];
|
||||
}
|
||||
@@ -979,18 +1043,11 @@ const addParentParam = (value: string) => {
|
||||
inputSource,
|
||||
});
|
||||
|
||||
// 更新 selectedElement 以触发界面刷新
|
||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||||
if (n) {
|
||||
selectedElement.value = {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
kind: 'node',
|
||||
properties: n.properties || {},
|
||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||||
};
|
||||
}
|
||||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||
selectedElement.value.properties = {
|
||||
...currentProps,
|
||||
inputSource,
|
||||
};
|
||||
|
||||
syncDsl();
|
||||
ElMessage.success(`已添加上级参数:${paramName}`);
|
||||
@@ -1022,23 +1079,18 @@ const removeInputSource = (nodeId: string, paramName: string) => {
|
||||
inputSource.splice(nodeIndex, 1);
|
||||
}
|
||||
|
||||
const newInputSource = inputSource.length > 0 ? inputSource : null;
|
||||
|
||||
lf.setProperties(selectedElement.value.id, {
|
||||
...currentProps,
|
||||
inputSource: inputSource.length > 0 ? inputSource : null,
|
||||
inputSource: newInputSource,
|
||||
});
|
||||
|
||||
// 更新 selectedElement 以触发界面刷新
|
||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||||
if (n) {
|
||||
selectedElement.value = {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
kind: 'node',
|
||||
properties: n.properties || {},
|
||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||||
};
|
||||
}
|
||||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||
selectedElement.value.properties = {
|
||||
...currentProps,
|
||||
inputSource: newInputSource,
|
||||
};
|
||||
|
||||
syncDsl();
|
||||
ElMessage.success(`已删除参数:${paramName}`);
|
||||
@@ -1067,18 +1119,11 @@ const updateQuoteOutput = (nodeId: string, enabled: boolean) => {
|
||||
inputSource,
|
||||
});
|
||||
|
||||
// 更新 selectedElement 以触发界面刷新
|
||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||||
if (n) {
|
||||
selectedElement.value = {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
kind: 'node',
|
||||
properties: n.properties || {},
|
||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||||
};
|
||||
}
|
||||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||
selectedElement.value.properties = {
|
||||
...currentProps,
|
||||
inputSource,
|
||||
};
|
||||
|
||||
syncDsl();
|
||||
|
||||
@@ -1194,18 +1239,11 @@ const toggleNodeOutput = (nodeId: string, enabled: boolean) => {
|
||||
inputSource,
|
||||
});
|
||||
|
||||
// 更新 selectedElement 以触发界面刷新
|
||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
||||
if (n) {
|
||||
selectedElement.value = {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
kind: 'node',
|
||||
properties: n.properties || {},
|
||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
||||
};
|
||||
}
|
||||
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||
selectedElement.value.properties = {
|
||||
...currentProps,
|
||||
inputSource,
|
||||
};
|
||||
|
||||
syncDsl();
|
||||
|
||||
@@ -1259,14 +1297,20 @@ watch(
|
||||
formState.text = String(e?.text || '');
|
||||
formState.nodeCode = String(e?.properties?.nodeCode || '');
|
||||
formState.field = String(e?.properties?.field || '');
|
||||
Object.keys(dynamicFormValues).forEach((k) => delete dynamicFormValues[k]);
|
||||
|
||||
// 重置 dynamicFormValues(不删除键,保持响应式)
|
||||
for (const key in dynamicFormValues) {
|
||||
dynamicFormValues[key] = '';
|
||||
}
|
||||
|
||||
// 获取当前节点的基础表单字段(直接从 nodeSchemaMap 获取,避免响应式延迟)
|
||||
const currentNodeCode = formState.nodeCode;
|
||||
const baseFormFields = nodeSchemaMap.value[currentNodeCode] || [];
|
||||
const baseFieldNames = new Set(baseFormFields.map((f) => f.field));
|
||||
|
||||
// 加载自定义字段和基础字段(从 formConfig)
|
||||
customFields.value = [];
|
||||
if (e?.properties?.formConfig && Array.isArray(e.properties.formConfig)) {
|
||||
// 分离基础字段和自定义字段
|
||||
const baseFieldNames = new Set(currentNodeForm.value.map(f => f.field));
|
||||
|
||||
e.properties.formConfig.forEach((fieldConfig: any) => {
|
||||
if (baseFieldNames.has(fieldConfig.field)) {
|
||||
// 基础字段:加载到 dynamicFormValues
|
||||
@@ -1304,15 +1348,42 @@ watch(
|
||||
dynamicFormValues.modelApiKey = e?.properties?.modelApiKey || '';
|
||||
}
|
||||
|
||||
// 获取当前节点的模型配置
|
||||
let nodeModelConfigs: any[] = [];
|
||||
nodeLibraryGroups.value.forEach((group) => {
|
||||
(group.items || []).forEach((item) => {
|
||||
if (item.nodeCode === currentNodeCode) {
|
||||
nodeModelConfigs = item.modelConfig || [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 如果没有选择模型但有模型配置,选择第一个
|
||||
if (!selectedModel.value && currentNodeModelConfig.value.length > 0) {
|
||||
selectedModel.value = currentNodeModelConfig.value[0].modelName;
|
||||
if (!selectedModel.value && nodeModelConfigs.length > 0) {
|
||||
selectedModel.value = nodeModelConfigs[0].modelName;
|
||||
}
|
||||
|
||||
// 恢复技能信息(只根据 skillName)
|
||||
if (e?.properties?.skillName) {
|
||||
// 只保存技能名称用于显示,完整信息在选择时已经保存到节点属性
|
||||
selectedSkill.value = {
|
||||
id: 0,
|
||||
name: e.properties.skillName,
|
||||
description: '',
|
||||
category: '',
|
||||
fileName: '',
|
||||
fileUrl: '',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
} else {
|
||||
selectedSkill.value = null;
|
||||
}
|
||||
|
||||
// 初始化所有表单字段(基础 + 模型)- 只设置还没有值的字段
|
||||
allFormFields.value.forEach((fieldItem) => {
|
||||
// 如果已经从 formConfig 或 modelConfig 加载过,跳过
|
||||
if (dynamicFormValues[fieldItem.field] !== undefined) {
|
||||
if (dynamicFormValues[fieldItem.field] !== undefined && dynamicFormValues[fieldItem.field] !== '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1360,7 +1431,7 @@ const applySelected = () => {
|
||||
formState.field ? (p.field = formState.field) : delete p.field;
|
||||
} else {
|
||||
// 保留重要的配置字段,删除其他字段
|
||||
const keysToKeep = ['nodeCode', 'fieldMetadata', 'modelConfig', 'inputSource', 'formConfig', 'width', 'height'];
|
||||
const keysToKeep = ['nodeCode', 'fieldMetadata', 'modelConfig', 'inputSource', 'formConfig', 'skillName', 'width', 'height'];
|
||||
Object.keys(p).forEach((key) => {
|
||||
if (!keysToKeep.includes(key)) {
|
||||
delete p[key];
|
||||
@@ -1414,7 +1485,7 @@ const applySelected = () => {
|
||||
|
||||
// 保存 formConfig(包含基础字段 + 自定义字段,不包含模型字段)
|
||||
const formConfig: Array<any> = [];
|
||||
|
||||
|
||||
// 1. 添加基础表单字段(非模型字段)
|
||||
// 重用上面的 modelFieldNames
|
||||
currentNodeForm.value.forEach((fieldItem) => {
|
||||
@@ -1423,11 +1494,11 @@ const applySelected = () => {
|
||||
type: fieldItem.type,
|
||||
field: fieldItem.field,
|
||||
label: fieldItem.label,
|
||||
value: value !== undefined && value !== null ? value : (fieldItem.default || ''),
|
||||
value: value !== undefined && value !== null ? value : fieldItem.default || '',
|
||||
required: fieldItem.required || false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// 2. 添加自定义字段
|
||||
customFields.value.forEach((field) => {
|
||||
formConfig.push({
|
||||
@@ -1438,7 +1509,7 @@ const applySelected = () => {
|
||||
required: field.required,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// 保存 formConfig
|
||||
if (formConfig.length > 0) {
|
||||
p.formConfig = formConfig;
|
||||
@@ -1672,14 +1743,31 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
||||
|
||||
try {
|
||||
// 转换后端 DSL 为 LogicFlow 格式
|
||||
const nodes = (dsl.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type || 'rect',
|
||||
x: n.config?.x || 220,
|
||||
y: n.config?.y || 140,
|
||||
text: n.name || '',
|
||||
properties: n.config || {},
|
||||
}));
|
||||
const nodes = (dsl.nodes || []).map((n: any) => {
|
||||
// 判断是否为判断节点
|
||||
const nodeCode = String(n.nodeCode || '').toLowerCase();
|
||||
const nodeName = String(n.name || '').toLowerCase();
|
||||
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
|
||||
const nodeType = isJudge ? 'diamond' : 'rect';
|
||||
|
||||
return {
|
||||
id: n.id,
|
||||
type: nodeType,
|
||||
x: n.config?.x || 220,
|
||||
y: n.config?.y || 140,
|
||||
text: n.name || '',
|
||||
properties: {
|
||||
// 保留 config 中的基础配置
|
||||
...(n.config || {}),
|
||||
// 添加节点级别的重要字段
|
||||
nodeCode: n.nodeCode,
|
||||
skillName: n.skillName || null,
|
||||
formConfig: n.formConfig || null,
|
||||
modelConfig: n.modelConfig || null,
|
||||
inputSource: n.inputSource || null,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges = (dsl.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
@@ -1695,7 +1783,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
||||
syncDsl();
|
||||
ElMessage.success('工作流已加载');
|
||||
} catch (error) {
|
||||
// 后端错误会自动显示
|
||||
ElMessage.error('工作流加载失败');
|
||||
}
|
||||
};
|
||||
onMounted(async () => {
|
||||
@@ -2659,4 +2747,19 @@ onBeforeUnmount(() => {
|
||||
margin-top: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-selector-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selected-skill-tag {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.selected-skill-tag .el-tag {
|
||||
font-size: 14px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
471
src/views/digitalHuman/skill/index.vue
Normal file
471
src/views/digitalHuman/skill/index.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<div class="skill-page">
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">Skill 技能管理</h2>
|
||||
<p class="page-desc">管理和配置 AI 技能模板</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建技能
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索技能名称或描述" clearable class="search-input" @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon><Search /></el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 技能列表 -->
|
||||
<div class="skill-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && skillList.length === 0" description="暂无技能数据" :image-size="120" />
|
||||
<div v-else class="skill-grid">
|
||||
<div v-for="skill in skillList" :key="skill.id" class="skill-card">
|
||||
<div class="skill-card-header">
|
||||
<div class="skill-category">{{ skill.category }}</div>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => handleCommand(cmd, skill)">
|
||||
<el-icon class="more-icon"><MoreFilled /></el-icon>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="delete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
删除
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="skill-card-body">
|
||||
<h3 class="skill-name">{{ skill.name }}</h3>
|
||||
<p class="skill-desc">{{ skill.description || '暂无描述' }}</p>
|
||||
<div class="skill-file" v-if="skill.fileName">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ skill.fileName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-card-footer">
|
||||
<span class="skill-time">{{ formatTime(skill.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 创建对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px" :close-on-click-modal="false">
|
||||
<el-form :model="formData" :rules="formRules" ref="formRef" label-position="top">
|
||||
<el-form-item label="技能名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入技能名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="技能描述" prop="description">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="4" placeholder="请输入技能描述" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category">
|
||||
<el-input v-model="formData.category" placeholder="请输入分类(如:文本生成、图像生成等)" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传文件" prop="file">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-area"
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="handleFileChange"
|
||||
:on-exceed="handleExceed"
|
||||
:file-list="fileList"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">支持各种文件格式,文件大小不超过 100MB</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 文件信息预览 -->
|
||||
<div v-if="formData.fileName" class="file-preview">
|
||||
<div class="file-preview-header">
|
||||
<span class="file-preview-title">已上传文件</span>
|
||||
<el-tag type="success" size="small">上传成功</el-tag>
|
||||
</div>
|
||||
<div class="file-preview-info">
|
||||
<div class="file-info-item">
|
||||
<span class="file-info-label">文件名:</span>
|
||||
<span class="file-info-value">{{ formData.fileName }}</span>
|
||||
</div>
|
||||
<div class="file-info-item">
|
||||
<span class="file-info-label">文件地址:</span>
|
||||
<span class="file-info-value">{{ formData.fileUrl }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadInstance, type UploadProps, type UploadUserFile } from 'element-plus';
|
||||
import { Plus, Search, MoreFilled, Delete, Document, UploadFilled } from '@element-plus/icons-vue';
|
||||
import { getUserSkillList, createUserSkill, deleteUserSkill, type SkillItem, type CreateSkillParams } from '/@/api/digitalHuman/skill';
|
||||
import { uploadFile as uploadFileToOss } from '/@/api/common/upload';
|
||||
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const skillList = ref<SkillItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('创建技能');
|
||||
const formRef = ref<FormInstance>();
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const submitting = ref(false);
|
||||
const fileList = ref<UploadUserFile[]>([]);
|
||||
const formData = reactive<CreateSkillParams>({ name: '', description: '', category: '', fileName: '', fileUrl: '' });
|
||||
const formRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入技能名称', trigger: 'blur' }],
|
||||
category: [{ required: true, message: '请输入分类', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const formatTime = (time: string) => (time ? time.replace('T', ' ').split('.')[0] : '');
|
||||
|
||||
const handleFileChange: UploadProps['onChange'] = async (uploadFile) => {
|
||||
if (!uploadFile.raw) return;
|
||||
const maxSize = 100 * 1024 * 1024;
|
||||
if (uploadFile.raw.size > maxSize) {
|
||||
ElMessage.warning('文件大小不能超过 100MB');
|
||||
fileList.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ElMessage.info('正在上传文件到 OSS...');
|
||||
const uploadRes = await uploadFileToOss(uploadFile.raw, { errorMode: 'page' });
|
||||
formData.fileName = uploadRes.data.fileName;
|
||||
formData.fileUrl = uploadRes.data.fileURL;
|
||||
fileList.value = [uploadFile];
|
||||
ElMessage.success('文件上传成功');
|
||||
} catch (error) {
|
||||
fileList.value = [];
|
||||
formData.fileName = '';
|
||||
formData.fileUrl = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleExceed: UploadProps['onExceed'] = () => ElMessage.warning('只能上传一个文件');
|
||||
|
||||
const fetchSkillList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = { pageNum: pagination.pageNum, pageSize: pagination.pageSize, keyword: searchParams.keyword || undefined };
|
||||
const res = await getUserSkillList(params, { errorMode: 'page' });
|
||||
skillList.value = res.data?.list || [];
|
||||
pagination.total = res.data?.total || 0;
|
||||
} catch (error) {
|
||||
skillList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
};
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.pageNum = page;
|
||||
fetchSkillList();
|
||||
};
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.pageNum = 1;
|
||||
fetchSkillList();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogTitle.value = '创建技能';
|
||||
Object.assign(formData, { name: '', description: '', category: '', fileName: '', fileUrl: '' });
|
||||
fileList.value = [];
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
if (!formData.fileName || !formData.fileUrl) {
|
||||
ElMessage.warning('请先上传文件');
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
await createUserSkill(
|
||||
{
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category: formData.category,
|
||||
fileName: formData.fileName,
|
||||
fileUrl: formData.fileUrl,
|
||||
},
|
||||
{ errorMode: 'page' }
|
||||
);
|
||||
ElMessage.success('创建成功');
|
||||
dialogVisible.value = false;
|
||||
fetchSkillList();
|
||||
} catch (error) {
|
||||
// 错误已由 errorMode: 'page' 处理
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommand = async (command: string, skill: SkillItem) => {
|
||||
if (command === 'delete') {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除技能"${skill.name}"吗?此操作不可恢复。`, '删除确认', {
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
});
|
||||
await deleteUserSkill(skill.id, { errorMode: 'page' });
|
||||
ElMessage.success('删除成功');
|
||||
fetchSkillList();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
// 错误已由 errorMode: 'page' 处理
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => fetchSkillList());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.skill-page {
|
||||
padding: 20px;
|
||||
background: #f6f8fb;
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.page-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
.skill-list {
|
||||
min-height: 400px;
|
||||
}
|
||||
.skill-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.skill-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.skill-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.skill-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skill-category {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.more-icon {
|
||||
cursor: pointer;
|
||||
color: #94a3b8;
|
||||
font-size: 20px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.more-icon:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.skill-card-body {
|
||||
flex: 1;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skill-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-desc {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 12px 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 44px;
|
||||
}
|
||||
.skill-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #475569;
|
||||
}
|
||||
.skill-file .el-icon {
|
||||
color: #3b82f6;
|
||||
}
|
||||
.skill-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
.skill-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.upload-area {
|
||||
width: 100%;
|
||||
}
|
||||
.upload-area :deep(.el-upload-dragger) {
|
||||
width: 100%;
|
||||
}
|
||||
.file-preview {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f0fdf4;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #86efac;
|
||||
}
|
||||
.file-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.file-preview-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
}
|
||||
.file-preview-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
font-size: 13px;
|
||||
}
|
||||
.file-info-label {
|
||||
color: #15803d;
|
||||
margin-right: 8px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-info-value {
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.skill-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.search-bar {
|
||||
flex-direction: column;
|
||||
}
|
||||
.search-input {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user