fix: 成长档案 images JSON 格式化、排序与卡片排版
- 后端:GrowthRecordResponse.images 改为 List<String>,Mapper 解析 JSON - 后端:TeacherGrowthController 返回 GrowthRecordResponse 统一 images 格式 - 后端:分页按 updatedAt、createdAt 倒序排序 - 前端:教师/学校端成长档案卡片封面与排版修复(cover 约束、flex 布局) Made-with: Cursor
This commit is contained in:
parent
ac8e07c784
commit
7d659e87c8
@ -182,10 +182,10 @@
|
|||||||
<a-form-item label="图片">
|
<a-form-item label="图片">
|
||||||
<a-upload
|
<a-upload
|
||||||
v-model:file-list="fileList"
|
v-model:file-list="fileList"
|
||||||
:action="uploadUrl"
|
:custom-request="handleCustomUpload"
|
||||||
:headers="uploadHeaders"
|
|
||||||
list-type="picture-card"
|
list-type="picture-card"
|
||||||
:max-count="9"
|
:max-count="9"
|
||||||
|
accept="image/*"
|
||||||
@change="handleUploadChange"
|
@change="handleUploadChange"
|
||||||
>
|
>
|
||||||
<div v-if="fileList.length < 9">
|
<div v-if="fileList.length < 9">
|
||||||
@ -273,6 +273,7 @@ import {
|
|||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import type { FormInstance } from 'ant-design-vue';
|
import type { FormInstance } from 'ant-design-vue';
|
||||||
|
import { fileApi, validateFileType } from '@/api/file';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@ -404,10 +405,41 @@ const handleModalOk = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadChange = (info: any) => {
|
/** OSS 直传:自定义上传 */
|
||||||
if (info.file.status === 'done') {
|
const handleCustomUpload = async (options: any) => {
|
||||||
formState.images.push(info.file.response.url);
|
const { file, onSuccess, onError, onProgress } = options;
|
||||||
|
const uploadFile = file instanceof File ? file : (file?.originFileObj ?? file);
|
||||||
|
|
||||||
|
const isImage = uploadFile.type?.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
onError?.(new Error('只能上传图片'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validation = validateFileType(uploadFile, 'POSTER');
|
||||||
|
if (!validation.valid) {
|
||||||
|
message.error(validation.error);
|
||||||
|
onError?.(new Error(validation.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileApi.uploadFile(uploadFile, 'resource', {
|
||||||
|
onProgress: (percent) => onProgress?.({ percent }),
|
||||||
|
});
|
||||||
|
onSuccess?.({ url: result.filePath });
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || '上传失败';
|
||||||
|
message.error(msg);
|
||||||
|
onError?.(new Error(msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange = (info: any) => {
|
||||||
|
formState.images = (info.fileList || [])
|
||||||
|
.filter((f: any) => f.response?.url)
|
||||||
|
.map((f: any) => f.response.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -528,6 +560,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.record-card {
|
.record-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -542,20 +576,30 @@ onMounted(() => {
|
|||||||
|
|
||||||
.card-cover {
|
.card-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.cover-image img {
|
.cover-image img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-count {
|
.image-count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
z-index: 2;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -581,6 +625,7 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
|
z-index: 2;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -605,7 +650,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-title {
|
.record-title {
|
||||||
@ -639,16 +688,24 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #636E72;
|
color: #636E72;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0 0 12px 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid #F0F0F0;
|
border-top: 1px solid #F0F0F0;
|
||||||
background: #FAFAFA;
|
background: #FAFAFA;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@ -24,24 +24,14 @@
|
|||||||
<!-- 操作栏 -->
|
<!-- 操作栏 -->
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<a-select
|
<a-select v-model:value="filters.classId" placeholder="选择班级" allow-clear style="width: 150px;"
|
||||||
v-model:value="filters.classId"
|
@change="handleFilter">
|
||||||
placeholder="选择班级"
|
|
||||||
allow-clear
|
|
||||||
style="width: 150px;"
|
|
||||||
@change="handleFilter"
|
|
||||||
>
|
|
||||||
<a-select-option v-for="cls in myClasses" :key="cls.id" :value="cls.id">
|
<a-select-option v-for="cls in myClasses" :key="cls.id" :value="cls.id">
|
||||||
{{ cls.name }}
|
{{ cls.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
<a-input-search
|
<a-input-search v-model:value="filters.keyword" placeholder="搜索标题" style="width: 200px;" @search="handleFilter"
|
||||||
v-model:value="filters.keyword"
|
allow-clear />
|
||||||
placeholder="搜索标题"
|
|
||||||
style="width: 200px;"
|
|
||||||
@search="handleFilter"
|
|
||||||
allow-clear
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<a-button type="primary" class="add-btn" @click="showAddModal">
|
<a-button type="primary" class="add-btn" @click="showAddModal">
|
||||||
<PlusOutlined class="btn-icon" />
|
<PlusOutlined class="btn-icon" />
|
||||||
@ -51,14 +41,10 @@
|
|||||||
|
|
||||||
<!-- 档案卡片网格 -->
|
<!-- 档案卡片网格 -->
|
||||||
<div class="record-grid" v-if="!loading && records.length > 0">
|
<div class="record-grid" v-if="!loading && records.length > 0">
|
||||||
<div
|
<div v-for="record in records" :key="record.id" class="record-card">
|
||||||
v-for="record in records"
|
|
||||||
:key="record.id"
|
|
||||||
class="record-card"
|
|
||||||
>
|
|
||||||
<div class="card-cover">
|
<div class="card-cover">
|
||||||
<div v-if="record.images?.length" class="cover-image">
|
<div v-if="record.images?.length" class="cover-image ">
|
||||||
<img :src="getImageUrl(record.images[0])" alt="cover" />
|
<img :src="getImageUrl(record.images[0])" class="pos-absolute !object-contain " />
|
||||||
<div class="image-count" v-if="record.images.length > 1">
|
<div class="image-count" v-if="record.images.length > 1">
|
||||||
<CameraOutlined /> {{ record.images.length }}
|
<CameraOutlined /> {{ record.images.length }}
|
||||||
</div>
|
</div>
|
||||||
@ -88,7 +74,8 @@
|
|||||||
<span>{{ formatDate(record.recordDate) }}</span>
|
<span>{{ formatDate(record.recordDate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="record-content">{{ record.content?.substring(0, 80) }}{{ record.content?.length > 80 ? '...' : '' }}</p>
|
<p class="record-content">{{ record.content?.substring(0, 80) }}{{ record.content?.length > 80 ? '...' : '' }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<a-button type="link" size="small" @click="handleView(record)">
|
<a-button type="link" size="small" @click="handleView(record)">
|
||||||
@ -124,12 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加/编辑档案弹窗 -->
|
<!-- 添加/编辑档案弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="modalVisible" width="700px" @ok="handleModalOk" :confirm-loading="submitting">
|
||||||
v-model:open="modalVisible"
|
|
||||||
width="700px"
|
|
||||||
@ok="handleModalOk"
|
|
||||||
:confirm-loading="submitting"
|
|
||||||
>
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="modal-title">
|
<span class="modal-title">
|
||||||
<EditOutlined v-if="isEdit" class="modal-title-icon" />
|
<EditOutlined v-if="isEdit" class="modal-title-icon" />
|
||||||
@ -137,20 +119,10 @@
|
|||||||
{{ isEdit ? ' 编辑档案' : ' 添加档案' }}
|
{{ isEdit ? ' 编辑档案' : ' 添加档案' }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<a-form
|
<a-form ref="formRef" :model="formState" :rules="rules" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
||||||
ref="formRef"
|
|
||||||
:model="formState"
|
|
||||||
:rules="rules"
|
|
||||||
:label-col="{ span: 4 }"
|
|
||||||
:wrapper-col="{ span: 18 }"
|
|
||||||
>
|
|
||||||
<a-form-item label="学生" name="studentId" v-if="!isEdit">
|
<a-form-item label="学生" name="studentId" v-if="!isEdit">
|
||||||
<a-select
|
<a-select v-model:value="formState.studentId" placeholder="请选择学生" show-search
|
||||||
v-model:value="formState.studentId"
|
:filter-option="filterStudentOption">
|
||||||
placeholder="请选择学生"
|
|
||||||
show-search
|
|
||||||
:filter-option="filterStudentOption"
|
|
||||||
>
|
|
||||||
<a-select-option v-for="student in students" :key="student.id" :value="student.id">
|
<a-select-option v-for="student in students" :key="student.id" :value="student.id">
|
||||||
{{ student.name }} - {{ student.className }}
|
{{ student.name }} - {{ student.className }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
@ -158,36 +130,26 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="档案类型" name="recordType" v-if="!isEdit">
|
<a-form-item label="档案类型" name="recordType" v-if="!isEdit">
|
||||||
<a-radio-group v-model:value="formState.recordType">
|
<a-radio-group v-model:value="formState.recordType">
|
||||||
<a-radio value="STUDENT"><UserOutlined class="radio-icon" /> 个人档案</a-radio>
|
<a-radio value="STUDENT">
|
||||||
<a-radio value="CLASS"><TeamOutlined class="radio-icon" /> 班级档案</a-radio>
|
<UserOutlined class="radio-icon" /> 个人档案
|
||||||
|
</a-radio>
|
||||||
|
<a-radio value="CLASS">
|
||||||
|
<TeamOutlined class="radio-icon" /> 班级档案
|
||||||
|
</a-radio>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="标题" name="title">
|
<a-form-item label="标题" name="title">
|
||||||
<a-input v-model:value="formState.title" placeholder="请输入档案标题" />
|
<a-input v-model:value="formState.title" placeholder="请输入档案标题" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="记录日期" name="recordDate">
|
<a-form-item label="记录日期" name="recordDate">
|
||||||
<a-date-picker
|
<a-date-picker v-model:value="formState.recordDateValue" style="width: 100%;" value-format="YYYY-MM-DD" />
|
||||||
v-model:value="formState.recordDateValue"
|
|
||||||
style="width: 100%;"
|
|
||||||
value-format="YYYY-MM-DD"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="内容" name="content">
|
<a-form-item label="内容" name="content">
|
||||||
<a-textarea
|
<a-textarea v-model:value="formState.content" placeholder="请输入档案内容" :rows="4" />
|
||||||
v-model:value="formState.content"
|
|
||||||
placeholder="请输入档案内容"
|
|
||||||
:rows="4"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="图片">
|
<a-form-item label="图片">
|
||||||
<a-upload
|
<a-upload v-model:file-list="fileList" :custom-request="handleCustomUpload" list-type="picture-card"
|
||||||
v-model:file-list="fileList"
|
:max-count="9" accept="image/*" @change="handleUploadChange">
|
||||||
:action="uploadUrl"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
list-type="picture-card"
|
|
||||||
:max-count="9"
|
|
||||||
@change="handleUploadChange"
|
|
||||||
>
|
|
||||||
<div v-if="fileList.length < 9">
|
<div v-if="fileList.length < 9">
|
||||||
<PlusOutlined />
|
<PlusOutlined />
|
||||||
<div style="margin-top: 8px">上传</div>
|
<div style="margin-top: 8px">上传</div>
|
||||||
@ -198,11 +160,7 @@
|
|||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 查看档案详情弹窗 -->
|
<!-- 查看档案详情弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="detailModalVisible" width="700px" :footer="null">
|
||||||
v-model:open="detailModalVisible"
|
|
||||||
width="700px"
|
|
||||||
:footer="null"
|
|
||||||
>
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="modal-title">
|
<span class="modal-title">
|
||||||
<CameraOutlined class="modal-title-icon" />
|
<CameraOutlined class="modal-title-icon" />
|
||||||
@ -231,12 +189,8 @@
|
|||||||
|
|
||||||
<div v-if="currentRecord.images?.length" class="image-gallery">
|
<div v-if="currentRecord.images?.length" class="image-gallery">
|
||||||
<a-image-preview-group>
|
<a-image-preview-group>
|
||||||
<a-image
|
<a-image v-for="(img, index) in currentRecord.images" :key="index" :src="getImageUrl(img)"
|
||||||
v-for="(img, index) in currentRecord.images"
|
style="width: 100px; height: 100px; object-fit: cover; margin-right: 8px; border-radius: 8px;" />
|
||||||
:key="index"
|
|
||||||
:src="getImageUrl(img)"
|
|
||||||
style="width: 100px; height: 100px; object-fit: cover; margin-right: 8px; border-radius: 8px;"
|
|
||||||
/>
|
|
||||||
</a-image-preview-group>
|
</a-image-preview-group>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -245,14 +199,9 @@
|
|||||||
|
|
||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div class="pagination-wrapper" v-if="records.length > 0">
|
<div class="pagination-wrapper" v-if="records.length > 0">
|
||||||
<a-pagination
|
<a-pagination v-model:current="pagination.current" v-model:pageSize="pagination.pageSize"
|
||||||
v-model:current="pagination.current"
|
:total="pagination.total" :show-size-changer="true" :show-total="(total: number) => `共 ${total} 条`"
|
||||||
v-model:pageSize="pagination.pageSize"
|
@change="handlePageChange" />
|
||||||
:total="pagination.total"
|
|
||||||
:show-size-changer="true"
|
|
||||||
:show-total="(total: number) => `共 ${total} 条`"
|
|
||||||
@change="handlePageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -283,6 +232,7 @@ import {
|
|||||||
type UpdateGrowthRecordDto,
|
type UpdateGrowthRecordDto,
|
||||||
} from '@/api/growth';
|
} from '@/api/growth';
|
||||||
import { getTeacherClasses, getTeacherStudents } from '@/api/teacher';
|
import { getTeacherClasses, getTeacherStudents } from '@/api/teacher';
|
||||||
|
import { fileApi, validateFileType } from '@/api/file';
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@ -309,9 +259,6 @@ const records = ref<GrowthRecord[]>([]);
|
|||||||
const currentRecord = ref<GrowthRecord | null>(null);
|
const currentRecord = ref<GrowthRecord | null>(null);
|
||||||
const fileList = ref<any[]>([]);
|
const fileList = ref<any[]>([]);
|
||||||
|
|
||||||
const uploadUrl = '/api/upload';
|
|
||||||
const uploadHeaders = {};
|
|
||||||
|
|
||||||
const formState = reactive<CreateGrowthRecordDto & { recordDateValue?: string }>({
|
const formState = reactive<CreateGrowthRecordDto & { recordDateValue?: string }>({
|
||||||
studentId: undefined as any,
|
studentId: undefined as any,
|
||||||
classId: undefined,
|
classId: undefined,
|
||||||
@ -476,10 +423,41 @@ const handleModalOk = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUploadChange = (info: any) => {
|
/** OSS 直传:自定义上传 */
|
||||||
if (info.file.status === 'done') {
|
const handleCustomUpload = async (options: any) => {
|
||||||
formState.images.push(info.file.response.url);
|
const { file, onSuccess, onError, onProgress } = options;
|
||||||
|
const uploadFile = file instanceof File ? file : (file?.originFileObj ?? file);
|
||||||
|
|
||||||
|
const isImage = uploadFile.type?.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
onError?.(new Error('只能上传图片'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validation = validateFileType(uploadFile, 'POSTER');
|
||||||
|
if (!validation.valid) {
|
||||||
|
message.error(validation.error);
|
||||||
|
onError?.(new Error(validation.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fileApi.uploadFile(uploadFile, 'resource', {
|
||||||
|
onProgress: (percent) => onProgress?.({ percent }),
|
||||||
|
});
|
||||||
|
onSuccess?.({ url: result.filePath });
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err?.message || '上传失败';
|
||||||
|
message.error(msg);
|
||||||
|
onError?.(new Error(msg));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange = (info: any) => {
|
||||||
|
formState.images = (info.fileList || [])
|
||||||
|
.filter((f: any) => f.response?.url)
|
||||||
|
.map((f: any) => f.response.url);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -602,6 +580,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.record-card {
|
.record-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -616,20 +596,30 @@ onMounted(() => {
|
|||||||
|
|
||||||
.card-cover {
|
.card-cover {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
height: 160px;
|
height: 160px;
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.cover-image img {
|
.cover-image img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-count {
|
.image-count {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
right: 8px;
|
right: 8px;
|
||||||
|
z-index: 2;
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: rgba(0, 0, 0, 0.6);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@ -655,6 +645,7 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
|
z-index: 2;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@ -679,7 +670,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-title {
|
.record-title {
|
||||||
@ -713,16 +708,24 @@ onMounted(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #636E72;
|
color: #636E72;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
margin: 0;
|
margin: 0 0 12px 0;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid #F0F0F0;
|
border-top: 1px solid #F0F0F0;
|
||||||
background: #FAFAFA;
|
background: #FAFAFA;
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.reading.platform.common.mapper;
|
|||||||
import com.reading.platform.dto.response.GrowthRecordResponse;
|
import com.reading.platform.dto.response.GrowthRecordResponse;
|
||||||
import com.reading.platform.entity.GrowthRecord;
|
import com.reading.platform.entity.GrowthRecord;
|
||||||
import org.mapstruct.Mapper;
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
import org.mapstruct.factory.Mappers;
|
import org.mapstruct.factory.Mappers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -16,8 +17,9 @@ public interface GrowthRecordMapper {
|
|||||||
GrowthRecordMapper INSTANCE = Mappers.getMapper(GrowthRecordMapper.class);
|
GrowthRecordMapper INSTANCE = Mappers.getMapper(GrowthRecordMapper.class);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity 转 Response
|
* Entity 转 Response(images 从 JSON 字符串解析为 List)
|
||||||
*/
|
*/
|
||||||
|
@Mapping(target = "images", expression = "java(java.util.Arrays.asList(com.reading.platform.common.util.JsonUtils.parseStringArray(entity.getImages())))")
|
||||||
GrowthRecordResponse toVO(GrowthRecord entity);
|
GrowthRecordResponse toVO(GrowthRecord entity);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,7 +28,8 @@ public interface GrowthRecordMapper {
|
|||||||
List<GrowthRecordResponse> toVO(List<GrowthRecord> entities);
|
List<GrowthRecordResponse> toVO(List<GrowthRecord> entities);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response 转 Entity(用于创建/更新时)
|
* Response 转 Entity(images 从 List 转为 JSON 字符串)
|
||||||
*/
|
*/
|
||||||
|
@Mapping(target = "images", expression = "java(vo.getImages() != null ? com.reading.platform.common.util.JsonUtils.toJson(vo.getImages()) : null)")
|
||||||
GrowthRecord toEntity(GrowthRecordResponse vo);
|
GrowthRecord toEntity(GrowthRecordResponse vo);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
package com.reading.platform.controller.teacher;
|
package com.reading.platform.controller.teacher;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||||
|
import com.reading.platform.common.mapper.GrowthRecordMapper;
|
||||||
import com.reading.platform.common.response.PageResult;
|
import com.reading.platform.common.response.PageResult;
|
||||||
import com.reading.platform.common.response.Result;
|
import com.reading.platform.common.response.Result;
|
||||||
import com.reading.platform.common.security.SecurityUtils;
|
import com.reading.platform.common.security.SecurityUtils;
|
||||||
import com.reading.platform.dto.request.GrowthRecordCreateRequest;
|
import com.reading.platform.dto.request.GrowthRecordCreateRequest;
|
||||||
import com.reading.platform.dto.request.GrowthRecordUpdateRequest;
|
import com.reading.platform.dto.request.GrowthRecordUpdateRequest;
|
||||||
|
import com.reading.platform.dto.response.GrowthRecordResponse;
|
||||||
import com.reading.platform.entity.GrowthRecord;
|
import com.reading.platform.entity.GrowthRecord;
|
||||||
import com.reading.platform.service.GrowthRecordService;
|
import com.reading.platform.service.GrowthRecordService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@ -14,6 +16,8 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Tag(name = "教师端 - 成长记录", description = "教师端成长记录 API")
|
@Tag(name = "教师端 - 成长记录", description = "教师端成长记录 API")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/teacher/growth-records")
|
@RequestMapping("/api/v1/teacher/growth-records")
|
||||||
@ -21,37 +25,42 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
public class TeacherGrowthController {
|
public class TeacherGrowthController {
|
||||||
|
|
||||||
private final GrowthRecordService growthRecordService;
|
private final GrowthRecordService growthRecordService;
|
||||||
|
private final GrowthRecordMapper growthRecordMapper;
|
||||||
|
|
||||||
@Operation(summary = "创建成长记录")
|
@Operation(summary = "创建成长记录")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public Result<GrowthRecord> createGrowthRecord(@Valid @RequestBody GrowthRecordCreateRequest request) {
|
public Result<GrowthRecordResponse> createGrowthRecord(@Valid @RequestBody GrowthRecordCreateRequest request) {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
Long userId = SecurityUtils.getCurrentUserId();
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
return Result.success(growthRecordService.createGrowthRecord(tenantId, userId, "teacher", request));
|
GrowthRecord record = growthRecordService.createGrowthRecord(tenantId, userId, "teacher", request);
|
||||||
|
return Result.success(growthRecordMapper.toVO(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "更新成长记录")
|
@Operation(summary = "更新成长记录")
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public Result<GrowthRecord> updateGrowthRecord(@PathVariable Long id, @RequestBody GrowthRecordUpdateRequest request) {
|
public Result<GrowthRecordResponse> updateGrowthRecord(@PathVariable Long id, @RequestBody GrowthRecordUpdateRequest request) {
|
||||||
return Result.success(growthRecordService.updateGrowthRecord(id, request));
|
GrowthRecord record = growthRecordService.updateGrowthRecord(id, request);
|
||||||
|
return Result.success(growthRecordMapper.toVO(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "根据 ID 获取成长记录")
|
@Operation(summary = "根据 ID 获取成长记录")
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Result<GrowthRecord> getGrowthRecord(@PathVariable Long id) {
|
public Result<GrowthRecordResponse> getGrowthRecord(@PathVariable Long id) {
|
||||||
return Result.success(growthRecordService.getGrowthRecordById(id));
|
GrowthRecord record = growthRecordService.getGrowthRecordById(id);
|
||||||
|
return Result.success(growthRecordMapper.toVO(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取成长记录分页列表")
|
@Operation(summary = "获取成长记录分页列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public Result<PageResult<GrowthRecord>> getGrowthRecordPage(
|
public Result<PageResult<GrowthRecordResponse>> getGrowthRecordPage(
|
||||||
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
|
||||||
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
|
@RequestParam(required = false, defaultValue = "10") Integer pageSize,
|
||||||
@RequestParam(required = false) Long studentId,
|
@RequestParam(required = false) Long studentId,
|
||||||
@RequestParam(required = false) String type) {
|
@RequestParam(required = false) String type) {
|
||||||
Long tenantId = SecurityUtils.getCurrentTenantId();
|
Long tenantId = SecurityUtils.getCurrentTenantId();
|
||||||
Page<GrowthRecord> page = growthRecordService.getGrowthRecordPage(tenantId, pageNum, pageSize, studentId, type);
|
Page<GrowthRecord> page = growthRecordService.getGrowthRecordPage(tenantId, pageNum, pageSize, studentId, type);
|
||||||
return Result.success(PageResult.of(page));
|
List<GrowthRecordResponse> voList = growthRecordMapper.toVO(page.getRecords());
|
||||||
|
return Result.success(PageResult.of(voList, page.getTotal(), page.getCurrent(), page.getSize()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "删除成长记录")
|
@Operation(summary = "删除成长记录")
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.reading.platform.dto.request;
|
package com.reading.platform.dto.request;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
@ -17,7 +18,8 @@ public class GrowthRecordCreateRequest {
|
|||||||
private Long studentId;
|
private Long studentId;
|
||||||
|
|
||||||
@NotBlank(message = "类型不能为空")
|
@NotBlank(message = "类型不能为空")
|
||||||
@Schema(description = "类型:reading-阅读,behavior-行为,achievement-成就,milestone-里程碑")
|
@JsonAlias("recordType")
|
||||||
|
@Schema(description = "类型:reading-阅读,behavior-行为,achievement-成就,milestone-里程碑,STUDENT-学生记录")
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@NotBlank(message = "标题不能为空")
|
@NotBlank(message = "标题不能为空")
|
||||||
@ -27,8 +29,8 @@ public class GrowthRecordCreateRequest {
|
|||||||
@Schema(description = "内容")
|
@Schema(description = "内容")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@Schema(description = "图片(JSON 数组)")
|
@Schema(description = "图片 URL 列表")
|
||||||
private String images;
|
private List<String> images;
|
||||||
|
|
||||||
@Schema(description = "记录日期")
|
@Schema(description = "记录日期")
|
||||||
private LocalDate recordDate;
|
private LocalDate recordDate;
|
||||||
|
|||||||
@ -19,8 +19,8 @@ public class GrowthRecordUpdateRequest {
|
|||||||
@Schema(description = "内容")
|
@Schema(description = "内容")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@Schema(description = "图片(JSON 数组)")
|
@Schema(description = "图片 URL 列表")
|
||||||
private String images;
|
private List<String> images;
|
||||||
|
|
||||||
@Schema(description = "记录日期")
|
@Schema(description = "记录日期")
|
||||||
private LocalDate recordDate;
|
private LocalDate recordDate;
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成长记录响应
|
* 成长记录响应
|
||||||
@ -34,8 +35,8 @@ public class GrowthRecordResponse {
|
|||||||
@Schema(description = "内容")
|
@Schema(description = "内容")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@Schema(description = "图片")
|
@Schema(description = "图片URL列表")
|
||||||
private String images;
|
private List<String> images;
|
||||||
|
|
||||||
@Schema(description = "记录人 ID")
|
@Schema(description = "记录人 ID")
|
||||||
private Long recordedBy;
|
private Long recordedBy;
|
||||||
|
|||||||
@ -39,7 +39,13 @@ public class GrowthRecordServiceImpl extends ServiceImpl<GrowthRecordMapper, Gro
|
|||||||
record.setType(request.getType());
|
record.setType(request.getType());
|
||||||
record.setTitle(request.getTitle());
|
record.setTitle(request.getTitle());
|
||||||
record.setContent(request.getContent());
|
record.setContent(request.getContent());
|
||||||
record.setImages(request.getImages());
|
if (request.getImages() != null) {
|
||||||
|
try {
|
||||||
|
record.setImages(objectMapper.writeValueAsString(request.getImages()));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
record.setImages("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
record.setRecordedBy(recorderId);
|
record.setRecordedBy(recorderId);
|
||||||
record.setRecorderRole(recorderRole);
|
record.setRecorderRole(recorderRole);
|
||||||
record.setRecordDate(request.getRecordDate() != null ? request.getRecordDate() : LocalDate.now());
|
record.setRecordDate(request.getRecordDate() != null ? request.getRecordDate() : LocalDate.now());
|
||||||
@ -72,7 +78,11 @@ public class GrowthRecordServiceImpl extends ServiceImpl<GrowthRecordMapper, Gro
|
|||||||
record.setContent(request.getContent());
|
record.setContent(request.getContent());
|
||||||
}
|
}
|
||||||
if (request.getImages() != null) {
|
if (request.getImages() != null) {
|
||||||
record.setImages(request.getImages());
|
try {
|
||||||
|
record.setImages(objectMapper.writeValueAsString(request.getImages()));
|
||||||
|
} catch (JsonProcessingException e) {
|
||||||
|
record.setImages("[]");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (request.getRecordDate() != null) {
|
if (request.getRecordDate() != null) {
|
||||||
record.setRecordDate(request.getRecordDate());
|
record.setRecordDate(request.getRecordDate());
|
||||||
@ -127,7 +137,8 @@ public class GrowthRecordServiceImpl extends ServiceImpl<GrowthRecordMapper, Gro
|
|||||||
if (StringUtils.hasText(type)) {
|
if (StringUtils.hasText(type)) {
|
||||||
wrapper.eq(GrowthRecord::getType, type);
|
wrapper.eq(GrowthRecord::getType, type);
|
||||||
}
|
}
|
||||||
wrapper.orderByDesc(GrowthRecord::getRecordDate);
|
// 按最新修改时间、最新创建时间倒序
|
||||||
|
wrapper.orderByDesc(GrowthRecord::getUpdatedAt).orderByDesc(GrowthRecord::getCreatedAt);
|
||||||
|
|
||||||
return growthRecordMapper.selectPage(page, wrapper);
|
return growthRecordMapper.selectPage(page, wrapper);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user