Files
admin-ui/src/views/settings/skill/index.vue

477 lines
13 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>
<!-- 搜索栏 -->
<el-card shadow="hover" class="search-card">
<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-icon><Search /></el-icon>
搜索
</el-button>
</div>
</el-card>
<!-- 技能表格 -->
<el-card shadow="hover" class="table-card">
<el-table :data="skillList" v-loading="loading" stripe style="width: 100%">
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="name" label="技能名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="description" label="技能描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="fileName" label="文件名" min-width="150" show-overflow-tooltip />
<el-table-column prop="fileUrl" label="文件地址" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.fileUrl" target="_blank" type="primary" :underline="false">{{ row.fileUrl }}</el-link>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ formatTime(row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<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-card>
<!-- 创建/编辑对话框 -->
<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="file">
<el-upload
ref="uploadRef"
class="upload-area"
drag
:auto-upload="false"
:limit="1"
:on-change="handleFileChange"
:on-exceed="handleExceed"
:file-list="fileList"
accept=".zip"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text"> zip 文件拖到此处<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip">只支持 .zip 格式且压缩包内必须包含 .md 文件文件大小不超过 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">{{ isEdit ? '当前文件' : '上传成功' }}</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, UploadFilled } from '@element-plus/icons-vue';
import {
getUserSkillList,
createUserSkill,
updateUserSkill,
deleteUserSkill,
getUserSkillDetail,
type SkillItem,
type CreateSkillParams,
} from '/@/api/settings/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 isEdit = ref(false);
const currentEditId = ref<number | null>(null);
const formRef = ref<FormInstance>();
const uploadRef = ref<UploadInstance>();
const submitting = ref(false);
const fileList = ref<UploadUserFile[]>([]);
const formData = reactive<CreateSkillParams>({ name: '', description: '', fileName: '', fileUrl: '' });
const formRules: FormRules = {
name: [{ required: true, message: '请输入技能名称', trigger: 'blur' }],
};
const formatTime = (time: string) => (time ? time.replace('T', ' ').split('.')[0] : '');
// 验证 zip 文件中是否包含 .md 文件
const validateZipContainsMd = async (file: File): Promise<boolean> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const arrayBuffer = e.target?.result as ArrayBuffer;
// 使用 JSZip 库解析 zip 文件
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(arrayBuffer);
// 检查是否有 .md 文件
const hasMdFile = Object.keys(zip.files).some(filename => filename.toLowerCase().endsWith('.md'));
resolve(hasMdFile);
} catch (error) {
console.error('解析 zip 文件失败:', error);
resolve(false);
}
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file);
});
};
const handleFileChange: UploadProps['onChange'] = async (uploadFile) => {
if (!uploadFile.raw) return;
// 1. 验证文件格式必须是 zip
const fileName = uploadFile.name.toLowerCase();
if (!fileName.endsWith('.zip')) {
ElMessage.warning('只支持上传 .zip 格式的文件');
fileList.value = [];
return;
}
// 2. 验证文件大小
const maxSize = 100 * 1024 * 1024;
if (uploadFile.raw.size > maxSize) {
ElMessage.warning('文件大小不能超过 100MB');
fileList.value = [];
return;
}
// 3. 验证 zip 包内必须包含 .md 文件
ElMessage.info('正在验证文件...');
const hasMdFile = await validateZipContainsMd(uploadFile.raw);
if (!hasMdFile) {
ElMessage.warning('zip 压缩包内必须包含 .md 文件');
fileList.value = [];
return;
}
// 4. 上传文件
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 = () => {
isEdit.value = false;
currentEditId.value = null;
dialogTitle.value = '创建技能';
Object.assign(formData, { name: '', description: '', fileName: '', fileUrl: '' });
fileList.value = [];
dialogVisible.value = true;
};
const handleEdit = async (row: SkillItem) => {
isEdit.value = true;
currentEditId.value = row.id;
dialogTitle.value = '编辑技能';
try {
// 获取详情
const res = await getUserSkillDetail(row.id);
const detail = res.data || row;
Object.assign(formData, {
name: detail.name,
description: detail.description,
fileName: detail.fileName,
fileUrl: detail.fileUrl,
});
// 如果有文件,设置文件列表用于显示
if (detail.fileName) {
fileList.value = [
{
name: detail.fileName,
url: detail.fileUrl,
} as UploadUserFile,
];
} else {
fileList.value = [];
}
dialogVisible.value = true;
} catch (error) {
ElMessage.error('获取技能详情失败');
}
};
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 {
const submitData = {
name: formData.name,
description: formData.description,
fileName: formData.fileName,
fileUrl: formData.fileUrl,
};
if (isEdit.value && currentEditId.value) {
// 编辑
await updateUserSkill({ ...submitData, id: currentEditId.value });
ElMessage.success('更新成功');
} else {
// 创建
await createUserSkill(submitData);
ElMessage.success('创建成功');
}
dialogVisible.value = false;
fetchSkillList();
} catch {
// 接口错误由 request 全局提示后端 message
} finally {
submitting.value = false;
}
});
};
const handleDelete = async (row: SkillItem) => {
try {
await ElMessageBox.confirm(`确定要删除技能"${row.name}"吗?此操作不可恢复。`, '删除确认', {
confirmButtonText: '确定删除',
cancelButtonText: '取消',
type: 'warning',
});
await deleteUserSkill(row.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: 20px;
}
.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-card {
margin-bottom: 20px;
}
.search-bar {
display: flex;
gap: 12px;
}
.search-input {
flex: 1;
max-width: 400px;
}
.table-card {
:deep(.el-card__body) {
padding: 20px;
}
}
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.upload-area {
width: 100%;
: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;
}
</style>