Files
qingcheng-Web/src/view/course/courseDetail.vue

779 lines
18 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.

<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 handlePriceInput = (value: number, field: keyof CourseDetail) => {
limitPrice(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>
</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>
<!-- 编辑页 -->
<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-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>
</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-title {
font-size: 1.4rem;
color: #1a1a1a;
border-left: 4px solid #ffa940;
padding-left: 1rem;
margin: 1.5rem 0;
}
.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;
}
.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-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;
max-height: 500px;
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;
}
</style>