refactor(租户管理): 调整配额模态框移除套餐选择器

- 移除调整配额模态框中的套餐选择功能
- quotaForm 数据定义移除 collectionIds 字段
- 简化 handleQuota 函数,仅保留配额相关逻辑
- 使前端与后端 UpdateTenantQuotaDto 接口保持一致

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-03-24 16:26:53 +08:00
parent de448a3e64
commit 1038a70d92
17 changed files with 614 additions and 162 deletions

View File

@ -0,0 +1,100 @@
# 学校端家长管理"选择孩子"列表字段完善计划
## 问题背景
学校端家长管理页面中的"选择孩子"弹窗列表(用于将学生关联到家长)缺少两个关键字段的显示:
1. **性别** (gender) - 学生性别
2. **所属班级** (className) - 学生所在班级名称
## 问题分析
通过代码审查发现:
### 1. 前端代码 (`ParentListView.vue`)
- 第 597-601 行定义了表格列,期望显示 `gender``className`
```typescript
const studentTableColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name', width: 120 },
{ title: '性别', dataIndex: 'gender', key: 'gender', width: 80 },
{ title: '班级', dataIndex: 'className', key: 'className', width: 120 },
];
```
### 2. 后端代码 (`SchoolStudentController.java`)
- 第 89-119 行的 `getStudentPage` 方法返回学生列表
- 目前只设置了 `classId`,未设置 `className`
### 3. DTO (`StudentResponse.java`)
- 有 `gender` 字段(第 29 行)✅
- 有 `classId` 字段(第 56 行)✅
- **缺少 `className` 字段**
### 4. 实体类 (`Student.java`)
- 有 `gender` 字段(第 26 行)✅
## 根本原因
1. `StudentResponse` DTO 缺少 `className` 字段
2. Controller 中只查询和设置了 `classId`,没有查询班级名称并设置 `className`
## 修改方案
### 1. 添加 `className` 字段到 `StudentResponse`
**文件**: `reading-platform-java/src/main/java/com/reading/platform/dto/response/StudentResponse.java`
`classId` 字段后添加 `className` 字段:
```java
@Schema(description = "所在班级 ID")
private Long classId;
@Schema(description = "所在班级名称")
private String className;
```
### 2. 在 Controller 中设置 `className`
**文件**: `reading-platform-java/src/main/java/com/reading/platform/controller/school/SchoolStudentController.java`
修改 `getStudentPage` 方法(第 100-118 行):
```java
for (StudentResponse vo : voList) {
// 设置班级
var clazz = classService.getPrimaryClassByStudentId(vo.getId());
vo.setClassId(clazz != null ? clazz.getId() : null);
vo.setClassName(clazz != null ? clazz.getName() : null); // 新增
// ... 其余代码不变
}
```
同样修改 `getStudent` 方法(第 66-86 行),添加 `className` 的设置。
### 3. 验证 `gender` 字段
`gender` 字段应该已通过 MapStruct 自动映射。如果前端仍不显示,需要检查:
- 数据库中 `gender` 字段是否有值
- Mapper 是否正确映射了该字段
## 关键文件列表
| 文件路径 | 修改类型 | 说明 |
|---------|---------|------|
| `reading-platform-java/src/main/java/com/reading/platform/dto/response/StudentResponse.java` | 修改 | 添加 `className` 字段 |
| `reading-platform-java/src/main/java/com/reading/platform/controller/school/SchoolStudentController.java` | 修改 | 设置 `className` 字段值 |
## 验证步骤
1. 启动后端服务
2. 进入学校端家长管理页面
3. 点击任意家长的"孩子"按钮打开管理弹窗
4. 点击"添加孩子"打开选择学生弹窗
5. 验证表格中是否正确显示:
- 性别列(男/女)
- 班级列(班级名称)
## 风险评估
- **低风险**:只是添加一个返回字段,不影响现有功能
- **向后兼容**:前端已准备好接收这些字段,不会破坏现有功能

View File

