newfront/src/views/Submit.vue
2025-04-21 22:27:46 +08:00

1584 lines
43 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="submit-page">
<Navbar />
<Loading :visible="isLoading" :text="loadingText" />
<!-- 课程选择模态窗口 -->
<div class="course-selector-modal" v-if="showCourseSelector">
<div class="modal-overlay" @click="closeCourseSelector"></div>
<div class="modal-container">
<div class="modal-header">
<h3>选择课程</h3>
<button class="close-btn" @click="closeCourseSelector">×</button>
</div>
<div class="modal-body">
<div class="search-bar">
<input
type="text"
v-model="courseSearch"
placeholder="请输入课程名称"
class="search-input"
@keyup.enter="searchCourses"
/>
<button class="search-btn" @click="searchCourses">搜索</button>
</div>
<div class="course-results">
<div v-if="searchLoading" class="course-loading">
<div class="spinner"></div>
<span>搜索中...</span>
</div>
<div v-else-if="searchError" class="search-error">
{{ searchError }}
</div>
<div v-else-if="courseResults.length === 0" class="no-results">
未找到相关课程
</div>
<table v-else class="course-table">
<thead>
<tr>
<th>选择</th>
<th>课程名称</th>
<th>课程类别</th>
<th>授课教师</th>
<th>评分</th>
</tr>
</thead>
<tbody>
<tr
v-for="course in courseResults"
:key="course.course_id"
@click="selectCourse(course)"
:class="{ 'selected': selectedCourse && selectedCourse.course_id === course.course_id }"
>
<td>
<input
type="radio"
:checked="selectedCourse && selectedCourse.course_id === course.course_id"
@click.stop="selectCourse(course)"
/>
</td>
<td>{{ course.course_name }}</td>
<td>{{ getCategoryName(course.category_id) }}</td>
<td>{{ course.teachers }}</td>
<td>
<span class="rating">{{ course.rating }}</span>
<span class="rating-count">({{ course.rating_count }}人评)</span>
</td>
</tr>
</tbody>
</table>
<!-- 分页控件 -->
<div class="pagination" v-if="totalPages > 1">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
上一页
</button>
<span class="page-info">第 {{ currentPage }} / {{ totalPages }} 页</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
下一页
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button
class="btn btn-secondary"
@click="closeCourseSelector"
>
取消
</button>
<button
class="btn btn-primary"
@click="confirmCourseSelection"
:disabled="!selectedCourse"
>
确定
</button>
</div>
</div>
</div>
<div class="container">
<div class="page-header">
<h1>{{ isEditMode ? '编辑投稿内容' : '内容投稿' }}</h1>
<p class="sub-title">感谢您分享您的学习资源或独到见解!</p>
</div>
<!-- 投稿表单 -->
<div class="submission-form">
<div class="form-section">
<div class="form-group">
<label for="title">标题 {{ isEditMode ? '(如需更改标题请不要更改【edit】前缀)' : '' }}</label>
<input
type="text"
id="title"
v-model="formData.title"
class="form-control"
placeholder="请输入投稿标题"
maxlength="50"
/>
</div>
<div class="form-group">
<label for="section">板块</label>
<select id="section" v-model="formData.section" class="form-control">
<option value="" disabled>请选择板块</option>
<option value="攻略指南">攻略指南</option>
<option value="资源共享">资源共享</option>
</select>
</div>
<div class="form-group">
<label for="note">备注信息 (选填)</label>
<input
type="text"
id="note"
v-model="formData.note"
class="form-control"
placeholder="可添加备注信息"
/>
</div>
</div>
<!-- 图片上传区 -->
<!-- <div class="form-section">
<h3>图片上传</h3>
<p class="section-desc">您可以上传图片并将图片标签插入到文章中</p>
<div class="upload-area">
<div class="file-input-container">
<label for="imagePicker" class="upload-label">
<span class="upload-icon">📷</span>
<span>选择图片</span>
</label>
<input
type="file"
id="imagePicker"
ref="imagePicker"
@change="uploadImage"
accept="image/*"
class="file-input"
/>
</div>
<div v-if="imageUrl" class="preview-area">
<div class="image-preview">
<img :src="imageUrl" alt="预览图" ref="imagePreview" />
</div>
<div class="image-info">
<p>图片HTML标签:</p>
<div class="tag-container">
<code>{{ imageTag }}</code>
</div>
<button type="button" class="btn btn-secondary" @click="copyImageUrl">
复制标签
</button>
</div>
</div>
</div>
</div> -->
<!-- 附件上传区 -->
<div class="form-section">
<h3>附件上传</h3>
<p class="section-desc">您可以上传最多10个文件总大小不超过500MB。</p>
<div class="upload-area">
<div class="file-input-container">
<label for="filePicker" class="upload-label">
<span class="upload-icon">📎</span>
<span>选择文件</span>
</label>
<input
type="file"
id="filePicker"
ref="filePicker"
@change="validateFiles"
multiple
class="file-input"
/>
</div>
<div v-if="selectedFiles.length > 0" class="file-list">
<h4>已选择的文件:</h4>
<ul>
<li v-for="(file, index) in selectedFiles" :key="index">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">({{ formatFileSize(file.size) }})</span>
</li>
</ul>
<p class="total-size">总大小: {{ formatFileSize(totalFileSize) }}</p>
</div>
</div>
</div>
<!-- 编辑器 -->
<div class="form-section">
<h3>内容编辑</h3>
<p class="section-desc">此部分主要用于"攻略指南"板块"资源共享"板块可以留空</p>
<p class="section-desc">使用Markdown语法编辑您的投稿内容插入图片后如需修改图片大小请在编辑器中找到插入的图片标签修改其width属性</p>
<p class="section-desc">如上传了附件且需在文中指定位置展示<strong>可以用[附件名]来表示附件位置</strong>如不指定则审核人员将按照文章内容在适当位置展示</p>
<p class="section-desc"><strong>草稿功能和文章页预览只针对此部分内容上传的附件等将不会保存并不支持文章页预览</strong></p>
<p class="section-desc" style="color: purple;">您可以轻松地插入课程卡片点击<svg data-v-bcfef25c="" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-linecap="round" stroke-linejoin="round" width="20" height="24" stroke-width="1.1" style="vertical-align: middle; display: inline-block; margin-bottom: 2px;"><path data-v-bcfef25c="" d="M22 9l-10 -4l-10 4l10 4l10 -4v6"></path><path data-v-bcfef25c="" d="M6 10.6v5.4a6 3 0 0 0 12 0v-5.4"></path></svg>按钮引用课程评分系统中的课程数据<strong>注意由于网站的安全设置课程卡片前必须有其他内容请勿在内容第一行插入课程卡片否则将无法正常显示</strong></p>
<p v-if="isEditMode" class="section-desc" style="color: red;"><strong>检测到您当前正在编辑已有的投稿内容请注意您之前如有其他投稿的草稿内容这些内容在此页面保存草稿或提交编辑后将不会保留</strong></p>
<div class="editor-container">
<MdEditor
v-model="formData.content"
ref="markdownEditor"
:theme="editorTheme"
:toolbars="editorToolbars"
language="zh-CN"
preview-theme="github"
:placeholder="editorPlaceholder"
@onUploadImg="handleImageUpload"
@onSave="saveDraftWithConfirm"
>
<template #defToolbars>
<span class="md-editor-toolbar-item" title="添加课程卡片" @click="chooseCourse">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="20" height="24" stroke-width="1.1">
<path d="M22 9l-10 -4l-10 4l10 4l10 -4v6"></path>
<path d="M6 10.6v5.4a6 3 0 0 0 12 0v-5.4"></path>
</svg>
</span>
</template>
</MdEditor>
</div>
<div class="draft-actions">
<button
type="button"
class="btn btn-secondary"
@click="loadDraft"
>
加载草稿
</button>
<button
type="button"
class="btn btn-secondary"
@click="saveDraftWithConfirm"
>
保存草稿
</button>
<button
type="button"
class="btn btn-secondary preview-btn"
@click="previewDraft"
>
在文章页中预览
</button>
</div>
</div>
<!-- 提交按钮 -->
<div class="form-actions">
<button type="button" class="btn btn-primary" @click="submitForm">
{{ isEditMode ? '提交编辑' : '提交投稿' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import Navbar from '@/components/NavBar.vue';
import MdEditor from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
import Cookies from 'js-cookie';
import messageBox from '@/utils/messageBox.js';
import Loading from '@/components/Loading.vue';
export default {
name: 'SubmitPage',
components: {
Navbar,
MdEditor,
Loading
},
data() {
return {
formData: {
title: '',
section: '',
note: '',
content: ''
},
selectedFiles: [],
totalFileSize: 0,
imageUrl: '',
imageTag: '',
editorTheme: 'light',
editorPlaceholder: '在此输入您的内容,支持直接把图片粘贴到编辑器中',
editorToolbars: [
'bold', 'italic', 'underline', 'sub', 'sup', '-', 'title', 'unorderedList',
'orderedList', '-', 'quote', 'link', 'image', 'table', 'codeRow', 'code', 'mermaid', 0,
'=', 'save', 'revoke', 'next', '-', 'preview', 'pageFullscreen', 'fullscreen'
],
isEditMode: false,
editArticleId: null,
isLoading: false,
loadingText: '获取投稿内容中...',
// 课程选择器相关状态
showCourseSelector: false,
courseSearch: '',
searchLoading: false,
searchError: null,
courseResults: [],
selectedCourse: null,
currentPage: 1,
totalPages: 1,
// 类别映射
categoryMap: {
1: '通识选修类',
2: '人文选修类',
3: '专业方向类',
4: '体育类',
5: '学科基础类',
6: '暑期国际课',
7: '数学与自然科学类',
8: '重修专栏',
9: '数学与自然科学类(必修)',
10: '人文社会科学类(必修)',
11: '学科基础类(必修)',
12: '专业方向类(必修)',
13: '实践类(必修)'
}
};
},
created() {
document.title = '内容投稿 - NEU小站';
// 检查登录状态,未登录则跳转到登录页面
this.checkLoginStatus().then(() => {
// 检查是否是编辑模式
this.checkEditMode();
});
},
methods: {
// 检查登录状态
async checkLoginStatus() {
const token = Cookies.get('token');
if (!token) {
this.$router.push('/login');
return Promise.reject(new Error('未登录'));
}
try {
const response = await fetch('https://newfront.xn--xhq44jb2fzpc.com/user/islogin', {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
throw new Error('验证失败');
}
const data = await response.json();
if (!data.isLoggedIn) {
// 删除无效的 token
Cookies.remove('token');
this.$router.push('/login');
return Promise.reject(new Error('登录已过期'));
}
return Promise.resolve();
} catch (error) {
console.error('登录验证失败:', error);
// 删除可能无效的 token
Cookies.remove('token');
this.$router.push('/login');
return Promise.reject(error);
}
},
// 检查是否是编辑模式
async checkEditMode() {
// 获取查询参数中的文章ID
const urlParams = new URLSearchParams(window.location.search);
const articleId = urlParams.get('article');
if (!articleId) return;
try {
// 更新界面状态
this.isEditMode = true;
this.editArticleId = articleId;
// 显示加载中组件
this.isLoading = true;
// 获取文章详情
const token = Cookies.get('token');
const response = await fetch(`https://newfront.xn--xhq44jb2fzpc.com/article?id=${articleId}&edit=true`, {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
throw new Error('获取文章失败');
}
const articleData = await response.json();
// 填充表单
this.formData.title = `【edit${articleId}${articleData.title}`;
this.formData.section = articleData.section;
this.formData.content = articleData.content;
// 更新标题
document.title = `编辑投稿内容 - NEU小站`;
} catch (error) {
console.error('获取文章信息失败:', error);
// 获取失败则回退到普通投稿模式
this.isEditMode = false;
this.editArticleId = null;
// 可以显示友好提示
// messageBox.alert("非法操作!", "提示");
} finally {
// 隐藏加载中组件
this.isLoading = false;
}
},
// 图片上传处理
async handleImageUpload(files, callback) {
// 这个方法处理编辑器内部的图片上传
// files 是上传的文件数组callback 是处理上传完成后返回URL的回调函数
const token = Cookies.get('token');
if (!token) {
messageBox.alert('请先登录!', '提示');
return;
}
try {
const uploadPromises = files.map(async file => {
// 检查文件大小2MB
if (file.size > 2 * 1024 * 1024) {
throw new Error(`图片 ${file.name} 大小不能超过2MB`);
}
const postfix = file.name.split('.').pop().toLowerCase();
const type = file.type;
// 请求上传URL
const prepareResponse = await fetch('https://userlogin.xn--xhq44jb2fzpc.com/submission/prepare-image-upload', {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ postfix, type })
});
if (!prepareResponse.ok) {
throw new Error('准备上传失败');
}
const { uploadUrl, path } = await prepareResponse.json();
// 上传文件
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': type,
'Authorization': token
}
});
if (!uploadResponse.ok) {
throw new Error(`文件 ${file.name} 上传失败`);
}
// 返回图片URL - 现在返回HTML img标签格式
const imageUrl = `https://download.xn--xhq44jb2fzpc.com/${path}`;
// 返回HTML img标签
return imageUrl;
});
// 等待所有上传完成
const htmlTags = await Promise.all(uploadPromises);
// 在回调中返回HTML标签数组编辑器会直接插入这些标签
callback(htmlTags);
// 提示用户如何调整图片大小
if (htmlTags.length > 0) {
setTimeout(() => {
messageBox.alert(
'您已成功上传图片。如需修改图片大小请在编辑器中找到刚插入的图片标签修改其width属性。',
'图片大小调整提示'
);
}, 800);
}
} catch (error) {
console.error('图片上传失败:', error);
messageBox.alert('图片上传失败:' + error.message, '错误');
}
},
// 验证上传的文件
validateFiles(event) {
const maxFiles = 10;
const maxTotalFileSize = 500 * 1024 * 1024; // 500MB in bytes
const files = event.target.files;
if (files.length > maxFiles) {
messageBox.alert(`您只能选择最多 ${maxFiles} 个文件。`, '提示');
event.target.value = ''; // 清除已选文件
this.selectedFiles = []; // 清空状态
this.totalFileSize = 0;
return;
}
let totalSize = 0;
const validFiles = [];
// 检查文件限制
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileSize = file.size;
// 更新总文件大小
totalSize += fileSize;
// 检查文件总大小是否超过限制
if (totalSize > maxTotalFileSize) {
messageBox.alert(`所有文件的总大小不能超过 500MB。`, '提示');
event.target.value = ''; // 清除已选文件
this.selectedFiles = []; // 清空状态
this.totalFileSize = 0;
return;
}
validFiles.push(file);
}
// 验证通过,更新状态
this.selectedFiles = validFiles;
this.totalFileSize = totalSize;
},
// 格式化文件大小显示
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
// 上传图片
async uploadImage(event) {
const file = event.target.files[0];
// 基础检查
if (!file) {
messageBox.alert("请先选择图片文件!", '提示');
return;
}
// 检查文件大小2MB
if (file.size > 2 * 1024 * 1024) {
messageBox.alert("图片文件大小不能超过2MB", '提示');
event.target.value = '';
return;
}
// 检查文件类型
if (!file.type.startsWith('image/')) {
messageBox.alert("请选择图片文件!", '提示');
event.target.value = '';
return;
}
try {
const token = Cookies.get('token');
if (!token) {
messageBox.alert('请先登录!', '提示');
return;
}
const postfix = file.name.split('.').pop().toLowerCase();
// 使用文件的MIME类型
const type = file.type;
// 请求上传URL传递MIME类型
const prepareResponse = await fetch('https://userlogin.xn--xhq44jb2fzpc.com/submission/prepare-image-upload', {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ postfix, type })
});
if (!prepareResponse.ok) {
throw new Error('准备上传失败');
}
const { uploadUrl, path } = await prepareResponse.json();
// 使用签名URL上传文件使用相同的MIME类型
const uploadResponse = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': type,
'Authorization': token
}
});
if (!uploadResponse.ok) {
// 尝试解析错误响应
const text = await uploadResponse.text();
if (text.includes('<?xml')) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(text, "text/xml");
const requestId = xmlDoc.getElementsByTagName("RequestId")[0]?.textContent;
throw new Error(`文件上传失败 (RequestId: ${requestId || '未知'})`);
}
throw new Error('文件上传失败');
}
// 构造完整URL
const imageUrl = `https://download.xn--xhq44jb2fzpc.com/${path}`;
const imageTag = `<img src="${imageUrl}" alt="自定义图片文字" width="150">`;
// 更新界面显示
this.imageUrl = imageUrl;
this.imageTag = imageTag;
} catch (error) {
console.error('上传失败:', error);
messageBox.alert('图片上传失败:' + error.message, '错误');
event.target.value = '';
}
},
// 复制图片HTML标签到剪贴板
copyImageUrl() {
// 创建一个临时文本区域元素
const textArea = document.createElement("textarea");
textArea.value = this.imageTag;
document.body.appendChild(textArea);
textArea.select();
try {
const successful = document.execCommand('copy');
const msg = successful ? '标签已复制到剪贴板!请直接粘贴到编辑区中,并根据预览效果调整大小。' : '复制失败!';
messageBox.alert(msg, '提示');
} catch (err) {
messageBox.alert(`复制失败!${err}`, '错误');
}
document.body.removeChild(textArea);
},
// 预览草稿
async previewDraft() {
const { content } = this.formData;
if (!content.trim()) {
messageBox.alert("内容不能为空!", '提示');
return;
}
try {
// 添加确认提示框
const confirmResult = await messageBox.confirm("此操作会自动保存草稿。确认继续吗?如您之前有草稿记录,此操作会覆盖之前的草稿内容。", '确认');
// 如果用户取消了操作,直接返回
if (!confirmResult) return;
// 保存草稿后跳转到预览页面
this.saveDraft(true).then(() => {
window.open('/preview', '_blank');
}).catch(error => {
console.error("无法预览草稿:", error);
});
} catch (error) {
console.error("预览草稿操作被取消:", error);
}
},
// 包装保存草稿方法,确保调用确认对话框
async saveDraftWithConfirm() {
const { content } = this.formData;
if (!content.trim()) {
messageBox.alert("内容不能为空!", '提示');
return;
}
try {
// 显示确认对话框
const confirmResult = await messageBox.confirm("确认保存草稿内容吗?如您之前有草稿记录,此操作会覆盖之前的草稿内容。", '确认');
// 确认后保存草稿
if (confirmResult) {
this.saveDraft(true);
}
} catch (error) {
console.error("保存草稿操作被取消:", error);
}
},
// 保存草稿
async saveDraft(silent = false) {
const token = Cookies.get('token');
if (!token) {
messageBox.alert("非法操作!请先登录。", '警告');
return Promise.reject(new Error("未登录"));
}
const draftContent = this.formData.content;
if (!draftContent.trim()) {
messageBox.alert("内容不能为空!", '提示');
return;
}
try {
let confirmResult = true;
if (!silent) {
confirmResult = await messageBox.confirm("确认保存草稿内容吗?如您之前有草稿记录,此操作会覆盖之前的草稿内容。", '确认');
}
// 如果用户取消了操作,直接返回
if (!confirmResult) return;
const response = await fetch('https://userlogin.xn--xhq44jb2fzpc.com/submission/save-draft', {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ content: draftContent }) // 将草稿内容发送到后端
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "保存草稿失败");
}
if (!silent) {
messageBox.alert("草稿已保存!", '成功');
}
return Promise.resolve();
} catch (error) {
console.error("保存草稿时出错:", error);
if (!silent) {
messageBox.alert("保存草稿失败,请稍后再试。", '错误');
}
return Promise.reject(error);
}
},
// 加载草稿
async loadDraft() {
const token = Cookies.get('token');
if (!token) {
messageBox.alert("非法操作!请先登录。", '警告');
return;
}
try {
const response = await fetch('https://userlogin.xn--xhq44jb2fzpc.com/submission/load-draft', {
method: 'GET',
headers: {
'Authorization': token
}
});
if (!response.ok) {
if (response.status === 404) {
messageBox.alert("没有草稿记录!", '提示');
} else {
const errorData = await response.json();
throw new Error(errorData.message || "无法加载草稿内容");
}
return;
}
const draftData = await response.json();
try {
const confirmResult = await messageBox.confirm("此操作会覆盖您当前的输入内容,确认加载草稿吗?", '确认');
// 如果用户取消了操作,直接返回
if (!confirmResult) return;
// 设置编辑器中的内容
this.formData.content = draftData.content;
messageBox.alert("草稿已加载!", '成功');
} catch (error) {
// 用户取消了加载操作,不执行任何操作
console.log("用户取消了加载草稿操作");
}
} catch (error) {
console.error("加载草稿时出错:", error);
messageBox.alert("加载草稿失败,请稍后再试。", '错误');
}
},
// 提交表单
async submitForm() {
const { title, section, note, content } = this.formData;
const token = Cookies.get('token');
const files = this.selectedFiles;
if (!title || !section) {
messageBox.alert("请填写内容的标题和板块。", '提示');
return;
}
try {
let confirmMessage = '请仔细检查后提交,多次提交无关内容将被禁止访问网站!';
if (this.isEditMode) {
confirmMessage = '确认提交修改?修改后的内容将重新进入审核流程。';
}
const confirmResult = await messageBox.confirm(confirmMessage, '提交确认');
// 如果用户取消了操作,直接返回
if (!confirmResult) return;
// 准备文件信息
const fileData = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const postfix = file.name.split('.').pop().toLowerCase();
fileData.push({
filename: file.name,
postfix: postfix,
type: file.type || 'application/octet-stream',
size: (file.size / (1024 * 1024)).toFixed(2)
});
}
// 提交表单和获取上传URL
let apiUrl = 'https://userlogin.xn--xhq44jb2fzpc.com/submission/submit';
// 提交参数
const submitData = {
title,
section,
detail: content || null,
submission_note: note || null,
has_file: files.length > 0 ? 1 : 0,
files: fileData
};
// 如果是编辑模式添加文章ID
if (this.isEditMode && this.editArticleId) {
submitData.article_id = this.editArticleId;
}
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': token,
'Content-Type': 'application/json'
},
body: JSON.stringify(submitData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || '投稿失败,请重试!');
}
const result = await response.json();
// 如果有文件需要上传
if (result.fileUploadData && result.fileUploadData.length > 0) {
// 显示加载中状态
this.loadingText = "文件正在上传中,请耐心等待...";
this.isLoading = true;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const uploadInfo = result.fileUploadData[i];
const uploadResponse = await fetch(uploadInfo.uploadUrl, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Authorization': token
}
});
if (!uploadResponse.ok) {
// 隐藏加载中状态
this.isLoading = false;
throw new Error(`文件 ${file.name} 上传失败`);
}
}
// 隐藏加载中状态
this.isLoading = false;
}
let successMessage = `投稿成功!您将会在审核状态变化后收到邮件通知,敬请留意~`;
if (this.isEditMode) {
successMessage = `修改提交成功!您将会在审核状态变化后收到邮件通知,敬请留意~`;
}
await messageBox.alert(successMessage, '成功');
this.$router.push('/user-center'); // 提交成功后跳转到用户中心
} catch (error) {
// 确保隐藏加载中状态
this.isLoading = false;
console.error("投稿失败:", error);
// 判断是否为用户取消操作
if (error && error.action === 'cancel') {
// 用户主动取消操作,不显示错误提示
return;
}
messageBox.alert(`投稿失败:${error.message || '未知错误'}`, '错误');
}
},
chooseCourse() {
this.showCourseSelector = true;
this.courseSearch = '';
this.courseResults = [];
this.selectedCourse = null;
this.currentPage = 1;
this.totalPages = 1;
this.searchError = null;
},
closeCourseSelector() {
this.showCourseSelector = false;
},
async searchCourses(page = 1) {
// 如果参数是事件对象设为默认页码1
if (page && typeof page === 'object' && page.type) {
page = 1;
}
if (!this.courseSearch.trim()) {
messageBox.alert('请输入课程名称', '提示');
return;
}
this.searchLoading = true;
this.searchError = null;
this.selectedCourse = null;
this.currentPage = page;
try {
// 构建API URL
const encodedSearch = encodeURIComponent(this.courseSearch.trim());
const url = `https://coursesystem.xn--xhq44jb2fzpc.com/list/courses?search=${encodedSearch}&page=${page}`;
// 发送请求
const response = await fetch(url);
if (!response.ok) {
throw new Error('搜索课程失败');
}
const data = await response.json();
// 更新数据
this.courseResults = data.courses || [];
this.currentPage = data.currentPage || 1;
this.totalPages = data.totalPages || 1;
} catch (error) {
console.error('课程搜索出错:', error);
this.searchError = '搜索课程时发生错误,请稍后重试';
} finally {
this.searchLoading = false;
}
},
getCategoryName(categoryId) {
return this.categoryMap[categoryId] || '未知类别';
},
selectCourse(course) {
this.selectedCourse = course;
},
changePage(page) {
if (page < 1 || page > this.totalPages) return;
this.searchCourses(page);
},
confirmCourseSelection() {
if (!this.selectedCourse) {
messageBox.alert('请先选择一个课程', '提示');
return;
}
// 获取编辑器实例
const editor = this.$refs.markdownEditor;
// 构建要插入的内容
const courseCardCode = `<CourseCard id="${this.selectedCourse.course_id}" />\n`;
// 在光标位置插入内容
editor.insert((selectedText) => {
return {
targetValue: courseCardCode,
select: false,
deviationStart: 0,
deviationEnd: 0
};
});
// 关闭模态窗口
this.closeCourseSelector();
}
},
computed: {
displayedPages() {
// 最多显示5个页码
const maxButtons = 5;
const halfButtons = Math.floor(maxButtons / 2);
let startPage = Math.max(1, this.currentPage - halfButtons);
let endPage = Math.min(this.totalPages, startPage + maxButtons - 1);
// 调整开始页码,确保显示正确数量的按钮
if (endPage - startPage + 1 < maxButtons) {
startPage = Math.max(1, endPage - maxButtons + 1);
}
const pages = [];
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
}
}
};
</script>
<style scoped>
.submit-page {
background-color: #f5f7fa;
min-height: 100vh;
padding-top: 60px; /* 为导航栏预留空间 */
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 30px 20px;
}
.page-header {
margin-bottom: 30px;
text-align: center;
}
.page-header h1 {
font-size: 28px;
color: #333;
margin-bottom: 10px;
}
.sub-title {
color: #666;
font-size: 16px;
}
.submission-form {
background: white;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.form-section {
padding: 30px;
border-bottom: 1px solid #eee;
}
.form-section:last-child {
border-bottom: none;
}
.form-section h3 {
margin-top: 0;
margin-bottom: 10px;
font-size: 18px;
color: #333;
}
.section-desc {
color: #666;
font-size: 14px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s;
}
.form-control:focus {
border-color: #3273dc;
outline: none;
}
select.form-control {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5'%3E%3Cpath fill='%23333' d='M0 0h8L4 5z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1em center;
background-size: 8px 5px;
padding-right: 30px;
appearance: none;
}
.upload-area {
background-color: #f9f9f9;
border: 2px dashed #ddd;
border-radius: 6px;
padding: 20px;
text-align: center;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #3273dc;
}
.file-input-container {
display: inline-block;
}
.upload-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 10px 20px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
transition: all 0.3s;
}
.upload-label:hover {
background-color: #f0f0f0;
}
.upload-icon {
font-size: 20px;
}
.file-input {
display: none;
}
.preview-area {
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.image-preview {
max-width: 300px;
max-height: 200px;
overflow: hidden;
border: 1px solid #ddd;
border-radius: 4px;
}
.image-preview img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.image-info {
width: 100%;
max-width: 500px;
}
.tag-container {
background-color: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
font-family: monospace;
word-break: break-all;
font-size: 13px;
color: #333;
}
.file-list {
margin-top: 20px;
text-align: left;
}
.file-list h4 {
margin-top: 0;
font-size: 16px;
}
.file-list ul {
padding-left: 20px;
margin-bottom: 10px;
}
.file-list li {
margin-bottom: 5px;
}
.file-name {
font-weight: 500;
}
.file-size {
color: #666;
margin-left: 5px;
}
.total-size {
font-weight: 500;
color: #333;
}
.editor-container {
margin-bottom: 20px;
}
/* 确保编辑器高度适中 */
.editor-container :deep(.md-editor) {
height: 400px;
}
.draft-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.form-actions {
padding: 30px;
text-align: center;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background-color: #3273dc;
color: white;
}
.btn-primary:hover {
background-color: #2366d1;
}
.btn-secondary {
background-color: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background-color: #e0e0e0;
}
.preview-btn {
background-color: #4a9eff;
color: white;
}
.preview-btn:hover {
background-color: #3789e9;
}
@media (max-width: 768px) {
.container {
padding: 20px 15px;
}
.form-section {
padding: 20px;
}
.preview-area {
flex-direction: column;
}
.image-info {
width: 100%;
}
}
/* 课程选择器样式 */
.course-selector-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
display: flex;
justify-content: center;
align-items: center;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-container {
width: 90%;
max-width: 900px;
max-height: 90vh;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.modal-body {
padding: 16px;
overflow-y: auto;
flex-grow: 1;
}
.search-bar {
display: flex;
margin-bottom: 16px;
}
.search-input {
flex-grow: 1;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
margin-right: 8px;
}
.search-btn {
background-color: #3273dc;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
}
.course-loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3273dc;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.search-error, .no-results {
text-align: center;
padding: 20px;
color: #e74c3c;
}
.no-results {
color: #666;
}
.course-table {
width: 100%;
border-collapse: collapse;
}
.course-table th, .course-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #eee;
}
.course-table th {
background-color: #f8f9fa;
font-weight: 600;
}
.course-table tr {
cursor: pointer;
transition: background-color 0.2s;
}
.course-table tr:hover {
background-color: #f5f7fa;
}
.course-table tr.selected {
background-color: #e8f0ff;
}
.rating {
font-weight: 600;
color: #ff9800;
}
.rating-count {
color: #888;
font-size: 0.9em;
margin-left: 4px;
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
gap: 5px;
align-items: center;
}
.page-btn {
min-width: 36px;
height: 36px;
padding: 0 8px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.page-btn:hover:not(:disabled) {
background-color: #f5f7fa;
border-color: #ccc;
}
.page-btn.active {
background-color: #3273dc;
color: white;
border-color: #3273dc;
}
.page-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.modal-footer {
padding: 16px;
display: flex;
justify-content: flex-end;
gap: 12px;
border-top: 1px solid #eee;
}
.page-info {
margin: 0 10px;
font-size: 14px;
color: #666;
}
</style>