feat: 资源库管理优化 - 直传、WebOffice预览、接口对齐

- 资源上传改为 OSS 直传,支持进度与取消
- Office 文档(PDF/PPT/Word/Excel)使用 WebOffice 在线预览
- 后端 ResourceItemResponse 补充 title/fileType/filePath/fileSize/tags/library
- 后端 getStats 返回 totalLibraries/totalItems/itemsByLibraryType
- 前后端字段对齐:libraryType、name/type 兼容
- 修复 tags 反序列化、name 必填、主键冲突问题
- 新增 V16 迁移修复 resource_item AUTO_INCREMENT

Made-with: Cursor
This commit is contained in:
zhonghua 2026-03-17 15:53:04 +08:00
parent 673214481d
commit 193bbe90ae
15 changed files with 317 additions and 151 deletions

View File

@ -174,6 +174,7 @@ export const fileApi = {
| "ppt"
| "poster"
| "document"
| "resource"
| "other",
options?: {
onProgress?: (percent: number) => void;
@ -235,6 +236,7 @@ export const FILE_TYPES = {
PPT: "ppt",
POSTER: "poster",
DOCUMENT: "document",
RESOURCE: "resource",
OTHER: "other",
} as const;
@ -249,6 +251,7 @@ export const FILE_SIZE_LIMITS = {
PPT: 300 * 1024 * 1024, // 300MB
POSTER: 10 * 1024 * 1024, // 10MB
DOCUMENT: 300 * 1024 * 1024, // 300MB
RESOURCE: 100 * 1024 * 1024, // 100MB资源库单文件限制
OTHER: 300 * 1024 * 1024, // 300MB
} as const;

View File

@ -128,7 +128,7 @@ export const deleteResourceItem = (id: number) =>
http.delete(`/v1/admin/resources/items/${id}`);
export const batchDeleteResourceItems = (ids: number[]) =>
http.post<{ message: string }>('/v1/admin/resources/items/batch-delete', { ids });
http.post<{ message: string }>('/v1/admin/resources/items/batch-delete', ids);
// ==================== 统计数据 ====================

View File

@ -11,8 +11,10 @@ declare module 'vue' {
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
@ -23,6 +25,8 @@ declare module 'vue' {
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
@ -48,11 +52,14 @@ declare module 'vue' {
ARate: typeof import('ant-design-vue/es')['Rate']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']

View File

@ -1,37 +1,42 @@
<template>
<div class="resource-list-view">
<a-page-header
title="资源库管理"
sub-title="管理平台数字资源"
/>
<a-page-header title="资源库管理" sub-title="管理平台数字资源" />
<!-- 统计卡片 -->
<a-row :gutter="16" style="margin-bottom: 16px;">
<a-col :span="6">
<a-card>
<a-statistic title="资源库总数" :value="stats.totalLibraries">
<template #prefix><FolderOutlined /></template>
<template #prefix>
<FolderOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="资源总数" :value="stats.totalItems">
<template #prefix><FileOutlined /></template>
<template #prefix>
<FileOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="绘本资源" :value="stats.itemsByLibraryType?.PICTURE_BOOK || 0">
<template #prefix><BookOutlined /></template>
<template #prefix>
<BookOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic title="教学材料" :value="stats.itemsByLibraryType?.MATERIAL || 0">
<template #prefix><AppstoreOutlined /></template>
<template #prefix>
<AppstoreOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
@ -41,11 +46,15 @@
<template #extra>
<a-space>
<a-button @click="showLibraryModal">
<template #icon><FolderAddOutlined /></template>
<template #icon>
<FolderAddOutlined />
</template>
新建资源库
</a-button>
<a-button type="primary" @click="showUploadModal">
<template #icon><UploadOutlined /></template>
<template #icon>
<UploadOutlined />
</template>
上传资源
</a-button>
</a-space>
@ -55,36 +64,23 @@
<div class="filter-bar" style="margin-bottom: 16px;">
<a-row :gutter="16">
<a-col :span="6">
<a-input-search
v-model:value="filters.keyword"
placeholder="搜索资源名称"
allow-clear
@search="fetchItems"
>
<template #prefix><SearchOutlined /></template>
<a-input-search v-model:value="filters.keyword" placeholder="搜索资源名称" allow-clear @search="fetchItems">
<template #prefix>
<SearchOutlined />
</template>
</a-input-search>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.libraryId"
placeholder="选择资源库"
allow-clear
style="width: 100%"
@change="fetchItems"
>
<a-select v-model:value="filters.libraryId" placeholder="选择资源库" allow-clear style="width: 100%"
@change="fetchItems">
<a-select-option v-for="lib in libraries" :key="lib.id" :value="lib.id">
{{ lib.name }}
</a-select-option>
</a-select>
</a-col>
<a-col :span="4">
<a-select
v-model:value="filters.fileType"
placeholder="资源类型"
allow-clear
style="width: 100%"
@change="fetchItems"
>
<a-select v-model:value="filters.fileType" placeholder="资源类型" allow-clear style="width: 100%"
@change="fetchItems">
<a-select-option value="IMAGE">图片</a-select-option>
<a-select-option value="PDF">PDF</a-select-option>
<a-select-option value="VIDEO">视频</a-select-option>
@ -96,30 +92,23 @@
</a-row>
</div>
<a-table
:columns="columns"
:data-source="items"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
@change="handleTableChange"
row-key="id"
>
<a-table :columns="columns" :data-source="items" :loading="loading" :pagination="pagination"
:row-selection="rowSelection" @change="handleTableChange" row-key="id">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'resource'">
<div class="resource-item">
<div class="resource-icon">
<FilePdfOutlined v-if="record.fileType === 'PDF'" />
<VideoCameraOutlined v-else-if="record.fileType === 'VIDEO'" />
<AudioOutlined v-else-if="record.fileType === 'AUDIO'" />
<PictureOutlined v-else-if="record.fileType === 'IMAGE'" />
<FilePptOutlined v-else-if="record.fileType === 'PPT'" />
<FilePdfOutlined v-if="getRecordFileType(record) === 'PDF'" />
<VideoCameraOutlined v-else-if="getRecordFileType(record) === 'VIDEO'" />
<AudioOutlined v-else-if="getRecordFileType(record) === 'AUDIO'" />
<PictureOutlined v-else-if="getRecordFileType(record) === 'IMAGE'" />
<FilePptOutlined v-else-if="getRecordFileType(record) === 'PPT'" />
<FileOutlined v-else />
</div>
<div class="resource-info">
<div class="resource-name">{{ record.title }}</div>
<div class="resource-name">{{ record.title ?? record.name }}</div>
<div class="resource-meta">
<a-tag size="small">{{ getFileTypeLabel(record.fileType) }}</a-tag>
<a-tag size="small">{{ getFileTypeLabel(getRecordFileType(record)) }}</a-tag>
<span style="color: #999; font-size: 12px;">
{{ formatFileSize(record.fileSize) }}
</span>
@ -146,12 +135,8 @@
<a-button type="link" size="small" @click="openEditModal(record as ResourceItem)">
编辑
</a-button>
<a-button
type="link"
size="small"
:disabled="!canPreview(record as ResourceItem)"
@click="previewResource(record as ResourceItem)"
>
<a-button type="link" size="small" :disabled="!canPreview(record as ResourceItem)"
@click="previewResource(record as ResourceItem)">
预览
</a-button>
<a-button type="link" size="small" @click="downloadResource(record as ResourceItem)">
@ -180,12 +165,7 @@
</a-card>
<!-- 新建资源库弹窗 -->
<a-modal
v-model:open="libraryModalVisible"
title="新建资源库"
@ok="handleCreateLibrary"
:confirm-loading="submitting"
>
<a-modal v-model:open="libraryModalVisible" title="新建资源库" @ok="handleCreateLibrary" :confirm-loading="submitting">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="资源库名称" required>
<a-input v-model:value="libraryForm.name" placeholder="请输入资源库名称" />
@ -204,13 +184,8 @@
</a-modal>
<!-- 上传资源弹窗 -->
<a-modal
v-model:open="uploadModalVisible"
title="上传资源"
width="600px"
@ok="handleUpload"
:confirm-loading="uploading"
>
<a-modal v-model:open="uploadModalVisible" title="上传资源" width="600px" @ok="handleUpload"
:confirm-loading="uploading">
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="目标资源库" required>
<a-select v-model:value="uploadForm.libraryId" placeholder="请选择资源库">
@ -232,15 +207,8 @@
</a-form-item>
<a-form-item label="选择文件" required>
<a-upload
v-model:file-list="uploadForm.files"
:action="uploadUrl"
:headers="uploadHeaders"
:data="{ type: 'resources' }"
:max-count="10"
list-type="text"
@change="handleUploadChange"
>
<a-upload v-model:file-list="uploadForm.files" :custom-request="handleCustomUpload" :max-count="10"
list-type="text" @change="handleUploadChange">
<a-button>
<UploadOutlined /> 选择文件
</a-button>
@ -251,12 +219,7 @@
</a-form-item>
<a-form-item label="资源标签">
<a-select
v-model:value="uploadForm.tags"
mode="tags"
placeholder="输入标签,按回车添加"
style="width: 100%"
>
<a-select v-model:value="uploadForm.tags" mode="tags" placeholder="输入标签,按回车添加" style="width: 100%">
<a-select-option value="绘本阅读">绘本阅读</a-select-option>
<a-select-option value="儿歌">儿歌</a-select-option>
<a-select-option value="游戏">游戏</a-select-option>
@ -268,12 +231,7 @@
</a-modal>
<!-- 编辑资源弹窗 -->
<a-modal
v-model:open="editModalVisible"
title="编辑资源"
@ok="handleEditConfirm"
:confirm-loading="submitting"
>
<a-modal v-model:open="editModalVisible" title="编辑资源" @ok="handleEditConfirm" :confirm-loading="submitting">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="资源名称">
<a-input v-model:value="editForm.title" placeholder="资源名称" />
@ -282,33 +240,36 @@
<a-textarea v-model:value="editForm.description" placeholder="描述" :rows="3" />
</a-form-item>
<a-form-item label="标签">
<a-select
v-model:value="editForm.tags"
mode="tags"
placeholder="输入标签"
style="width: 100%"
/>
<a-select v-model:value="editForm.tags" mode="tags" placeholder="输入标签" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<!-- 预览弹窗 -->
<a-modal
v-model:open="previewModalVisible"
:title="currentPreviewResource?.title"
width="800px"
:footer="null"
>
<a-modal v-model:open="previewModalVisible" :title="currentPreviewResource?.title" width="800px" :footer="null">
<div class="preview-container">
<div v-if="currentPreviewResource?.fileType === 'IMAGE'">
<img :src="getFileUrl(currentPreviewResource?.filePath)" style="max-width: 100%;" />
</div>
<div v-else-if="currentPreviewResource?.fileType === 'VIDEO'">
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%; max-height: 500px;"></video>
<video :src="getFileUrl(currentPreviewResource?.filePath)" controls
style="width: 100%; max-height: 500px;"></video>
</div>
<div v-else-if="currentPreviewResource?.fileType === 'AUDIO'">
<audio :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%;"></audio>
</div>
<div v-else-if="isOfficeDoc(currentPreviewResource)">
<div class="preview-placeholder office-preview">
<FileTextOutlined style="font-size: 64px; color: #1890ff;" />
<p style="color: #666;">使用在线文档预览</p>
<a-space>
<a-button type="primary" @click="openInWebOffice(currentPreviewResource)">
<EyeOutlined /> WebOffice 预览
</a-button>
<a-button @click="downloadResource(currentPreviewResource)">下载文件</a-button>
</a-space>
</div>
</div>
<div v-else class="preview-placeholder">
<FileTextOutlined style="font-size: 64px; color: #ccc;" />
<p style="color: #999;">此文件类型不支持在线预览</p>
@ -336,6 +297,7 @@ import {
FolderOutlined,
FolderAddOutlined,
AppstoreOutlined,
EyeOutlined,
} from '@ant-design/icons-vue';
import {
getLibraries,
@ -348,9 +310,8 @@ import {
getResourceStats,
} from '@/api/resource';
import type { ResourceLibrary, ResourceItem, FileType as FileTypeType } from '@/api/resource';
import { useUserStore } from '@/stores/user';
const userStore = useUserStore();
import { fileApi, validateFileType } from '@/api/file';
import { openWebOffice } from '@/views/office/webOffice';
const loading = ref(false);
const submitting = ref(false);
@ -415,11 +376,6 @@ const editForm = reactive({
tags: [] as string[],
});
const uploadUrl = '/api/v1/files/upload';
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.token}`,
}));
const columns = [
{ title: '资源', key: 'resource', width: 300 },
{ title: '所属资源库', key: 'library', width: 150 },
@ -428,6 +384,9 @@ const columns = [
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
];
/** 兼容后端返回 fileType 或 type */
const getRecordFileType = (record: any) => record?.fileType ?? record?.type ?? 'OTHER';
const getFileTypeLabel = (type: FileTypeType) => {
const labels: Record<FileTypeType, string> = {
IMAGE: '图片',
@ -447,9 +406,10 @@ const formatFileSize = (bytes?: number) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const formatDate = (dateStr: string) => {
const formatDate = (dateStr?: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('zh-CN');
return isNaN(date.getTime()) ? '-' : date.toLocaleDateString('zh-CN');
};
const getFileUrl = (path?: string) => {
@ -460,7 +420,56 @@ const getFileUrl = (path?: string) => {
};
const canPreview = (resource: ResourceItem) => {
return ['IMAGE', 'VIDEO', 'AUDIO'].includes(resource.fileType);
const fileType = (resource as any).fileType ?? (resource as any).type;
if (['IMAGE', 'VIDEO', 'AUDIO', 'PDF', 'PPT'].includes(fileType)) return true;
// OTHER Office Word/Excel
if (fileType === 'OTHER' && resource.filePath) {
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
return ['doc', 'docx', 'xls', 'xlsx'].includes(ext);
}
return false;
};
/** 是否为 Office 文档PDF/PPT/Word/Excel可使用 WebOffice 在线预览 */
const isOfficeDoc = (resource: ResourceItem | null) => {
if (!resource) return false;
if (['PDF', 'PPT'].includes(resource.fileType)) return true;
if (resource.fileType === 'OTHER' && resource.filePath) {
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
return ['doc', 'docx', 'xls', 'xlsx'].includes(ext);
}
return false;
};
/** 解析文件名得到 name 和 type扩展名 */
const parseResourceFileName = (resource: ResourceItem) => {
const title = (resource as any).title ?? (resource as any).name ?? '';
const fileType = (resource as any).fileType ?? (resource as any).type;
let type = 'pdf';
if (fileType === 'PDF') type = 'pdf';
else if (fileType === 'PPT') type = 'pptx';
else if (resource.filePath) {
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
if (['doc', 'docx', 'xls', 'xlsx'].includes(ext)) type = ext;
}
return { name: title, type };
};
/** 使用 WebOffice 在新窗口打开在线文档 */
const openInWebOffice = (resource: ResourceItem | null) => {
if (!resource) return;
const fullUrl = getFileUrl(resource.filePath);
if (!fullUrl.startsWith('http')) {
message.warning('WebOffice 预览需要完整的文件 URL请确保文件已上传至 OSS');
return;
}
const { name, type } = parseResourceFileName(resource);
openWebOffice({
id: String(resource.id),
url: fullUrl,
name,
type,
});
};
const fetchLibraries = async () => {
@ -480,8 +489,8 @@ const fetchItems = async () => {
pageSize: pagination.pageSize,
...filters,
});
items.value = result.list;
pagination.total = result.total;
items.value = result?.list ?? [];
pagination.total = result?.total ?? 0;
} catch (error) {
message.error('获取资源列表失败');
} finally {
@ -491,7 +500,15 @@ const fetchItems = async () => {
const fetchStats = async () => {
try {
stats.value = await getResourceStats();
const data = await getResourceStats();
// totalLibraries/totalItems libraryCount/itemCount
const d = data as unknown as Record<string, unknown>;
stats.value = {
totalLibraries: (d?.totalLibraries ?? d?.libraryCount ?? 0) as number,
totalItems: (d?.totalItems ?? d?.itemCount ?? 0) as number,
itemsByType: (d?.itemsByType ?? {}) as Record<string, number>,
itemsByLibraryType: (d?.itemsByLibraryType ?? {}) as Record<string, number>,
};
} catch (error) {
console.error('Failed to fetch stats:', error);
}
@ -537,6 +554,31 @@ const showUploadModal = () => {
uploadModalVisible.value = true;
};
/** OSS 直传:自定义上传请求 */
const handleCustomUpload = async (options: any) => {
const { file, onSuccess, onError, onProgress } = options;
const uploadFile = file instanceof File ? file : (file?.originFileObj ?? file);
// 100MB
const validation = validateFileType(uploadFile, 'RESOURCE');
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?.({ filePath: result.filePath });
} catch (err: any) {
const msg = err.response?.data?.message || err.message || '上传失败';
message.error(msg);
onError?.(new Error(msg));
}
};
const handleUploadChange = (info: any) => {
if (info.file.status === 'done') {
message.success(`${info.file.name} 上传成功`);
@ -728,6 +770,10 @@ onMounted(() => {
.preview-placeholder {
text-align: center;
&.office-preview {
padding: 40px;
}
}
}
}

View File

@ -139,7 +139,11 @@ async function init(mount: HTMLElement | null) {
_temObj.value = temObj;
const url = decodeURIComponent(`oss://lesingle-kid-course${new URL(decodeURIComponent(temObj.url)).pathname}`);
// URL ImmUtil oss:// https://
const decodedUrl = decodeURIComponent(temObj.url);
const url = decodedUrl.startsWith('http://') || decodedUrl.startsWith('https://')
? decodedUrl
: decodeURIComponent(`oss://lesingle-kid-course${new URL(decodedUrl).pathname}`);
let tokenInfo = await getTokenFun(url, temObj);
const instance = (window as any).aliyun.config({
mount,

View File

@ -1,5 +1,24 @@
import { setTemItem } from "./temObjs";
import { router } from "@/router";
/** WebOffice 支持的文档扩展名(阿里云 IMM */
export const WEB_OFFICE_EXTENSIONS = ['pdf', 'ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx'] as const;
/**
* WebOffice WebOffice
* @param resource fileType/typefilePath
*/
export function isWebOfficeSupported(resource: { fileType?: string; type?: string; filePath?: string }): boolean {
const fileType = resource.fileType ?? resource.type;
if (fileType === 'PDF') return true;
if (fileType === 'PPT') return true;
if (fileType === 'OTHER' && resource.filePath) {
const ext = resource.filePath.toLowerCase().split('.').pop()?.split('?')[0] || '';
return (WEB_OFFICE_EXTENSIONS as readonly string[]).includes(ext);
}
return false;
}
/**
* @param res obj
* @param isEdit

View File

@ -3,6 +3,7 @@ package com.reading.platform.common.mapper;
import com.reading.platform.dto.response.ResourceItemResponse;
import com.reading.platform.entity.ResourceItem;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers;
import java.util.List;
@ -17,7 +18,10 @@ public interface ResourceItemMapper {
/**
* Entity Response
* tagslibrary Controller 单独处理JSON 解析关联查询
*/
@Mapping(target = "tags", ignore = true)
@Mapping(target = "library", ignore = true)
ResourceItemResponse toVO(ResourceItem entity);
/**
@ -28,5 +32,6 @@ public interface ResourceItemMapper {
/**
* Response Entity用于创建/更新时
*/
@Mapping(target = "tags", ignore = true)
ResourceItem toEntity(ResourceItemResponse vo);
}

View File

@ -16,6 +16,8 @@ import com.reading.platform.entity.ResourceLibrary;
import com.reading.platform.service.ResourceLibraryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@ -34,6 +36,7 @@ import java.util.stream.Collectors;
public class AdminResourceController {
private final ResourceLibraryService resourceLibraryService;
private final ObjectMapper objectMapper;
// ==================== 资源库管理 ====================
@ -124,7 +127,11 @@ public class AdminResourceController {
@PostMapping("/items")
@Operation(summary = "创建资源项目")
@RequireRole(UserRole.ADMIN)
public Result<ResourceItemResponse> createItem(@Valid @RequestBody ResourceItemCreateRequest request) {
public Result<ResourceItemResponse> createItem(@Valid @RequestBody ResourceItemCreateRequest request) throws JsonProcessingException {
String tagsStr = null;
if (request.getTags() != null && !request.getTags().isEmpty()) {
tagsStr = objectMapper.writeValueAsString(request.getTags());
}
ResourceItem item = resourceLibraryService.createItem(
request.getLibraryId(),
request.getTitle(),
@ -132,7 +139,7 @@ public class AdminResourceController {
request.getFilePath(),
request.getFileSize(),
request.getDescription(),
request.getTags(),
tagsStr,
request.getTenantId()
);
return Result.success(toItemResponse(item));
@ -143,12 +150,16 @@ public class AdminResourceController {
@RequireRole(UserRole.ADMIN)
public Result<ResourceItemResponse> updateItem(
@PathVariable String id,
@Valid @RequestBody ResourceItemUpdateRequest request) {
@Valid @RequestBody ResourceItemUpdateRequest request) throws JsonProcessingException {
String tagsStr = null;
if (request.getTags() != null && !request.getTags().isEmpty()) {
tagsStr = objectMapper.writeValueAsString(request.getTags());
}
ResourceItem item = resourceLibraryService.updateItem(
id,
request.getTitle(),
request.getDescription(),
request.getTags()
tagsStr
);
return Result.success(toItemResponse(item));
}
@ -197,16 +208,43 @@ public class AdminResourceController {
* ResourceItem 实体转换为 ResourceItemResponse
*/
private ResourceItemResponse toItemResponse(ResourceItem item) {
ResourceItemResponse.LibrarySummary librarySummary = null;
if (item.getLibraryId() != null && !item.getLibraryId().isEmpty()) {
var lib = resourceLibraryService.findLibraryByIdOrNull(item.getLibraryId());
if (lib != null) {
librarySummary = ResourceItemResponse.LibrarySummary.builder()
.id(lib.getId())
.name(lib.getName())
.libraryType(lib.getLibraryType())
.build();
}
}
List<String> tagsList = parseTagsJson(item.getTags());
return ResourceItemResponse.builder()
.id(item.getId())
.libraryId(item.getLibraryId())
.tenantId(item.getTenantId())
.type(item.getFileType())
.name(item.getTitle())
.title(item.getTitle())
.fileType(item.getFileType())
.filePath(item.getFilePath())
.fileSize(item.getFileSize())
.tags(tagsList)
.library(librarySummary)
.description(item.getDescription())
.status(item.getStatus())
.createdAt(item.getCreatedAt())
.updatedAt(item.getUpdatedAt())
.build();
}
private List<String> parseTagsJson(String tagsJson) {
if (tagsJson == null || tagsJson.isEmpty()) return List.of();
try {
return objectMapper.readValue(tagsJson, objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
} catch (Exception e) {
return List.of();
}
}
}

View File

@ -3,6 +3,8 @@ package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 资源项目创建请求
*/
@ -28,8 +30,8 @@ public class ResourceItemCreateRequest {
@Schema(description = "资源描述")
private String description;
@Schema(description = "标签")
private String tags;
@Schema(description = "标签(字符串数组)")
private List<String> tags;
@Schema(description = "租户 ID")
private String tenantId;

View File

@ -3,6 +3,8 @@ package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 资源项目更新请求
*/
@ -16,6 +18,6 @@ public class ResourceItemUpdateRequest {
@Schema(description = "资源描述")
private String description;
@Schema(description = "标签")
private String tags;
@Schema(description = "标签(字符串数组)")
private List<String> tags;
}

View File

@ -1,5 +1,6 @@
package com.reading.platform.dto.request;
import com.fasterxml.jackson.annotation.JsonAlias;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@ -14,6 +15,7 @@ public class ResourceLibraryCreateRequest {
private String name;
@Schema(description = "资源库类型")
@JsonAlias("libraryType")
private String type;
@Schema(description = "资源库描述")

View File

@ -5,6 +5,7 @@ import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 资源项响应
@ -24,39 +25,42 @@ public class ResourceItemResponse {
@Schema(description = "租户 ID")
private String tenantId;
@Schema(description = "资源类型")
private String type;
@Schema(description = "资源标题")
private String title;
@Schema(description = "资源名称")
private String name;
@Schema(description = "文件类型 (IMAGE/PDF/VIDEO/AUDIO/PPT/OTHER)")
private String fileType;
@Schema(description = "资源编码")
private String code;
@Schema(description = "文件路径")
private String filePath;
@Schema(description = "文件大小(字节)")
private Long fileSize;
@Schema(description = "资源标签")
private List<String> tags;
@Schema(description = "所属资源库信息")
private LibrarySummary library;
@Schema(description = "资源描述")
private String description;
@Schema(description = "数量")
private Integer quantity;
@Schema(description = "可用数量")
private Integer availableQuantity;
@Schema(description = "存放位置")
private String location;
@Schema(description = "状态")
private String status;
@Schema(description = "创建人")
private String createdBy;
@Schema(description = "更新人")
private String updatedBy;
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
/** 资源库简要信息 */
@Data
@Builder
public static class LibrarySummary {
private Long id;
private String name;
private String libraryType;
}
}

View File

@ -1,5 +1,6 @@
package com.reading.platform.dto.response;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
@ -28,6 +29,7 @@ public class ResourceLibraryResponse {
private String description;
@Schema(description = "资源库类型")
@JsonProperty("libraryType")
private String type;
@Schema(description = "创建人")

View File

@ -63,6 +63,14 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
return library;
}
/**
* 查询资源库不存在时返回 null
*/
public ResourceLibrary findLibraryByIdOrNull(String id) {
if (id == null || id.isEmpty()) return null;
return libraryMapper.selectById(id);
}
/**
* 创建资源库
*/
@ -167,8 +175,10 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
Long fileSize, String description, String tags, String tenantId) {
log.info("创建资源项目libraryId={}, title={}, fileType={}, fileSize={}, tenantId={}", libraryId, title, fileType, fileSize, tenantId);
ResourceItem item = new ResourceItem();
item.setLibraryId(libraryId);
item.setId(null); // 确保由数据库 AUTO_INCREMENT 生成避免主键冲突
item.setLibraryId(libraryId); // 目标资源库 ID
item.setTitle(title);
item.setName(title); // 数据库 name 字段 NOT NULL title 保持一致
item.setDescription(description);
item.setFileType(fileType);
item.setFilePath(filePath);
@ -237,10 +247,24 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
Long libraryCount = libraryMapper.selectCount(null);
Long itemCount = itemMapper.selectCount(null);
stats.put("libraryCount", libraryCount);
stats.put("itemCount", itemCount);
stats.put("totalLibraries", libraryCount);
stats.put("totalItems", itemCount);
log.info("资源库统计数据libraryCount={}, itemCount={}", libraryCount, itemCount);
// 按资源库类型统计资源数量绘本资源教学材料等
Map<String, Long> itemsByLibraryType = new HashMap<>();
LambdaQueryWrapper<ResourceLibrary> libWrapper = new LambdaQueryWrapper<>();
for (ResourceLibrary lib : libraryMapper.selectList(libWrapper)) {
String type = lib.getLibraryType();
if (type != null) {
LambdaQueryWrapper<ResourceItem> itemWrapper = new LambdaQueryWrapper<>();
itemWrapper.eq(ResourceItem::getLibraryId, String.valueOf(lib.getId()));
long count = itemMapper.selectCount(itemWrapper);
itemsByLibraryType.merge(type, count, Long::sum);
}
}
stats.put("itemsByLibraryType", itemsByLibraryType);
log.info("资源库统计数据totalLibraries={}, totalItems={}, itemsByLibraryType={}", libraryCount, itemCount, itemsByLibraryType);
return stats;
}

View File

@ -0,0 +1,8 @@
-- =====================================================
-- 修复 resource_item 表 AUTO_INCREMENT
-- 版本V16
-- 描述:确保新插入的资源项使用正确的自增 ID避免主键冲突
-- =====================================================
-- 将 AUTO_INCREMENT 设置为足够大的值(测试数据最大 id 为 12设为 100 确保安全)
ALTER TABLE `resource_item` AUTO_INCREMENT = 100;