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 {
|
export interface NodeLibraryItem {
|
||||||
nodeCode: string;
|
nodeCode: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
|
skillOption: boolean;
|
||||||
formConfig: NodeLibraryFormItem[];
|
formConfig: NodeLibraryFormItem[];
|
||||||
modelConfig: NodeLibraryModelConfig[];
|
modelConfig: NodeLibraryModelConfig[];
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ export function getCreationList(params: CreationListParams, requestOptions?: Req
|
|||||||
|
|
||||||
export function getNodeLibraryList(requestOptions?: RequestOptions) {
|
export function getNodeLibraryList(requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/node/library/list',
|
url: '/ai-agent/node/library/list',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
requestOptions,
|
requestOptions,
|
||||||
}) as Promise<NodeLibraryListResponse>;
|
}) 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) {
|
export function saveWorkflow(data: { flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/flow/user/create',
|
url: '/ai-agent/flow/user/create',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
requestOptions,
|
requestOptions,
|
||||||
@@ -159,7 +160,7 @@ export interface WorkflowListResponse {
|
|||||||
|
|
||||||
export function getWorkflowList(requestOptions?: RequestOptions) {
|
export function getWorkflowList(requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/flow/user/list',
|
url: '/ai-agent/flow/user/list',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
requestOptions,
|
requestOptions,
|
||||||
}) as Promise<WorkflowListResponse>;
|
}) as Promise<WorkflowListResponse>;
|
||||||
@@ -173,7 +174,7 @@ export interface WorkflowDetailResponse {
|
|||||||
|
|
||||||
export function getWorkflowDetail(id: string, requestOptions?: RequestOptions) {
|
export function getWorkflowDetail(id: string, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/flow/user/get',
|
url: '/ai-agent/flow/user/get',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { id },
|
params: { id },
|
||||||
requestOptions,
|
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) {
|
export function updateWorkflow(data: { id: string; flowName: string; description: string; flowContent: any }, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/flow/user/update',
|
url: '/ai-agent/flow/user/update',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data,
|
||||||
requestOptions,
|
requestOptions,
|
||||||
@@ -191,7 +192,7 @@ export function updateWorkflow(data: { id: string; flowName: string; description
|
|||||||
|
|
||||||
export function deleteWorkflow(id: string, requestOptions?: RequestOptions) {
|
export function deleteWorkflow(id: string, requestOptions?: RequestOptions) {
|
||||||
return request({
|
return request({
|
||||||
url: '/black-deacon/flow/user/delete',
|
url: '/ai-agent/flow/user/delete',
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
params: { id },
|
params: { id },
|
||||||
requestOptions,
|
requestOptions,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface ModelTypeListResponse {
|
|||||||
*/
|
*/
|
||||||
export function getModelTypeList(params: ModelTypeListParams) {
|
export function getModelTypeList(params: ModelTypeListParams) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/digital-human/model-config/model-type/list',
|
url: '/api/ai-agent/model-config/model-type/list',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ export function getModelTypeList(params: ModelTypeListParams) {
|
|||||||
*/
|
*/
|
||||||
export function addModelType(data: Partial<ModelTypeItem>) {
|
export function addModelType(data: Partial<ModelTypeItem>) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/digital-human/model-config/model-type/add',
|
url: '/api/ai-agent/model-config/model-type/add',
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ export function addModelType(data: Partial<ModelTypeItem>) {
|
|||||||
*/
|
*/
|
||||||
export function updateModelType(data: Partial<ModelTypeItem>) {
|
export function updateModelType(data: Partial<ModelTypeItem>) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/digital-human/model-config/model-type/update',
|
url: '/api/ai-agent/model-config/model-type/update',
|
||||||
method: 'put',
|
method: 'put',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
@@ -63,7 +63,7 @@ export function updateModelType(data: Partial<ModelTypeItem>) {
|
|||||||
*/
|
*/
|
||||||
export function deleteModelType(id: string) {
|
export function deleteModelType(id: string) {
|
||||||
return request({
|
return request({
|
||||||
url: `/api/digital-human/model-config/model-type/delete/${id}`,
|
url: `/api/ai-agent/model-config/model-type/delete/${id}`,
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ export function deleteModelType(id: string) {
|
|||||||
*/
|
*/
|
||||||
export function getModelTypeDetail(id: string) {
|
export function getModelTypeDetail(id: string) {
|
||||||
return request({
|
return request({
|
||||||
url: `/api/digital-human/model-config/model-type/detail/${id}`,
|
url: `/api/ai-agent/model-config/model-type/detail/${id}`,
|
||||||
method: 'get',
|
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 request from '/@/utils/request';
|
||||||
|
import { uploadFile } from '/@/api/common/upload';
|
||||||
|
|
||||||
|
// 导出公共上传函数供其他模块使用
|
||||||
|
export { uploadFile };
|
||||||
|
|
||||||
// 文档查询参数
|
// 文档查询参数
|
||||||
export interface DocumentQueryParams {
|
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) {
|
export function uploadDocument(data: FormData) {
|
||||||
return request({
|
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-form-item v-if="selectedModel" label="模型 API Key">
|
||||||
<el-input v-model="dynamicFormValues.modelApiKey" placeholder="请输入模型 API Key" type="password" show-password />
|
<el-input v-model="dynamicFormValues.modelApiKey" placeholder="请输入模型 API Key" type="password" show-password />
|
||||||
</el-form-item>
|
</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-form-item v-for="fieldItem in allFormFields" :key="fieldItem.field" :label="fieldItem.label">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -189,10 +203,7 @@
|
|||||||
<template v-if="currentWorkflowForCreation?.nodeInputParams">
|
<template v-if="currentWorkflowForCreation?.nodeInputParams">
|
||||||
<div v-for="node in currentWorkflowForCreation.nodeInputParams" :key="node.id" class="node-form-wrapper">
|
<div v-for="node in currentWorkflowForCreation.nodeInputParams" :key="node.id" class="node-form-wrapper">
|
||||||
<!-- 跳过开始节点 -->
|
<!-- 跳过开始节点 -->
|
||||||
<div
|
<div v-if="node.nodeCode !== '__start__' && (node.formConfig?.length > 0 || hasVisibleFields(node))" class="node-form-section">
|
||||||
v-if="node.nodeCode !== '__start__' && (node.config?.formConfig?.length > 0 || hasVisibleFields(node))"
|
|
||||||
class="node-form-section"
|
|
||||||
>
|
|
||||||
<div class="node-form-title">
|
<div class="node-form-title">
|
||||||
<el-icon class="node-icon"><Document /></el-icon>
|
<el-icon class="node-icon"><Document /></el-icon>
|
||||||
<span>{{ node.name }}</span>
|
<span>{{ node.name }}</span>
|
||||||
@@ -200,9 +211,9 @@
|
|||||||
|
|
||||||
<div class="form-grid">
|
<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
|
<el-form-item
|
||||||
v-for="field in node.config.formConfig"
|
v-for="field in node.formConfig"
|
||||||
:key="`${node.id}_${field.label}`"
|
:key="`${node.id}_${field.label}`"
|
||||||
:label="field.label"
|
:label="field.label"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
@@ -389,17 +400,22 @@
|
|||||||
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
|
<el-button type="primary" @click="confirmSaveWorkflow" :loading="saving">{{ currentEditingWorkflowId ? '确定更新' : '确定保存' }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 技能选择器 -->
|
||||||
|
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkill" @confirm="handleSkillConfirm" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
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 LogicFlow from '@logicflow/core';
|
||||||
import { Control, SelectionSelect } from '@logicflow/extension';
|
import { Control, SelectionSelect } from '@logicflow/extension';
|
||||||
import '@logicflow/core/dist/index.css';
|
import '@logicflow/core/dist/index.css';
|
||||||
import '@logicflow/extension/lib/style/index.css';
|
import '@logicflow/extension/lib/style/index.css';
|
||||||
|
import SkillSelector from '/@/components/skill/NodeSkillSelector.vue';
|
||||||
|
import type { SkillItem } from '/@/api/digitalHuman/skill';
|
||||||
import {
|
import {
|
||||||
downloadToFile,
|
downloadToFile,
|
||||||
getCreationList,
|
getCreationList,
|
||||||
@@ -442,6 +458,8 @@ const selectedElement = ref<SelectedState | null>(null);
|
|||||||
const customFields = ref<Array<{ label: string; value: string; type: string; required: boolean }>>([]);
|
const customFields = ref<Array<{ label: string; value: string; type: string; required: boolean }>>([]);
|
||||||
const selectedParentParam = ref('');
|
const selectedParentParam = ref('');
|
||||||
const selectedModel = ref('');
|
const selectedModel = ref('');
|
||||||
|
const showSkillSelector = ref(false);
|
||||||
|
const selectedSkill = ref<SkillItem | null>(null);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
|
const leftPanelTab = ref('selected'); // 默认显示"当前选中"Tab
|
||||||
const saveDialogVisible = ref(false);
|
const saveDialogVisible = ref(false);
|
||||||
@@ -489,7 +507,7 @@ const logicFlowRef = ref<HTMLDivElement | null>(null);
|
|||||||
const logicFlowInstance = ref<LogicFlow | null>(null);
|
const logicFlowInstance = ref<LogicFlow | null>(null);
|
||||||
const nodeSpawnIndex = ref(0);
|
const nodeSpawnIndex = ref(0);
|
||||||
const formState = reactive({ text: '', nodeCode: '', field: '' });
|
const formState = reactive({ text: '', nodeCode: '', field: '' });
|
||||||
const dynamicFormValues = reactive<Record<string, any>>({});
|
const dynamicFormValues = reactive<Record<string, any>>({ modelApiKey: '' });
|
||||||
const nodeSchemaMap = computed(() => {
|
const nodeSchemaMap = computed(() => {
|
||||||
const map: Record<string, NodeLibraryFormItem[]> = {};
|
const map: Record<string, NodeLibraryFormItem[]> = {};
|
||||||
nodeLibraryGroups.value.forEach((group) => {
|
nodeLibraryGroups.value.forEach((group) => {
|
||||||
@@ -512,6 +530,18 @@ const currentNodeModelConfig = computed(() => {
|
|||||||
});
|
});
|
||||||
return modelConfigs;
|
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[]>(() => {
|
const currentModelForm = computed<NodeLibraryFormItem[]>(() => {
|
||||||
if (!selectedModel.value) return [];
|
if (!selectedModel.value) return [];
|
||||||
@@ -595,6 +625,7 @@ const workflowDsl = computed(() => ({
|
|||||||
nodeCode: n.properties?.nodeCode || 'unknown',
|
nodeCode: n.properties?.nodeCode || 'unknown',
|
||||||
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
|
name: typeof n.text === 'string' ? n.text : n.text?.value || '',
|
||||||
type: n.type || 'rect',
|
type: n.type || 'rect',
|
||||||
|
skillName: n.properties?.skillName || null,
|
||||||
config: {
|
config: {
|
||||||
nodeCode: n.properties?.nodeCode || 'unknown',
|
nodeCode: n.properties?.nodeCode || 'unknown',
|
||||||
width: n.properties?.width || 100,
|
width: n.properties?.width || 100,
|
||||||
@@ -708,6 +739,38 @@ const handlePageChange = (page: number) => {
|
|||||||
workflowPagination.pageNum = page;
|
workflowPagination.pageNum = page;
|
||||||
fetchWorkflowList();
|
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) => {
|
const useWorkflow = async (workflow: WorkflowItem) => {
|
||||||
try {
|
try {
|
||||||
@@ -724,17 +787,18 @@ const useWorkflow = async (workflow: WorkflowItem) => {
|
|||||||
// 根据 nodeInputParams 初始化表单默认值
|
// 根据 nodeInputParams 初始化表单默认值
|
||||||
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
if (res.data.nodeInputParams && Array.isArray(res.data.nodeInputParams)) {
|
||||||
res.data.nodeInputParams.forEach((node: any) => {
|
res.data.nodeInputParams.forEach((node: any) => {
|
||||||
if (node.config?.formConfig && Array.isArray(node.config.formConfig)) {
|
// 从节点根级别的 formConfig 读取(不是 node.config.formConfig)
|
||||||
node.config.formConfig.forEach((field: any) => {
|
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||||||
|
node.formConfig.forEach((field: any) => {
|
||||||
const fieldKey = `${node.id}_${field.label}`;
|
const fieldKey = `${node.id}_${field.label}`;
|
||||||
creationFormValues[fieldKey] = field.value || '';
|
creationFormValues[fieldKey] = field.value || '';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化其他配置字段
|
// 初始化其他配置字段(从 config 中读取)
|
||||||
if (node.config) {
|
if (node.config) {
|
||||||
Object.keys(node.config).forEach((key) => {
|
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}`;
|
const fieldKey = `${node.id}_${key}`;
|
||||||
creationFormValues[fieldKey] = node.config[key];
|
creationFormValues[fieldKey] = node.config[key];
|
||||||
}
|
}
|
||||||
@@ -979,18 +1043,11 @@ const addParentParam = (value: string) => {
|
|||||||
inputSource,
|
inputSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新 selectedElement 以触发界面刷新
|
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
selectedElement.value.properties = {
|
||||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
...currentProps,
|
||||||
if (n) {
|
inputSource,
|
||||||
selectedElement.value = {
|
};
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
kind: 'node',
|
|
||||||
properties: n.properties || {},
|
|
||||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDsl();
|
syncDsl();
|
||||||
ElMessage.success(`已添加上级参数:${paramName}`);
|
ElMessage.success(`已添加上级参数:${paramName}`);
|
||||||
@@ -1022,23 +1079,18 @@ const removeInputSource = (nodeId: string, paramName: string) => {
|
|||||||
inputSource.splice(nodeIndex, 1);
|
inputSource.splice(nodeIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newInputSource = inputSource.length > 0 ? inputSource : null;
|
||||||
|
|
||||||
lf.setProperties(selectedElement.value.id, {
|
lf.setProperties(selectedElement.value.id, {
|
||||||
...currentProps,
|
...currentProps,
|
||||||
inputSource: inputSource.length > 0 ? inputSource : null,
|
inputSource: newInputSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新 selectedElement 以触发界面刷新
|
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
selectedElement.value.properties = {
|
||||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
...currentProps,
|
||||||
if (n) {
|
inputSource: newInputSource,
|
||||||
selectedElement.value = {
|
};
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
kind: 'node',
|
|
||||||
properties: n.properties || {},
|
|
||||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDsl();
|
syncDsl();
|
||||||
ElMessage.success(`已删除参数:${paramName}`);
|
ElMessage.success(`已删除参数:${paramName}`);
|
||||||
@@ -1067,18 +1119,11 @@ const updateQuoteOutput = (nodeId: string, enabled: boolean) => {
|
|||||||
inputSource,
|
inputSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新 selectedElement 以触发界面刷新
|
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
selectedElement.value.properties = {
|
||||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
...currentProps,
|
||||||
if (n) {
|
inputSource,
|
||||||
selectedElement.value = {
|
};
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
kind: 'node',
|
|
||||||
properties: n.properties || {},
|
|
||||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDsl();
|
syncDsl();
|
||||||
|
|
||||||
@@ -1194,18 +1239,11 @@ const toggleNodeOutput = (nodeId: string, enabled: boolean) => {
|
|||||||
inputSource,
|
inputSource,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新 selectedElement 以触发界面刷新
|
// 只更新 properties,不重新赋值整个 selectedElement,避免触发 watch 重置表单
|
||||||
const g = lf.getGraphData() as { nodes: Item[]; edges: Item[] };
|
selectedElement.value.properties = {
|
||||||
const n = g.nodes.find((x) => x.id === selectedElement.value?.id);
|
...currentProps,
|
||||||
if (n) {
|
inputSource,
|
||||||
selectedElement.value = {
|
};
|
||||||
id: n.id,
|
|
||||||
type: n.type,
|
|
||||||
kind: 'node',
|
|
||||||
properties: n.properties || {},
|
|
||||||
text: typeof n.text === 'string' ? n.text : n.text?.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDsl();
|
syncDsl();
|
||||||
|
|
||||||
@@ -1259,14 +1297,20 @@ watch(
|
|||||||
formState.text = String(e?.text || '');
|
formState.text = String(e?.text || '');
|
||||||
formState.nodeCode = String(e?.properties?.nodeCode || '');
|
formState.nodeCode = String(e?.properties?.nodeCode || '');
|
||||||
formState.field = String(e?.properties?.field || '');
|
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)
|
// 加载自定义字段和基础字段(从 formConfig)
|
||||||
customFields.value = [];
|
customFields.value = [];
|
||||||
if (e?.properties?.formConfig && Array.isArray(e.properties.formConfig)) {
|
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) => {
|
e.properties.formConfig.forEach((fieldConfig: any) => {
|
||||||
if (baseFieldNames.has(fieldConfig.field)) {
|
if (baseFieldNames.has(fieldConfig.field)) {
|
||||||
// 基础字段:加载到 dynamicFormValues
|
// 基础字段:加载到 dynamicFormValues
|
||||||
@@ -1304,15 +1348,42 @@ watch(
|
|||||||
dynamicFormValues.modelApiKey = e?.properties?.modelApiKey || '';
|
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) {
|
if (!selectedModel.value && nodeModelConfigs.length > 0) {
|
||||||
selectedModel.value = currentNodeModelConfig.value[0].modelName;
|
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) => {
|
allFormFields.value.forEach((fieldItem) => {
|
||||||
// 如果已经从 formConfig 或 modelConfig 加载过,跳过
|
// 如果已经从 formConfig 或 modelConfig 加载过,跳过
|
||||||
if (dynamicFormValues[fieldItem.field] !== undefined) {
|
if (dynamicFormValues[fieldItem.field] !== undefined && dynamicFormValues[fieldItem.field] !== '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1360,7 +1431,7 @@ const applySelected = () => {
|
|||||||
formState.field ? (p.field = formState.field) : delete p.field;
|
formState.field ? (p.field = formState.field) : delete p.field;
|
||||||
} else {
|
} 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) => {
|
Object.keys(p).forEach((key) => {
|
||||||
if (!keysToKeep.includes(key)) {
|
if (!keysToKeep.includes(key)) {
|
||||||
delete p[key];
|
delete p[key];
|
||||||
@@ -1423,7 +1494,7 @@ const applySelected = () => {
|
|||||||
type: fieldItem.type,
|
type: fieldItem.type,
|
||||||
field: fieldItem.field,
|
field: fieldItem.field,
|
||||||
label: fieldItem.label,
|
label: fieldItem.label,
|
||||||
value: value !== undefined && value !== null ? value : (fieldItem.default || ''),
|
value: value !== undefined && value !== null ? value : fieldItem.default || '',
|
||||||
required: fieldItem.required || false,
|
required: fieldItem.required || false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1672,14 +1743,31 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 转换后端 DSL 为 LogicFlow 格式
|
// 转换后端 DSL 为 LogicFlow 格式
|
||||||
const nodes = (dsl.nodes || []).map((n: any) => ({
|
const nodes = (dsl.nodes || []).map((n: any) => {
|
||||||
id: n.id,
|
// 判断是否为判断节点
|
||||||
type: n.type || 'rect',
|
const nodeCode = String(n.nodeCode || '').toLowerCase();
|
||||||
x: n.config?.x || 220,
|
const nodeName = String(n.name || '').toLowerCase();
|
||||||
y: n.config?.y || 140,
|
const isJudge = JUDGE_KEYWORDS.some((k) => nodeCode.includes(k) || nodeName.includes(k));
|
||||||
text: n.name || '',
|
const nodeType = isJudge ? 'diamond' : 'rect';
|
||||||
properties: n.config || {},
|
|
||||||
}));
|
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) => ({
|
const edges = (dsl.edges || []).map((e: any) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
@@ -1695,7 +1783,7 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
|||||||
syncDsl();
|
syncDsl();
|
||||||
ElMessage.success('工作流已加载');
|
ElMessage.success('工作流已加载');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 后端错误会自动显示
|
ElMessage.error('工作流加载失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -2659,4 +2747,19 @@ onBeforeUnmount(() => {
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
flex-shrink: 0;
|
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>
|
</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