Files
admin-ui/src/views/digitalHuman/skill/index.vue
2910410219 29838b030f 添加会话模型和API Key配置功能
- 在模型模块中新增会话开关状态字段,支持会话模型的管理。
- 更新模型选择器,增加系统模型的API Key配置弹窗,提升用户体验。
- 优化错误处理逻辑,确保接口错误由全局拦截器处理,减少冗余提示。
- 更新相关样式以增强界面可读性和美观性。
2026-05-11 20:01:03 +08:00

469 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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);
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);
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,
});
ElMessage.success('创建成功');
dialogVisible.value = false;
fetchSkillList();
} catch {
// 接口错误由 request 全局提示后端 message
} 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);
ElMessage.success('删除成功');
fetchSkillList();
} catch (error) {
if (error !== 'cancel') {
// 接口错误由 request 全局提示后端 message
}
}
}
};
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>