feat: 更新数字人创作页面以支持技能选择功能

- 在节点库项中新增技能选择选项,允许用户为节点指定技能
- 更新API请求路径,统一为'/ai-agent'前缀
- 优化动态表单逻辑,确保根据节点类型正确显示技能选择器
- 移除冗余的文件上传函数,改为导入公共上传函数以简化代码结构
This commit is contained in:
2026-05-08 19:06:36 +08:00
parent 0c6cfe5c17
commit 8cc5f4be64
10 changed files with 1251 additions and 2285 deletions

32
src/api/common/upload.ts Normal file
View 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,
});
}

View File

@@ -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,

View File

@@ -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',
}); });
} }

View 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,
});
}

View File

@@ -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({

View 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>

View 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>

View File

@@ -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

View 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>