修改教学案例管理
This commit is contained in:
@@ -1,7 +1,706 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<el-config-provider :locale="zhCn">
|
||||||
</div>
|
<div class="p-6 bg-white rounded-lg shadow-md min-h-screen pb-24" v-loading="loading">
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<span class="text-2xl font-bold text-gray-700">教学案例管理列表</span>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="handleAdd">新增案例</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<div class="mb-6 flex gap-4">
|
||||||
|
<el-input
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索案例标题/导师姓名"
|
||||||
|
clearable
|
||||||
|
style="width: 300px;"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" @click="fetchData">搜索</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="tableData" style="width: 100%" row-key="id" max-height="82vh">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" sortable fixed></el-table-column>
|
||||||
|
<el-table-column label="封面" width="180">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-image
|
||||||
|
style="width: 120px; height: 70px; border-radius: 6px;"
|
||||||
|
:src="scope.row.cover_url || defaultCover"
|
||||||
|
:preview-src-list="[scope.row.cover_url || defaultCover]"
|
||||||
|
fit="cover"
|
||||||
|
:preview-teleported="true"
|
||||||
|
hide-on-click-modal
|
||||||
|
>
|
||||||
|
<template #error>
|
||||||
|
<div class="flex items-center justify-center w-full h-full bg-gray-100 text-gray-500">无封面</div>
|
||||||
|
</template>
|
||||||
|
</el-image>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="title" label="案例标题" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="font-semibold">{{ scope.row.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="tutor_name" label="导师姓名" width="120" show-overflow-tooltip></el-table-column>
|
||||||
|
<el-table-column prop="tutor_title" label="导师头衔" width="180" show-overflow-tooltip></el-table-column>
|
||||||
|
<el-table-column prop="student_names" label="学生姓名" min-width="180" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<span :title="scope.row.student_names">{{ scope.row.student_names.length > 15 ? `${scope.row.student_names.slice(0,15)}...` : scope.row.student_names }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort" label="排序" width="100" sortable></el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="创建时间" width="180" sortable></el-table-column>
|
||||||
|
<el-table-column prop="update_time" label="更新时间" width="180" sortable></el-table-column>
|
||||||
|
<el-table-column label="案例内容" min-width="250">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="content-preview" :title="stripHtml(scope.row.content)">
|
||||||
|
{{ stripHtml(scope.row.content).length > 100 ? `${stripHtml(scope.row.content).slice(0, 100)}...` : stripHtml(scope.row.content) }}
|
||||||
|
</div>
|
||||||
|
<el-button size="mini" type="text" class="mt-1 text-blue-600" @click="handleViewContent(scope.row)">查看详情</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-button size="small" type="primary" :icon="Edit" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" :icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<!-- 内容详情弹窗 -->
|
||||||
|
<el-dialog v-model="contentDialogVisible" title="案例内容详情" :width="`800px`" :before-close="handleCloseDialog">
|
||||||
|
<h3 class="text-xl font-bold text-gray-800 mb-4">标题: {{ currentCase.title }}</h3>
|
||||||
|
<div class="flex flex-wrap gap-4 mb-6 text-gray-600">
|
||||||
|
<div><span class="font-semibold">导师:</span>{{ currentCase.tutor_name }} {{ currentCase.tutor_title || '' }}</div>
|
||||||
|
<div><span class="font-semibold">参与学生:</span>{{ currentCase.student_names }}</div>
|
||||||
|
<div><span class="font-semibold">创建时间:</span>{{ currentCase.create_time }}</div>
|
||||||
|
<div><span class="font-semibold">最后更新:</span>{{ currentCase.update_time }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="content-full text-gray-700 leading-relaxed whitespace-pre-wrap" v-html="currentCase.content || '当前案例暂无内容'">
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页 -->
|
||||||
|
<div class="fixed bottom-0 right-0 w-full p-4 bg-white shadow-[0_-2px_5px_rgba(0,0,0,0.05)] flex justify-end z-10 border-t border-gray-200">
|
||||||
|
<el-pagination
|
||||||
|
class="custom-pagination"
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
></el-pagination>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑/新增抽屉 -->
|
||||||
|
<el-drawer
|
||||||
|
v-model="drawerVisible"
|
||||||
|
:title="form.id ? '编辑教学案例' : '新增教学案例'"
|
||||||
|
direction="rtl"
|
||||||
|
size="60%"
|
||||||
|
:before-close="handleDrawerClose"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="publish-form-container">
|
||||||
|
<div class="form-group title-group">
|
||||||
|
<label class="form-label required">案例标题</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="请输入案例标题"
|
||||||
|
clearable
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
maxlength="255"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group cover-group">
|
||||||
|
<label class="form-label">案例封面</label>
|
||||||
|
<el-upload
|
||||||
|
action="http://localhost:8080/api/upload/cover"
|
||||||
|
name="image"
|
||||||
|
:show-file-list="false"
|
||||||
|
:on-success="handleCoverSuccess"
|
||||||
|
:before-upload="beforeCoverUpload"
|
||||||
|
:on-error="handleCoverError"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
>
|
||||||
|
<img v-if="form.cover_url" :src="form.cover_url" class="cover-preview" alt="封面"/>
|
||||||
|
<el-icon v-else class="cover-uploader-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
<p class="text-gray-500 text-sm mt-2">支持JPG、PNG格式,大小不超过5MB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<div class="form-group tutor-name-group">
|
||||||
|
<label class="form-label required">导师姓名</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.tutor_name"
|
||||||
|
placeholder="请输入导师姓名"
|
||||||
|
clearable
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group tutor-title-group">
|
||||||
|
<label class="form-label">导师头衔</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.tutor_title"
|
||||||
|
placeholder="请输入导师头衔(如:计算机学院副教授)"
|
||||||
|
clearable
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
maxlength="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group student-names-group">
|
||||||
|
<label class="form-label required">学生姓名</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.student_names"
|
||||||
|
placeholder="多人用逗号分隔(如:张三,李四,王五)"
|
||||||
|
clearable
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
maxlength="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group sort-group">
|
||||||
|
<label class="form-label">排序</label>
|
||||||
|
<el-input
|
||||||
|
v-model.number="form.sort"
|
||||||
|
placeholder="数字越小越靠前,默认0"
|
||||||
|
clearable
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group content-group">
|
||||||
|
<label class="form-label required">案例内容</label>
|
||||||
|
<div ref="editorRef" class="quill-editor"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div style="flex: auto">
|
||||||
|
<el-button @click="handleDrawerClose">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitCase" :loading="isSubmitting">
|
||||||
|
{{ form.id ? '更新案例' : '新增案例' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox, ElConfigProvider, ElDrawer, ElInput, ElUpload, ElPagination, ElTable, ElTableColumn, ElImage, ElDialog } from 'element-plus';
|
||||||
|
import { Edit, Delete, Plus } from '@element-plus/icons-vue';
|
||||||
|
import Quill from 'quill';
|
||||||
|
import 'quill/dist/quill.snow.css';
|
||||||
|
import type { UploadProps } from 'element-plus';
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||||
|
|
||||||
|
// --- 类型定义 ---
|
||||||
|
interface TeachingCase {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
tutor_name: string;
|
||||||
|
tutor_title: string;
|
||||||
|
student_names: string;
|
||||||
|
content: string;
|
||||||
|
cover_url: string;
|
||||||
|
sort: number;
|
||||||
|
create_time: string;
|
||||||
|
update_time: string;
|
||||||
|
is_delete: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListTeachingCaseReq {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
keyword: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateTeachingCaseReq {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
tutor_name: string;
|
||||||
|
tutor_title: string;
|
||||||
|
student_names: string;
|
||||||
|
content: string;
|
||||||
|
cover_url: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateTeachingCaseReq extends CreateTeachingCaseReq {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 常量定义 ---
|
||||||
|
const API_BASE_URL = 'http://localhost:8080/api';
|
||||||
|
const defaultCover = 'https://via.placeholder.com/120x70?text=无封面';
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 列表页相关状态与逻辑
|
||||||
|
// =================================================================
|
||||||
|
const loading = ref(true);
|
||||||
|
const tableData = ref<TeachingCase[]>([]);
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(10);
|
||||||
|
const total = ref(0);
|
||||||
|
const contentDialogVisible = ref(false);
|
||||||
|
const currentCase = ref<TeachingCase>({
|
||||||
|
id: 0,
|
||||||
|
title: '',
|
||||||
|
tutor_name: '',
|
||||||
|
tutor_title: '',
|
||||||
|
student_names: '',
|
||||||
|
content: '',
|
||||||
|
cover_url: '',
|
||||||
|
sort: 0,
|
||||||
|
create_time: '',
|
||||||
|
update_time: '',
|
||||||
|
is_delete: 0
|
||||||
|
});
|
||||||
|
const searchKeyword = ref('');
|
||||||
|
|
||||||
|
// --- HTML清理函数 ---
|
||||||
|
const stripHtml = (html: string) => {
|
||||||
|
if (!html) return '';
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
return tempDiv.textContent || tempDiv.innerText || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 获取案例列表 ---
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const reqData: ListTeachingCaseReq = {
|
||||||
|
page: currentPage.value,
|
||||||
|
size: pageSize.value,
|
||||||
|
keyword: searchKeyword.value.trim(),
|
||||||
|
sort: 0
|
||||||
|
};
|
||||||
|
const response = await fetch(`${API_BASE_URL}/teaching-cases/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(reqData)
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`请求失败!状态码:${response.status}`);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log("接受到:", data); // 打印数据确认结构
|
||||||
|
|
||||||
|
// 核心修正:后端返回的是小写list和total,与后端字段名保持一致
|
||||||
|
tableData.value = data.list || []; // 原代码是data.List,改为data.list
|
||||||
|
total.value = data.total || 0; // 原代码是data.Total,改为data.total
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] 获取教学案例列表失败:', error);
|
||||||
|
ElMessage.error('获取教学案例列表失败,请检查接口或网络!');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchData);
|
||||||
|
|
||||||
|
// --- 查看详情 ---
|
||||||
|
const handleViewContent = (row: TeachingCase) => {
|
||||||
|
currentCase.value = { ...row };
|
||||||
|
contentDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
contentDialogVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 删除案例 ---
|
||||||
|
const handleDelete = (row: TeachingCase) => {
|
||||||
|
ElMessageBox.confirm(`确定要删除案例《${row.title}》吗?此操作无法撤销!`, '警告', {
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/teaching-cases/${row.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errData?.msg || '删除失败');
|
||||||
|
}
|
||||||
|
ElMessage.success('删除成功!');
|
||||||
|
fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ERROR] 删除教学案例失败:', error);
|
||||||
|
ElMessage.error(`删除教学案例失败: ${(error as Error).message}`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
ElMessage.info('已取消删除');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 分页处理 ---
|
||||||
|
const handleSizeChange = (val: number) => {
|
||||||
|
pageSize.value = val;
|
||||||
|
currentPage.value = 1;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCurrentChange = (val: number) => {
|
||||||
|
currentPage.value = val;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// 抽屉编辑/新增相关状态与逻辑
|
||||||
|
// =================================================================
|
||||||
|
const drawerVisible = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
// --- 表单默认值 ---
|
||||||
|
const defaultFormState = () => ({
|
||||||
|
id: null as number | null,
|
||||||
|
title: '',
|
||||||
|
tutor_name: '',
|
||||||
|
tutor_title: '',
|
||||||
|
student_names: '',
|
||||||
|
content: '',
|
||||||
|
cover_url: '',
|
||||||
|
sort: 0,
|
||||||
|
});
|
||||||
|
const form = ref(defaultFormState());
|
||||||
|
|
||||||
|
// --- 富文本编辑器 ---
|
||||||
|
const editorRef = ref<HTMLDivElement | null>(null);
|
||||||
|
let quillInstance: Quill | null = null;
|
||||||
|
|
||||||
|
const initQuillEditor = () => {
|
||||||
|
if (editorRef.value && !quillInstance) {
|
||||||
|
quillInstance = new Quill(editorRef.value, {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: {
|
||||||
|
container: [
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
['blockquote', 'code-block'],
|
||||||
|
[{ 'header': 1 }, { 'header': 2 }],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
],
|
||||||
|
handlers: {
|
||||||
|
image: handleEditorImageUpload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeholder: '请详细描述教学案例的背景、实现过程、成果等内容...'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听粘贴图片
|
||||||
|
quillInstance.on('text-change', (delta, _, source) => {
|
||||||
|
if (source === 'user') {
|
||||||
|
const pastedBase64 = getPastedBase64Image(delta);
|
||||||
|
if (pastedBase64) {
|
||||||
|
const selection = quillInstance?.getSelection();
|
||||||
|
if (!selection) return;
|
||||||
|
|
||||||
|
const imageIndex = selection.index;
|
||||||
|
quillInstance?.deleteText(imageIndex, 1);
|
||||||
|
const blob = base64ToBlob(pastedBase64);
|
||||||
|
|
||||||
|
if (blob) {
|
||||||
|
const file = new File([blob], `pasted-image-${Date.now()}.png`, { type: blob.type });
|
||||||
|
uploadEditorImage(file, imageIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 监听抽屉显示状态 ---
|
||||||
|
watch(drawerVisible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
nextTick(() => {
|
||||||
|
initQuillEditor();
|
||||||
|
if (quillInstance) {
|
||||||
|
quillInstance.root.innerHTML = form.value.content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
quillInstance = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 编辑器图片上传相关函数 ---
|
||||||
|
function handleEditorImageUpload() {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
|
||||||
|
fileInput.onchange = () => {
|
||||||
|
const file = fileInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const selection = quillInstance?.getSelection();
|
||||||
|
const insertIndex = selection ? selection.index : quillInstance?.getLength() || 0;
|
||||||
|
uploadEditorImage(file, insertIndex);
|
||||||
|
fileInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadEditorImage(file: File, insertIndex: number) {
|
||||||
|
if (!quillInstance) return;
|
||||||
|
|
||||||
|
quillInstance.insertText(insertIndex, '[图片上传中...]', { color: '#999', italic: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/upload/image`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP状态码:${response.status}`);
|
||||||
|
const result = await response.json();
|
||||||
|
const imageUrl = result.data?.url;
|
||||||
|
|
||||||
|
if (!imageUrl) throw new Error('未从响应中获取到图片地址');
|
||||||
|
|
||||||
|
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
||||||
|
quillInstance.insertEmbed(insertIndex, 'image', imageUrl);
|
||||||
|
quillInstance.setSelection(insertIndex + 1);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : '未知错误';
|
||||||
|
quillInstance.deleteText(insertIndex, '[图片上传中...]'.length);
|
||||||
|
quillInstance.insertText(insertIndex, `[图片上传失败:${errMsg}]`, { color: '#f56c6c', italic: true });
|
||||||
|
ElMessage.error(`编辑器图片上传失败:${errMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Base64图片处理 ---
|
||||||
|
function getPastedBase64Image(delta: any): string | null {
|
||||||
|
if (!delta?.ops) return null;
|
||||||
|
for (const op of delta.ops) {
|
||||||
|
if (
|
||||||
|
op.insert &&
|
||||||
|
typeof op.insert === 'object' &&
|
||||||
|
op.insert.image &&
|
||||||
|
op.insert.image.startsWith('data:image')
|
||||||
|
) {
|
||||||
|
return op.insert.image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(base64: string): Blob | null {
|
||||||
|
try {
|
||||||
|
const [prefix, data] = base64.split(',');
|
||||||
|
if (!prefix || !data) throw new Error('base64格式错误');
|
||||||
|
|
||||||
|
const mimeMatch = prefix.match(/data:(.*?);/);
|
||||||
|
if (!mimeMatch) throw new Error('无法解析图片类型');
|
||||||
|
const mime = mimeMatch[1];
|
||||||
|
|
||||||
|
const binaryData = atob(data);
|
||||||
|
const bytes = new Uint8Array(binaryData.length);
|
||||||
|
for (let i = 0; i < binaryData.length; i++) {
|
||||||
|
bytes[i] = binaryData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: mime });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('base64转Blob失败:', error);
|
||||||
|
ElMessage.error('解析粘贴的图片失败!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 操作处理 ---
|
||||||
|
const handleAdd = () => {
|
||||||
|
form.value = defaultFormState();
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (row: TeachingCase) => {
|
||||||
|
form.value = {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
tutor_name: row.tutor_name,
|
||||||
|
tutor_title: row.tutor_title,
|
||||||
|
student_names: row.student_names,
|
||||||
|
content: row.content,
|
||||||
|
cover_url: row.cover_url,
|
||||||
|
sort: row.sort,
|
||||||
|
};
|
||||||
|
drawerVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrawerClose = () => {
|
||||||
|
drawerVisible.value = false;
|
||||||
|
form.value = defaultFormState();
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 提交案例(新增/更新) ---
|
||||||
|
const submitCase = async () => {
|
||||||
|
// 表单校验
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
ElMessage.warning('请输入案例标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.value.tutor_name.trim()) {
|
||||||
|
ElMessage.warning('请输入导师姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.value.student_names.trim()) {
|
||||||
|
ElMessage.warning('请输入学生姓名');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contentHTML = quillInstance?.root.innerHTML || '';
|
||||||
|
if (!contentHTML.trim()) {
|
||||||
|
ElMessage.warning('请输入案例内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
|
||||||
|
// 构造提交数据
|
||||||
|
const submitData: CreateTeachingCaseReq | UpdateTeachingCaseReq = {
|
||||||
|
id: form.value.id,
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
tutor_name: form.value.tutor_name.trim(),
|
||||||
|
tutor_title: form.value.tutor_title.trim(),
|
||||||
|
student_names: form.value.student_names.trim(),
|
||||||
|
content: contentHTML,
|
||||||
|
cover_url: form.value.cover_url.trim(),
|
||||||
|
sort: form.value.sort || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果是编辑,添加id
|
||||||
|
if (form.value.id) {
|
||||||
|
(submitData as UpdateTeachingCaseReq).id = form.value.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `${API_BASE_URL}/teaching-cases`;
|
||||||
|
let method = 'POST';
|
||||||
|
|
||||||
|
// 编辑模式
|
||||||
|
if (form.value.id) {
|
||||||
|
url = `${API_BASE_URL}/teaching-cases`;
|
||||||
|
method = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(submitData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errData = await response.json().catch(() => null);
|
||||||
|
throw new Error(errData?.msg || '提交失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
ElMessage.success(form.value.id ? '案例更新成功!' : '案例新增成功!');
|
||||||
|
drawerVisible.value = false;
|
||||||
|
form.value = defaultFormState();
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
ElMessage.error(`${form.value.id ? '更新' : '新增'}失败: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 封面上传处理 ---
|
||||||
|
const handleCoverSuccess: UploadProps['onSuccess'] = (response) => {
|
||||||
|
const ossUrl = response.data?.url;
|
||||||
|
if (ossUrl) {
|
||||||
|
form.value.cover_url = ossUrl;
|
||||||
|
ElMessage.success('封面上传成功');
|
||||||
|
} else {
|
||||||
|
ElMessage.error('封面上传失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeCoverUpload: UploadProps['beforeUpload'] = (rawFile) => {
|
||||||
|
const isImage = rawFile.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error('请上传图片文件!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isLt5M = rawFile.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
ElMessage.error('图片大小不能超过 5MB!');
|
||||||
|
}
|
||||||
|
return isImage && isLt5M;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverError = () => {
|
||||||
|
ElMessage.error('封面上传失败');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 基础样式 */
|
||||||
|
::v-deep(.el-drawer__body) { padding: 20px 0 !important; }
|
||||||
|
.required::after { content: '*'; color: #f56c6c; margin-left: 4px; }
|
||||||
|
|
||||||
|
/* 表格样式 */
|
||||||
|
.el-table .el-table__cell { vertical-align: middle; }
|
||||||
|
.el-table__header-wrapper th { background-color: #fafafa !important; font-weight: 600; color: #333; }
|
||||||
|
.action-buttons .el-button { margin-right: 8px; }
|
||||||
|
.content-preview { color: #666; line-height: 1.5; word-break: break-all; }
|
||||||
|
.content-full { min-height: 300px; padding: 20px; background-color: #f9fafb; border-radius: 8px; }
|
||||||
|
.el-dialog__title { font-size: 18px !important; font-weight: 600 !important; }
|
||||||
|
|
||||||
|
/* 分页器样式 */
|
||||||
|
.custom-pagination { justify-content: flex-end !important; }
|
||||||
|
.custom-pagination .el-pagination__total,
|
||||||
|
.custom-pagination .el-pagination__sizes,
|
||||||
|
.custom-pagination .el-pagination__jump { margin-right: 16px !important; }
|
||||||
|
.custom-pagination .el-pagination__jump .el-input { width: 60px !important; }
|
||||||
|
|
||||||
|
/* 抽屉内表单样式 */
|
||||||
|
.publish-form-container { padding: 0 20px; }
|
||||||
|
.form-group { margin-bottom: 24px; }
|
||||||
|
.form-label { display: block; margin-bottom: 8px; color: #333; font-size: 14px; font-weight: 600; }
|
||||||
|
.quill-editor { height: 350px; border-radius: 4px; border: 1px solid #dcdfe6; }
|
||||||
|
.cover-uploader .el-upload { border: 1px dashed #d9d9d9; border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; }
|
||||||
|
.cover-uploader .el-upload:hover { border-color: #409EFF; }
|
||||||
|
.cover-uploader-icon { font-size: 28px; color: #8c939d; width: 178px; height: 178px; text-align: center; line-height: 178px; }
|
||||||
|
.cover-preview { width: 178px; height: 178px; display: block; object-fit: cover; }
|
||||||
|
|
||||||
|
/* 响应式样式 */
|
||||||
|
@media (max-width: 1440px) {
|
||||||
|
.el-table-column { min-width: 120px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格布局 */
|
||||||
|
.grid { display: grid; }
|
||||||
|
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.gap-6 { gap: 1.5rem; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user