课程管理的增删改查以及批量删除以及富文本加密以及名称查询

This commit is contained in:
2025-06-24 20:27:14 +08:00
parent 787fadd33f
commit 774f7f97ed
8 changed files with 2191 additions and 27 deletions

View File

@ -0,0 +1,886 @@
<script setup lang="ts">
import {useRoute, useRouter} from "vue-router";
import { onMounted, ref } from "vue";
import myAxios from "../../api/myAxios";
import { message } from "ant-design-vue";
import RichTextEditor from '../components/RichTextEditor.vue';
import {Form} from 'ant-design-vue'
import {downLoadImage} from "../../api/ImageUrl.ts";
interface CourseDetail {
id: number;
name: string;
type: string;
image: string;
detail: string;
promoCodeDesc: string;
originPrice: number;
discountPrice: number;
orderCount: number;
firstLevelRate: number;
secondLevelRate: number;
}
const route = useRoute();
const loading = ref(false);
const courseData = ref<CourseDetail>({
id: 0,
name: '',
type: '',
image: '',
detail: '',
promoCodeDesc: '',
originPrice: 0,
discountPrice: 0,
orderCount: 0,
firstLevelRate: 0,
secondLevelRate: 0
});
const editData = ref<CourseDetail | null>(null);
const courseId = ref<string | null>(null);
const isEditing = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
function encode64(text: string): string {
return btoa(encodeURIComponent(text).replace(/%([0-9A-F]{2})/g, (_, p1) => {
return String.fromCharCode(parseInt(p1, 16));
}));
}
function decode64(text: string): string {
return decodeURIComponent(
Array.from(atob(text), char =>
'%' + ('00' + char.charCodeAt(0).toString(16)).slice(-2)
).join('')
);
}
const decryptCourseData = (data: CourseDetail): CourseDetail => {
const decryptedData: CourseDetail = { ...data };
const fieldsToDecrypt: (keyof CourseDetail)[] = [
'detail',
'promoCodeDesc'
];
fieldsToDecrypt.forEach(field => {
const value = decryptedData[field];
if (typeof value === 'string' && value) {
try {
(decryptedData as Record<keyof CourseDetail, any>)[field] = decode64(value);
} catch (error) {
console.error(`Base64解码失败 (${field}):`, error);
(decryptedData as Record<keyof CourseDetail, any>)[field] = value;
}
}
});
return decryptedData;
};
// 图片上传处理逻辑
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
// 本地预览
const reader = new FileReader();
reader.onload = (e) => {
previewImage.value = e.target?.result as string;
};
reader.readAsDataURL(file);
// 上传服务器
const uploadFormData = new FormData();
uploadFormData.append('biz', 'default');
uploadFormData.append('file', file);
const storedToken = localStorage.getItem('token');
const res: any = await myAxios.post('/file/upload', uploadFormData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': storedToken
}
});
if (res.code === 1 && res.data) {
if (editData.value) {
editData.value.image = res.data;
}
message.success('图片上传成功');
} else {
throw new Error(res.message || '上传失败');
}
} catch (error) {
console.error('上传失败:', error);
message.error('文件上传失败');
previewImage.value = '';
} finally {
// 重置文件输入,允许重复上传同一文件
if (input) input.value = '';
}
};
const previewImage = ref('');
const formRef = ref<typeof Form>();
// 添加粘贴校验方法
const handlePaste = (e: ClipboardEvent) => {
const pasteData = e.clipboardData?.getData('text/plain');
if (pasteData && !/^\d+$/.test(pasteData)) {
e.preventDefault();
message.error('只能粘贴数字');
}
};
const finishEditing = async () => {
try {
if (!editData.value) {
message.error('编辑数据不存在');
return;
}
const storedToken = localStorage.getItem('token');
if (!storedToken) {
message.error('未找到登录信息');
return;
}
const payload: Record<string, any> = { ...editData.value };
const fieldsToEncode: (keyof CourseDetail)[] = [
'detail',
'promoCodeDesc'
];
fieldsToEncode.forEach(field => {
const value = payload[field];
if (typeof value === 'string' && value) {
payload[field] = encode64(value);
}
});
const res: any = await myAxios.post(
"/course/update",
JSON.stringify(payload),
{
headers: {
'Content-Type': 'application/json',
'Authorization': storedToken
}
}
);
if (res.code === 1) {
message.success('课程更新成功');
courseData.value = editData.value;
editData.value = null;
router.push('/courseManagement')
} else {
message.error(res.message);
}
} catch (error) {
console.error("更新失败:", error);
message.error('课程更新失败');
} finally {
isEditing.value = false;
previewImage.value = ''; // 清空预览图
}
};
if (typeof route.query.id === 'string') {
courseId.value = route.query.id;
}
onMounted(() => {
if(courseId.value !== null) {
handleIdSearch(courseId.value);
}
});
const handleIdSearch = async (id: string) => {
if (!id) {
message.warning("请输入课程ID");
return;
}
if (!/^\d+$/.test(id)) {
message.warning("ID必须为数字");
return;
}
loading.value = true;
try {
const storedToken = localStorage.getItem('token');
if (!storedToken) {
message.error('未找到登录信息');
return;
}
const res: any = await myAxios.post(
"/course/queryById",
{ id: parseInt(id) },
{ headers: { Authorization: storedToken } }
);
if (res.code === 1 && res.data) {
const decryptedData = decryptCourseData(res.data);
courseData.value = decryptedData;
} else {
message.error(res.message || '查询失败');
}
} catch (error) {
console.error("查询失败:", error);
message.error('课程查询失败');
} finally {
loading.value = false;
}
};
const updateCourse = () => {
if (!courseData.value) {
message.warning('课程数据尚未加载完成,请稍后再试');
return;
}
// 创建编辑数据的深拷贝副本
editData.value = JSON.parse(JSON.stringify(courseData.value));
previewImage.value = ''; // 进入编辑模式时重置预览图
isEditing.value = true;
};
const router = useRouter();
const goBack = () => {
router.push('/courseManagement')
};
// 触发文件选择
const triggerFileInput = () => {
if (fileInput.value) {
fileInput.value.click();
}
};
const handleNumberInput = (e: KeyboardEvent) => {
const allowedKeys = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Tab', '.'];
// 只允许数字和控制键
if (!allowedKeys.includes(e.key)) {
e.preventDefault();
}
};
// 添加范围限制方法 - 现在在输入时立即修正
const limitPrice = (value: number, field: keyof CourseDetail) => {
if (!editData.value) return;
// 确保是数字
if (isNaN(value)) {
(editData.value as any)[field] = 0;
return;
}
// 限制价格不能为负数
if (value < 0) (editData.value as any)[field] = 0;
};
const limitRate = (value: number, field: keyof CourseDetail) => {
if (!editData.value) return;
// 确保是数字
if (isNaN(value)) {
(editData.value as any)[field] = 0;
return;
}
// 限制佣金比例在0-100之间
if (value < 0) (editData.value as any)[field] = 0;
if (value > 100) (editData.value as any)[field] = 100;
};
// 添加输入时实时限制 - 立即修正值
const handlePriceInput = (value: number, field: keyof CourseDetail) => {
limitPrice(value, field);
};
const handleRateInput = (value: number, field: keyof CourseDetail) => {
limitRate(value, field);
};
</script>
<template>
<div class="course-detail-container">
<!-- 加载中 -->
<div v-if="loading" class="loading">加载中...</div>
<!-- 详情页 -->
<div v-if="courseData && !isEditing" class="detail-card">
<!-- 头部区域 -->
<div class="header-section">
<h1 class="course-title">{{ courseData.name }}</h1>
<a-button class="custom-button" @click="updateCourse">编辑课程</a-button>
<a-button class="custom-button" @click="goBack">返回</a-button>
</div>
<!-- 基本信息 -->
<div class="basic-info">
<div class="course-image">
<img :src="downLoadImage+courseData.image" alt="课程封面图">
</div>
<div class="info-grid">
<div class="info-item">
<label>课程类型</label>
<div class="value">{{ courseData.type }}</div>
</div>
<div class="info-item">
<label>订单数量</label>
<div class="value">{{ courseData.orderCount }}</div>
</div>
<div class="info-item">
<label>原价</label>
<div class="value">¥{{ courseData.originPrice }}</div>
</div>
<div class="info-item">
<label>折扣价</label>
<div class="value">¥{{ courseData.discountPrice }}</div>
</div>
<div class="info-item">
<label>一级佣金比例</label>
<div class="value">{{ courseData.firstLevelRate }}%</div>
</div>
<div class="info-item">
<label>二级佣金比例</label>
<div class="value">{{ courseData.secondLevelRate }}%</div>
</div>
</div>
</div>
<!-- 富文本内容区块 -->
<div class="rich-section" v-if="courseData.detail">
<h2 class="section-title">课程详情</h2>
<div class="rich-content" v-html="courseData.detail"></div>
</div>
<div class="rich-section" v-if="courseData.promoCodeDesc">
<h2 class="section-title">推广码说明</h2>
<div class="rich-content" v-html="courseData.promoCodeDesc"></div>
</div>
</div>
<!-- 编辑页 -->
<div v-if="isEditing && editData" class="add-course-container">
<a-form ref="formRef" :model="editData" layout="vertical" @submit.prevent="finishEditing">
<div class="form-section">
<div class="form-header">
<h2>课程基本信息</h2>
<a-button class="custom-button" @click="isEditing = false">返回</a-button>
</div>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="课程名称">
<a-input v-model:value="editData.name" placeholder="请输入课程名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="课程类型">
<a-select v-model:value="editData.type" placeholder="请选择课程类型">
<a-select-option value="自媒体">自媒体</a-select-option>
<a-select-option value="财经">财经</a-select-option>
<a-select-option value="考公考研">考公考研</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="原价(元)"
name="originPrice"
>
<a-input-number
v-model:value="editData.originPrice"
style="width: 100%"
:min="0"
@keypress="handleNumberInput"
@paste="handlePaste"
@change="(val:any) => handlePriceInput(val, 'originPrice')"
placeholder="请输入原价"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="折扣价(元)"
name="discountPrice"
>
<a-input-number
v-model:value="editData.discountPrice"
style="width: 100%"
:min="0"
@keypress="handleNumberInput"
@paste="handlePaste"
@change="(val:any) => handlePriceInput(val, 'discountPrice')"
placeholder="请输入折扣价"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="一级佣金比例(%"
name="firstLevelRate"
>
<a-input-number
v-model:value="editData.firstLevelRate"
style="width: 100%"
:min="0"
:max="100"
@keypress="handleNumberInput"
@paste="handlePaste"
@change="(val:any) => handleRateInput(val, 'firstLevelRate')"
placeholder="0-100"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="二级佣金比例(%"
name="secondLevelRate"
>
<a-input-number
v-model:value="editData.secondLevelRate"
style="width: 100%"
:min="0"
:max="100"
@keypress="handleNumberInput"
@paste="handlePaste"
@change="(val:any) => handleRateInput(val, 'secondLevelRate')"
placeholder="0-100"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="课程图片">
<div class="file-upload">
<input
type="file"
class="file-input"
accept="image/*"
@change="handleFileUpload"
ref="fileInput"
>
<div class="upload-area" @click="triggerFileInput">
<div class="upload-button">
<span>点击上传图片</span>
<div class="file-name">支持 JPG/PNG 格式大小不超过 5MB</div>
</div>
</div>
<div v-if="previewImage || editData.image" class="preview-image">
<img
v-if="previewImage"
:src="previewImage"
alt="上传预览"
>
<img
v-else
:src="downLoadImage + editData.image"
alt="课程封面预览"
>
</div>
</div>
</a-form-item>
</div>
<div class="form-section">
<h2>富文本配置</h2>
<div class="rich-text-container">
<div class="rich-text-columns">
<div class="rich-text-group">
<span class="label-text">课程详情</span>
<RichTextEditor
v-model="editData.detail"
:disable="false"
@content-change="(html: string) => { if (editData) editData.detail = html }"
/>
</div>
<div class="rich-text-group">
<span class="label-text">推广码说明</span>
<RichTextEditor
v-model="editData.promoCodeDesc"
:disable="false"
@content-change="(html: string) => { if (editData) editData.promoCodeDesc = html }"
/>
</div>
</div>
</div>
</div>
<div class="form-actions">
<a-button class="custom-button" htmlType="submit">完成编辑</a-button>
</div>
</a-form>
</div>
</div>
</template>
<style scoped>
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.course-detail-container {
width: 100%;
margin: 2rem auto;
padding: 0 1rem;
}
.loading {
text-align: center;
font-size: 1.2rem;
color: #666;
padding: 2rem;
}
.detail-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
padding: 2rem;
}
.header-section {
display: flex;
align-items: center;
margin-bottom: 2rem;
gap: 1rem;
}
.course-title {
font-size: 2rem;
color: #1a1a1a;
margin: 0;
flex-grow: 1;
}
.basic-info {
display: grid;
grid-template-columns: 200px 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.course-image img {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
}
.info-item label {
display: block;
color: #666;
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.info-item .value {
font-size: 1rem;
color: #1a1a1a;
font-weight: 500;
}
.section {
margin-bottom: 2.5rem;
}
.section-title {
font-size: 1.4rem;
color: #1a1a1a;
border-left: 4px solid #ffa940;
padding-left: 1rem;
margin: 1.5rem 0;
}
.section-content {
line-height: 1.6;
color: #444;
}
.rich-section {
margin-bottom: 2.5rem;
}
.rich-content {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
line-height: 1.7;
}
.rich-content :deep(p) {
margin: 0.8em 0;
}
.rich-content :deep(h2) {
color: #1a1a1a;
margin: 1.2em 0 0.8em;
}
.rich-content :deep(ul) {
padding-left: 1.5em;
}
.rich-content :deep(li) {
margin: 0.5em 0;
}
@media (max-width: 768px) {
.basic-info {
grid-template-columns: 1fr;
}
.info-grid {
grid-template-columns: 1fr;
}
.course-title {
font-size: 1.6rem;
}
.header-section {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
.add-course-container {
width: 100%;
margin: 2rem auto;
padding: 2rem;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.form-section {
margin-bottom: 2.5rem;
}
.form-section h2 {
font-size: 1.3rem;
margin-bottom: 1.5rem;
color: #1a1a1a;
border-left: 4px solid #ffa940;
padding-left: 0.8rem;
}
.form-actions {
text-align: right;
margin-top: 2rem;
}
.ql-container {
min-height: 120px;
}
.file-upload {
position: relative;
margin-top: 1rem;
}
.file-input {
opacity: 0;
position: absolute;
width: 1px;
height: 1px;
}
.upload-area {
cursor: pointer;
}
.upload-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #f8fafc;
border: 2px dashed #cbd5e1;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.upload-button:hover {
background: #f1f5f9;
border-color: #94a3b8;
}
.upload-button span {
font-weight: 500;
color: #334155;
}
.file-name {
color: #64748b;
font-size: 0.85rem;
margin-top: 0.5rem;
}
.preview-image {
margin-top: 1rem;
text-align: center;
}
.preview-image img {
max-width: 100%;
max-height: 300px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border: 1px solid #eee;
}
.custom-button {
background-color: #ffa940;
border-color: #ffa940;
color: #fff;
font-weight: 500;
}
.custom-button:hover,
.custom-button:focus {
background-color: #fa8c16;
border-color: #fa8c16;
color: #fff;
}
.custom-button[disabled] {
background-color: #ffa940;
border-color: #ffa940;
opacity: 0.6;
color: #fff;
}
.custom-button.ant-btn-dangerous {
background-color: #ff4d4f;
border-color: #ff4d4f;
}
.custom-button.ant-btn-dangerous:hover,
.custom-button.ant-btn-dangerous:focus {
background-color: #ff7875;
border-color: #ff7875;
}
.rich-text-container {
margin-top: 1.5rem;
}
.rich-text-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.rich-text-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
overflow: hidden;
transition: all 0.3s ease;
min-height: 320px;
flex-grow: 1;
padding: 10px;
background: white;
}
.rich-text-group:focus-within {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.label-text {
margin: 10px 0 0 5px;
display: block;
font-size: 0.95rem;
font-weight: 600;
color: #3b4151;
padding-left: 0.2rem;
transition: color 0.3s ease;
}
.rich-text-group:focus-within .label-text {
color: #6366f1;
}
@media (max-width: 768px) {
.rich-text-columns {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
:deep(.ql-container) {
height: 280px;
border: none !important;
}
.textarea-field {
min-height: 100px;
resize: vertical;
}
.textarea-field:focus {
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
outline: none;
}
.textarea-field {
width: 100%;
padding: 0.8rem 1.2rem;
border: 2px solid #e2e8f0;
border-radius: 0.75rem;
transition: all 0.3s ease;
font-size: 1rem;
background: white;
}
</style>