@ -1,3 +1,4 @@
VITE_API_BASE_URL=
VITE_APP_TITLE=幼儿阅读教学服务平台
VITE_SERVER_BASE_URL=
VITE_ENABLE_DEFAULT_ACCOUNT=true

View File

@ -0,0 +1,4 @@
VITE_API_BASE_URL=
VITE_APP_TITLE=幼儿阅读教学服务平台
VITE_SERVER_BASE_URL=
VITE_ENABLE_DEFAULT_ACCOUNT=false

View File

@ -1,2 +1,3 @@
VITE_APP_PORT=5174
VITE_BACKEND_PORT=8481
VITE_ENABLE_DEFAULT_ACCOUNT=true

View File

@ -56,7 +56,19 @@
<video v-if="fileType === 'video'" :src="previewUrl" controls style="width: 100%" />
<audio v-else-if="fileType === 'audio'" :src="previewUrl" controls style="width: 100%" />
<iframe v-else-if="fileType === 'pdf' || isPdfFile" :src="previewUrl" style="width: 100%; height: 600px; border: none" />
<img v-else-if="fileType === 'image'" :src="previewUrl" style="max-width: 100%" />
<img
v-else-if="fileType === 'image'"
:src="previewUrl"
style="max-width: 100%"
@load="imagePreviewLoading = false; imagePreviewLoaded = true"
@error="imagePreviewLoading = false; imagePreviewError = true"
:style="{ opacity: imagePreviewLoaded ? 1 : 0, transition: 'opacity 0.3s' }"
/>
<!-- 图片加载失败提示 -->
<div v-if="fileType === 'image' && imagePreviewError" class="image-error-tip">
<FileOutlined style="font-size: 48px; color: #999" />
<p>图片加载失败</p>
</div>
<div v-else class="unsupported">
<FileOutlined style="font-size: 48px; color: #999" />
<p>暂不支持预览此类型文件</p>
@ -103,6 +115,20 @@ const emit = defineEmits<{
const previewVisible = ref(false);
const uploading = ref(false);
//
const imagePreviewLoading = ref(false);
const imagePreviewLoaded = ref(false);
const imagePreviewError = ref(false);
// previewUrl
watch(() => previewUrl.value, (newUrl) => {
if (newUrl && props.fileType === 'image') {
imagePreviewLoading.value = true;
imagePreviewLoaded.value = false;
imagePreviewError.value = false;
}
}, { immediate: true });
const buttonText = computed(() => {
const typeMap: Record<string, string> = {
video: '上传视频',
@ -300,6 +326,19 @@ const handleRemove = () => {
color: #666;
}
}
.image-error-tip {
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
p {
margin: 16px 0;
color: #999;
}
}
}
}
</style>

View File

@ -174,21 +174,6 @@
<a-form-item label="当前租户">
<span>{{ currentTenant?.name }}</span>
</a-form-item>
<a-form-item label="套餐">
<a-select
v-model:value="quotaForm.collectionIds"
mode="multiple"
placeholder="请选择套餐"
>
<a-select-option
v-for="pkg in packageList"
:key="pkg.id"
:value="pkg.id"
>
{{ pkg.name }} ({{ formatPackagePrice(pkg.discountPrice || pkg.price) }}{{ pkg.discountType ? ' ' + getDiscountTypeText(pkg.discountType) : '' }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="教师配额">
<a-input-number v-model:value="quotaForm.teacherQuota" :min="currentTenant?.teacherCount || 1" :max="1000"
style="width: 100%" />
@ -455,11 +440,9 @@ const quotaModalVisible = ref(false);
const quotaModalLoading = ref(false);
const currentTenant = ref<Tenant | null>(null);
const quotaForm = reactive<{
collectionIds?: number[];
teacherQuota: number;
studentQuota: number;
}>({
collectionIds: undefined,
teacherQuota: 20,
studentQuota: 200,
});
@ -593,8 +576,8 @@ const handleModalOk = async () => {
}
modalLoading.value = true;
try {
const [startDate, expireDate] = formData.dateRange || [];
try {
const data = {
...formData,
startDate,
@ -685,15 +668,6 @@ const handleViewDetail = async (record: Tenant) => {
//
const handleQuota = (record: Tenant) => {
currentTenant.value = record;
// ID
if (record.packageNames && record.packageNames.length > 0) {
quotaForm.collectionIds = record.packageNames.map(name => {
const pkg = packageList.value.find(p => p.name === name);
return pkg?.id;
}).filter(id => id !== undefined) as number[];
} else {
quotaForm.collectionIds = undefined;
}
quotaForm.teacherQuota = record.teacherQuota;
quotaForm.studentQuota = record.studentQuota;
quotaModalVisible.value = true;

View File

@ -110,6 +110,9 @@ import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
// /
const enableDefaultAccount = import.meta.env.VITE_ENABLE_DEFAULT_ACCOUNT === 'true';
const roles = [
{ value: 'admin', label: '超管', icon: markRaw(SettingOutlined) },
{ value: 'school', label: '学校', icon: markRaw(SolutionOutlined) },
@ -126,8 +129,8 @@ const testAccounts = [
const formState = reactive({
role: 'admin',
account: 'admin',
password: '123456',
account: enableDefaultAccount ? 'admin' : '',
password: enableDefaultAccount ? '123456' : '',
});
const loading = ref(false);
@ -143,6 +146,7 @@ const fillAccount = (acc: any) => {
};
watch(() => formState.role, (role) => {
if (!enableDefaultAccount) return; //
const found = testAccounts.find(t => t.role === role);
if (found) {
formState.account = found.account;

View File

@ -122,7 +122,6 @@ import {
type CoursePackage,
type FilterMetaResponse,
} from '@/api/course-center';
import { getCoursePackageDetail } from '@/api/school';
import CoursePackageCard from './components/CoursePackageCard.vue';
const router = useRouter();
@ -249,11 +248,20 @@ onMounted(() => {
});
</script>
<style scoped>
<style scoped lang="scss">
// -
$primary-color: #FF8C42; //
$primary-light: #FFF4EC;
$primary-dark: #E67635;
$bg-color: #F5F7FA;
$text-color: #333;
$text-secondary: #666;
$border-color: #F0F0F0;
.course-center-page {
display: flex;
min-height: calc(100vh - 120px);
background: #F5F7FA;
background: $bg-color;
gap: 20px;
padding: 20px;
}
@ -270,14 +278,14 @@ onMounted(() => {
.sidebar-header {
padding: 16px;
border-bottom: 1px solid #F0F0F0;
border-bottom: 1px solid $border-color;
}
.sidebar-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
color: $text-color;
}
.collection-list {
@ -296,18 +304,18 @@ onMounted(() => {
}
.collection-item:hover {
background: #F5F7FA;
background: $bg-color;
}
.collection-item.active {
background: #E6F7FF;
border-left-color: #1890ff;
background: $primary-light;
border-left-color: $primary-color;
}
.collection-name {
font-size: 14px;
font-weight: 500;
color: #333;
color: $text-color;
margin-bottom: 4px;
}
@ -340,7 +348,7 @@ onMounted(() => {
.collection-title {
font-size: 20px;
font-weight: 600;
color: #333;
color: $text-color;
margin: 0 0 12px;
}
@ -371,7 +379,7 @@ onMounted(() => {
padding: 0;
border: none;
background: none;
color: #1890ff;
color: $primary-color;
font-size: 13px;
cursor: pointer;
}
@ -434,12 +442,12 @@ onMounted(() => {
}
.grade-tag:hover {
background: #E6F7FF;
color: #1890ff;
background: $primary-light;
color: $primary-color;
}
.grade-tag.active {
background: linear-gradient(135deg, #1890ff 0%, #40a9ff 100%);
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
}
@ -513,7 +521,7 @@ onMounted(() => {
.lessons-section h4 {
font-size: 14px;
font-weight: 600;
color: #333;
color: $text-color;
margin-bottom: 12px;
}
@ -529,7 +537,7 @@ onMounted(() => {
.lesson-name {
font-size: 14px;
color: #333;
color: $text-color;
}
/* 响应式 */

View File

@ -2,12 +2,23 @@
<div class="package-card" @click="handleClick">
<!-- 封面区域 -->
<div class="cover-wrapper">
<div v-if="pkg.coverImagePath" class="cover-image-wrapper">
<a-spin :spinning="coverLoading" :indicator="null">
<img
v-if="pkg.coverImagePath"
:src="getImageUrl(pkg.coverImagePath)"
class="cover-image"
alt="课程包封面"
:style="{ opacity: coverLoaded ? 1 : 0, transition: 'opacity 0.3s' }"
@load="coverLoading = false; coverLoaded = true"
@error="coverLoading = false; coverError = true"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="coverError" class="error-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">封面加载失败</span>
</div>
</div>
<div v-else class="cover-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">精彩绘本</span>
@ -54,7 +65,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import {
BookOutlined,
BookFilled,
@ -69,6 +80,18 @@ const props = defineProps<{
pkg: CoursePackage;
}>();
//
const coverLoading = ref(true);
const coverLoaded = ref(false);
const coverError = ref(false);
// coverImagePath
watch(() => props.pkg.coverImagePath, () => {
coverLoading.value = true;
coverLoaded.value = false;
coverError.value = false;
}, { immediate: true });
const emit = defineEmits<{
(e: 'click', pkg: CoursePackage): void;
(e: 'view', pkg: CoursePackage): void;
@ -112,7 +135,12 @@ const handleView = () => {
};
</script>
<style scoped>
<style scoped lang="scss">
// -
$primary-color: #FF8C42;
$primary-light: #FFF4EC;
$primary-dark: #E67635;
.package-card {
background: #fff;
border-radius: 12px;
@ -125,8 +153,8 @@ const handleView = () => {
.package-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.15);
border-color: #1890ff;
box-shadow: 0 8px 20px rgba(255, 140, 66, 0.15);
border-color: $primary-color;
}
/* 封面区域 */
@ -136,6 +164,11 @@ const handleView = () => {
overflow: hidden;
}
.cover-image-wrapper {
position: relative;
width: 100%;
height: 100%;
.cover-image {
width: 100%;
height: 100%;
@ -143,6 +176,32 @@ const handleView = () => {
transition: transform 0.3s ease;
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, $primary-light 0%, #f0f5ff 100%);
.placeholder-icon {
font-size: 48px;
color: $primary-color;
margin-bottom: 8px;
}
.placeholder-text {
font-size: 14px;
color: $primary-color;
font-weight: 500;
}
}
}
.package-card:hover .cover-image {
transform: scale(1.05);
}
@ -154,18 +213,18 @@ const handleView = () => {
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%);
background: linear-gradient(135deg, $primary-light 0%, #f0f5ff 100%);
}
.placeholder-icon {
font-size: 48px;
color: #1890ff;
color: $primary-color;
margin-bottom: 8px;
}
.placeholder-text {
font-size: 14px;
color: #1890ff;
color: $primary-color;
font-weight: 500;
}

View File

@ -2,12 +2,23 @@
<div class="package-card" @click="handleClick">
<!-- 封面区域 -->
<div class="cover-wrapper">
<div v-if="pkg.coverImagePath" class="cover-image-wrapper">
<a-spin :spinning="coverLoading" :indicator="null">
<img
v-if="pkg.coverImagePath"
:src="getImageUrl(pkg.coverImagePath)"
class="cover-image"
alt="课程包封面"
:style="{ opacity: coverLoaded ? 1 : 0, transition: 'opacity 0.3s' }"
@load="coverLoading = false; coverLoaded = true"
@error="coverLoading = false; coverError = true"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="coverError" class="error-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">封面加载失败</span>
</div>
</div>
<div v-else class="cover-placeholder">
<BookFilled class="placeholder-icon" />
<span class="placeholder-text">精彩绘本</span>
@ -53,7 +64,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import {
BookOutlined,
BookFilled,
@ -68,6 +79,18 @@ const props = defineProps<{
pkg: CoursePackage;
}>();
//
const coverLoading = ref(true);
const coverLoaded = ref(false);
const coverError = ref(false);
// coverImagePath
watch(() => props.pkg.coverImagePath, () => {
coverLoading.value = true;
coverLoaded.value = false;
coverError.value = false;
}, { immediate: true });
const emit = defineEmits<{
(e: 'click', pkg: CoursePackage): void;
(e: 'prepare', pkg: CoursePackage): void;
@ -134,6 +157,11 @@ const handlePrepare = () => {
overflow: hidden;
}
.cover-image-wrapper {
position: relative;
width: 100%;
height: 100%;
.cover-image {
width: 100%;
height: 100%;
@ -141,6 +169,32 @@ const handlePrepare = () => {
transition: transform 0.3s ease;
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #FFE4C9 0%, #FFF0E0 100%);
.placeholder-icon {
font-size: 48px;
color: #FF8C42;
margin-bottom: 8px;
}
.placeholder-text {
font-size: 14px;
color: #FF8C42;
font-weight: 500;
}
}
}
.package-card:hover .cover-image {
transform: scale(1.05);
}

View File

@ -14,7 +14,22 @@
class="resource-item image-item"
@click="handlePreview('image', image)"
>
<img :src="getFileUrl(image.path)" :alt="image.name || `图片${idx + 1}`" />
<div class="image-wrapper">
<a-spin :spinning="imageLoadingStates[idx] === 'loading'" :indicator="null">
<img
:src="getFileUrl(image.path)"
:alt="image.name || `图片${idx + 1}`"
:style="{ display: imageLoadingStates[idx] === 'loaded' ? 'block' : 'none' }"
@load="imageLoadingStates[idx] = 'loaded'"
@error="imageLoadingStates[idx] = 'error'"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="imageLoadingStates[idx] === 'error'" class="error-placeholder">
<PictureOutlined style="font-size: 32px; color: #999;" />
<span>加载失败</span>
</div>
</div>
<div class="resource-info">
<div class="resource-name">{{ image.name || `图片${idx + 1}` }}</div>
<div class="resource-action">点击预览</div>
@ -113,7 +128,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
import {
PictureOutlined, VideoCameraOutlined, AudioOutlined,
@ -128,6 +143,24 @@ const emit = defineEmits<{
'preview-resource': [type: string, resource: any];
}>();
// 'loading' | 'loaded' | 'error'
const imageLoadingStates = ref<Record<number, 'loading' | 'loaded' | 'error'>>({});
// lesson.images
watch(() => props.lesson?.images, (newImages) => {
if (!newImages) {
imageLoadingStates.value = {};
return;
}
// 'loading'
const newStates: Record<number, 'loading' | 'loaded' | 'error'> = {};
newImages.forEach((_: any, idx: number) => {
newStates[idx] = 'loading';
});
imageLoadingStates.value = newStates;
}, { immediate: true, deep: true });
const hasResources = computed(() => {
const l = props.lesson;
return (l.images?.length || 0) +
@ -220,14 +253,48 @@ const handlePreview = (type: string, resource: any) => {
padding: 8px;
}
.image-item img {
.image-wrapper {
position: relative;
width: 100%;
height: 80px;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 6px;
margin-bottom: 8px;
}
.image-wrapper img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 6px;
}
.image-wrapper :deep(.ant-spin) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
color: #999;
font-size: 12px;
}
.resource-icon {
font-size: 32px;
color: #FF8C42;

View File

@ -44,7 +44,21 @@
<div v-for="record in records" :key="record.id" class="record-card">
<div class="card-cover">
<div v-if="record.images?.length" class="cover-image">
<img :src="getImageUrl(record.images[0])" class="pos-absolute !object-contain " />
<div class="image-wrapper">
<a-spin :spinning="recordLoadingStates[record.id]" :indicator="null">
<img
:src="getImageUrl(record.images[0])"
class="pos-absolute !object-contain"
:style="{ opacity: recordImageLoaded[record.id] ? 1 : 0, transition: 'opacity 0.3s' }"
@load="handleImageLoad(record.id)"
@error="handleImageError(record.id)"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="recordImageError[record.id]" class="error-placeholder">
<FileTextOutlined class="placeholder-icon" />
</div>
</div>
<div class="image-count" v-if="record.images.length > 1">
<CameraOutlined /> {{ record.images.length }}
</div>
@ -231,6 +245,33 @@ import {
type CreateGrowthRecordDto,
type UpdateGrowthRecordDto,
} from '@/api/growth';
//
const recordLoadingStates = ref<Record<number, boolean>>({});
const recordImageLoaded = ref<Record<number, boolean>>({});
const recordImageError = ref<Record<number, boolean>>({});
// records
const handleFilter = () => {
recordLoadingStates.value = {};
recordImageLoaded.value = {};
recordImageError.value = {};
pagination.current = 1;
loadRecords();
};
const handleImageLoad = (recordId: number) => {
recordLoadingStates.value[recordId] = false;
recordImageLoaded.value[recordId] = true;
recordImageError.value[recordId] = false;
};
const handleImageError = (recordId: number) => {
recordLoadingStates.value[recordId] = false;
recordImageLoaded.value[recordId] = false;
recordImageError.value[recordId] = true;
};
import { getTeacherClasses, getTeacherStudents } from '@/api/teacher';
import { fileApi, validateFileType } from '@/api/file';
@ -324,11 +365,6 @@ const loadStudents = async () => {
}
};
const handleFilter = () => {
pagination.current = 1;
loadRecords();
};
const handlePageChange = (page: number, pageSize: number) => {
pagination.current = page;
pagination.pageSize = pageSize;
@ -606,6 +642,36 @@ onMounted(() => {
inset: 0;
overflow: hidden;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
.image-wrapper {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
.placeholder-icon {
font-size: 48px;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
.cover-image img {

View File

@ -24,7 +24,23 @@
<!-- 图片展示 -->
<div v-else-if="currentResourceType === 'image'" key="image" class="content-viewer">
<div class="image-viewer">
<img :src="currentResourceUrl" :alt="currentResourceName" class="full-image" />
<div class="image-wrapper">
<a-spin :spinning="imageLoading" :indicator="null">
<img
:src="currentResourceUrl"
:alt="currentResourceName"
class="full-image"
:style="{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.3s' }"
@load="onImageLoad"
@error="onImageError"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="imageError" class="error-placeholder">
<Image style="font-size: 48px; color: #999;" />
<span>图片加载失败</span>
</div>
</div>
<div v-if="currentResourceName" class="image-caption">{{ currentResourceName }}</div>
</div>
</div>
@ -147,8 +163,22 @@
<div v-if="activityResourceUrl" class="activity-media">
<video v-if="activityResourceType === 'video'" :src="activityResourceUrl" controls
style="width: 100%; max-height: 50vh;"></video>
<img v-else-if="activityResourceType === 'image'" :src="activityResourceUrl"
style="width: 100%; max-height: 50vh; object-fit: contain;">
<div v-else-if="activityResourceType === 'image'" class="activity-image-wrapper">
<a-spin :spinning="activityImageLoading" :indicator="null">
<img
:src="activityResourceUrl"
style="width: 100%; max-height: 50vh; object-fit: contain; display: block;"
:style="{ opacity: activityImageLoaded ? 1 : 0, transition: 'opacity 0.3s' }"
@load="activityImageLoaded = true"
@error="activityImageError = true"
/>
</a-spin>
<!-- 加载失败占位图 -->
<div v-if="activityImageError" class="error-placeholder">
<Image style="font-size: 48px; color: #999;" />
<span>图片加载失败</span>
</div>
</div>
</div>
<div class="activity-info">
<div class="info-item" v-if="selectedActivity.objectives">
@ -173,7 +203,6 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import {
BookOpen,
Check,
ChevronLeft,
ChevronRight,
X,
@ -232,6 +261,16 @@ const currentEbookPage = ref(0);
const currentSlidePage = ref(0);
const currentDocumentPage = ref(0);
//
const imageLoading = ref(false);
const imageLoaded = ref(false);
const imageError = ref(false);
//
const activityImageLoading = ref(false);
const activityImageLoaded = ref(false);
const activityImageError = ref(false);
//
const syncAudioUrl = ref<string>('');
const autoPlayAudio = ref(false);
@ -788,6 +827,19 @@ const handleExit = () => {
emit('exit');
};
//
const onImageLoad = () => {
imageLoading.value = false;
imageLoaded.value = true;
imageError.value = false;
};
const onImageError = () => {
imageLoading.value = false;
imageLoaded.value = false;
imageError.value = true;
};
//
const handleResourceByIndex = (index: number) => {
playSound('click');
@ -797,6 +849,13 @@ const handleResourceByIndex = (index: number) => {
currentResourceType.value = resource.type;
currentResourceUrl.value = resource.url;
currentResourceName.value = resource.name;
//
if (resource.type === 'image') {
imageLoading.value = true;
imageLoaded.value = false;
imageError.value = false;
}
}
};
@ -866,12 +925,23 @@ const handleDocumentPageChange = (page: number) => {
const handleActivityClick = (activity: any) => {
selectedActivity.value = activity;
playSound('click');
//
activityImageLoading.value = false;
activityImageLoaded.value = false;
activityImageError.value = false;
if (activity.onlineMaterials) {
try {
const materials = typeof activity.onlineMaterials === 'string' ? JSON.parse(activity.onlineMaterials) : activity.onlineMaterials;
if (materials.length > 0) {
activityResourceUrl.value = getFileUrl(materials[0].path);
activityResourceType.value = materials[0].type || 'image';
// loading
if (materials[0].type === 'image') {
activityImageLoading.value = true;
}
}
} catch { activityResourceUrl.value = ''; }
}
@ -1192,6 +1262,14 @@ onUnmounted(() => {
justify-content: center;
padding: 20px;
.image-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
max-width: 100%;
max-height: calc(100% - 60px);
.full-image {
max-width: 100%;
max-height: calc(100% - 60px);
@ -1200,6 +1278,24 @@ onUnmounted(() => {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #999;
font-size: 14px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
}
}
.image-caption {
margin-top: 16px;
font-size: 18px;
@ -1774,6 +1870,32 @@ onUnmounted(() => {
background: white;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
border: 3px solid #FFE0B2;
.activity-image-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-height: 50vh;
.error-placeholder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #999;
font-size: 14px;
background: rgba(255, 255, 255, 0.9);
border-radius: 20px;
}
}
}
.activity-info {

View File

@ -8,6 +8,7 @@
:src="pages[currentPage]"
:key="currentPage"
class="slide-image"
:style="{ opacity: isLoading ? 0 : 1, transition: 'opacity 0.3s' }"
@load="onImageLoad"
@error="onImageError"
alt="幻灯片"
@ -109,7 +110,6 @@ import {
ChevronLeft,
ChevronRight,
Presentation,
FileText,
Download,
ExternalLink,
} from 'lucide-vue-next';

View File

@ -1,6 +1,7 @@
package com.reading.platform.common.aspect;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.filter.ValueFilter;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.reading.platform.common.annotation.Log;
import com.reading.platform.common.security.SecurityUtils;
@ -14,7 +15,6 @@ import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@ -43,6 +43,24 @@ public class LogAspect {
*/
private static final ThreadLocal<OperationLog> LOG_LOCAL = new ThreadLocal<>();
/**
* FastJSON2 值过滤器 - 用于脱敏敏感字段
*/
private static final ValueFilter SENSITIVE_FILTER = (obj, name, value) -> {
if (value == null) {
return null;
}
// 检查字段名是否包含敏感词
String lowerName = name.toLowerCase();
if (lowerName.contains("password") ||
lowerName.contains("passwd") ||
lowerName.contains("secret") ||
lowerName.contains("token")) {
return "***";
}
return value;
};
/**
* 前置通知在方法执行前记录日志
*
@ -98,9 +116,8 @@ public class LogAspect {
if (logAnnotation.recordParams()) {
try {
Object[] params = getRequestParams(joinPoint);
// 对参数进行脱敏处理移除敏感信息
Object[] sanitizedParams = desensitizeParams(joinPoint, params);
String paramsJson = JSON.toJSONString(sanitizedParams);
// 使用 ValueFilter 脱敏敏感字段后序列化
String paramsJson = JSON.toJSONString(params, SENSITIVE_FILTER);
operationLog.setRequestParams(paramsJson);
} catch (Exception e) {
log.warn("记录请求参数失败:{}", e.getMessage());
@ -209,68 +226,4 @@ public class LogAspect {
private Object[] getRequestParams(JoinPoint joinPoint) {
return joinPoint.getArgs();
}
/**
* 对请求参数进行脱敏处理密码字段
* <p>
* 注意DTO 类中的密码字段使用 @JsonIgnore 注解序列化时会自动忽略
* 本方法主要处理 String 类型的直接参数 oldPassword, newPassword
* </p>
*
* @param joinPoint 切面连接点
* @param params 原始参数
* @return 脱敏后的参数
*/
private Object[] desensitizeParams(JoinPoint joinPoint, Object[] params) {
if (params == null || params.length == 0) {
return params;
}
Object[] sanitized = new Object[params.length];
for (int i = 0; i < params.length; i++) {
if (params[i] instanceof String strParam) {
// 检查参数名是否包含敏感词通过方法参数名判断
String paramName = getParameterName(joinPoint, i);
if (isSensitiveParam(paramName)) {
sanitized[i] = "***";
} else {
sanitized[i] = strParam;
}
} else {
// DTO 对象依赖 @JsonIgnore 注解自动脱敏
sanitized[i] = params[i];
}
}
return sanitized;
}
/**
* 获取参数名
*/
private String getParameterName(JoinPoint joinPoint, int paramIndex) {
try {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
if (paramNames != null && paramIndex < paramNames.length) {
return paramNames[paramIndex];
}
} catch (Exception e) {
log.debug("获取参数名失败:{}", e.getMessage());
}
return "";
}
/**
* 判断是否为敏感参数名
*/
private boolean isSensitiveParam(String paramName) {
if (paramName == null || paramName.isEmpty()) {
return false;
}
String lowerName = paramName.toLowerCase();
return lowerName.contains("password") ||
lowerName.contains("passwd") ||
lowerName.contains("secret") ||
lowerName.contains("token");
}
}

View File

@ -39,7 +39,7 @@ public enum ErrorCode {
// Package Errors (3100+)
PACKAGE_NOT_FOUND(3101, "套餐不存在"),
REMOVE_PACKAGE_HAS_SCHEDULES(3102, "该套餐下有排课计划,请确认是否强制移"),
REMOVE_PACKAGE_HAS_SCHEDULES(3102, "该套餐下有排课计划,无法删"),
// User Errors (4000+)
USER_NOT_FOUND(4001, "用户不存在"),

View File

@ -240,7 +240,7 @@ public class TenantServiceImpl extends com.baomidou.mybatisplus.extension.servic
warningList.add((Map<String, Object>) warning);
}
throw new BusinessException(ErrorCode.REMOVE_PACKAGE_HAS_SCHEDULES,
"该套餐下有排课计划,请确认是否强制移", warningList);
"该套餐下有排课计划,无法删", warningList);
}
}