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:
parent
673214481d
commit
193bbe90ae
@ -174,6 +174,7 @@ export const fileApi = {
|
|||||||
| "ppt"
|
| "ppt"
|
||||||
| "poster"
|
| "poster"
|
||||||
| "document"
|
| "document"
|
||||||
|
| "resource"
|
||||||
| "other",
|
| "other",
|
||||||
options?: {
|
options?: {
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
@ -235,6 +236,7 @@ export const FILE_TYPES = {
|
|||||||
PPT: "ppt",
|
PPT: "ppt",
|
||||||
POSTER: "poster",
|
POSTER: "poster",
|
||||||
DOCUMENT: "document",
|
DOCUMENT: "document",
|
||||||
|
RESOURCE: "resource",
|
||||||
OTHER: "other",
|
OTHER: "other",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@ -249,6 +251,7 @@ export const FILE_SIZE_LIMITS = {
|
|||||||
PPT: 300 * 1024 * 1024, // 300MB
|
PPT: 300 * 1024 * 1024, // 300MB
|
||||||
POSTER: 10 * 1024 * 1024, // 10MB
|
POSTER: 10 * 1024 * 1024, // 10MB
|
||||||
DOCUMENT: 300 * 1024 * 1024, // 300MB
|
DOCUMENT: 300 * 1024 * 1024, // 300MB
|
||||||
|
RESOURCE: 100 * 1024 * 1024, // 100MB(资源库单文件限制)
|
||||||
OTHER: 300 * 1024 * 1024, // 300MB
|
OTHER: 300 * 1024 * 1024, // 300MB
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@ -128,7 +128,7 @@ export const deleteResourceItem = (id: number) =>
|
|||||||
http.delete(`/v1/admin/resources/items/${id}`);
|
http.delete(`/v1/admin/resources/items/${id}`);
|
||||||
|
|
||||||
export const batchDeleteResourceItems = (ids: number[]) =>
|
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);
|
||||||
|
|
||||||
// ==================== 统计数据 ====================
|
// ==================== 统计数据 ====================
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,10 @@ declare module 'vue' {
|
|||||||
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
AAvatar: typeof import('ant-design-vue/es')['Avatar']
|
||||||
ABadge: typeof import('ant-design-vue/es')['Badge']
|
ABadge: typeof import('ant-design-vue/es')['Badge']
|
||||||
AButton: typeof import('ant-design-vue/es')['Button']
|
AButton: typeof import('ant-design-vue/es')['Button']
|
||||||
|
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
|
||||||
ACard: typeof import('ant-design-vue/es')['Card']
|
ACard: typeof import('ant-design-vue/es')['Card']
|
||||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||||
|
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
|
||||||
ACol: typeof import('ant-design-vue/es')['Col']
|
ACol: typeof import('ant-design-vue/es')['Col']
|
||||||
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
||||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||||
@ -23,6 +25,8 @@ declare module 'vue' {
|
|||||||
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
AEmpty: typeof import('ant-design-vue/es')['Empty']
|
||||||
AForm: typeof import('ant-design-vue/es')['Form']
|
AForm: typeof import('ant-design-vue/es')['Form']
|
||||||
AFormItem: typeof import('ant-design-vue/es')['FormItem']
|
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']
|
AInput: typeof import('ant-design-vue/es')['Input']
|
||||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||||
@ -48,11 +52,14 @@ declare module 'vue' {
|
|||||||
ARate: typeof import('ant-design-vue/es')['Rate']
|
ARate: typeof import('ant-design-vue/es')['Rate']
|
||||||
ARow: typeof import('ant-design-vue/es')['Row']
|
ARow: typeof import('ant-design-vue/es')['Row']
|
||||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||||
|
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
|
||||||
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
|
||||||
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
|
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
|
||||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||||
ASpin: typeof import('ant-design-vue/es')['Spin']
|
ASpin: typeof import('ant-design-vue/es')['Spin']
|
||||||
AStatistic: typeof import('ant-design-vue/es')['Statistic']
|
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']
|
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
|
||||||
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
ASwitch: typeof import('ant-design-vue/es')['Switch']
|
||||||
ATable: typeof import('ant-design-vue/es')['Table']
|
ATable: typeof import('ant-design-vue/es')['Table']
|
||||||
|
|||||||
@ -1,37 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="resource-list-view">
|
<div class="resource-list-view">
|
||||||
<a-page-header
|
<a-page-header title="资源库管理" sub-title="管理平台数字资源" />
|
||||||
title="资源库管理"
|
|
||||||
sub-title="管理平台数字资源"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<a-row :gutter="16" style="margin-bottom: 16px;">
|
<a-row :gutter="16" style="margin-bottom: 16px;">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-statistic title="资源库总数" :value="stats.totalLibraries">
|
<a-statistic title="资源库总数" :value="stats.totalLibraries">
|
||||||
<template #prefix><FolderOutlined /></template>
|
<template #prefix>
|
||||||
|
<FolderOutlined />
|
||||||
|
</template>
|
||||||
</a-statistic>
|
</a-statistic>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-statistic title="资源总数" :value="stats.totalItems">
|
<a-statistic title="资源总数" :value="stats.totalItems">
|
||||||
<template #prefix><FileOutlined /></template>
|
<template #prefix>
|
||||||
|
<FileOutlined />
|
||||||
|
</template>
|
||||||
</a-statistic>
|
</a-statistic>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-statistic title="绘本资源" :value="stats.itemsByLibraryType?.PICTURE_BOOK || 0">
|
<a-statistic title="绘本资源" :value="stats.itemsByLibraryType?.PICTURE_BOOK || 0">
|
||||||
<template #prefix><BookOutlined /></template>
|
<template #prefix>
|
||||||
|
<BookOutlined />
|
||||||
|
</template>
|
||||||
</a-statistic>
|
</a-statistic>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-card>
|
<a-card>
|
||||||
<a-statistic title="教学材料" :value="stats.itemsByLibraryType?.MATERIAL || 0">
|
<a-statistic title="教学材料" :value="stats.itemsByLibraryType?.MATERIAL || 0">
|
||||||
<template #prefix><AppstoreOutlined /></template>
|
<template #prefix>
|
||||||
|
<AppstoreOutlined />
|
||||||
|
</template>
|
||||||
</a-statistic>
|
</a-statistic>
|
||||||
</a-card>
|
</a-card>
|
||||||
</a-col>
|
</a-col>
|
||||||
@ -41,11 +46,15 @@
|
|||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button @click="showLibraryModal">
|
<a-button @click="showLibraryModal">
|
||||||
<template #icon><FolderAddOutlined /></template>
|
<template #icon>
|
||||||
|
<FolderAddOutlined />
|
||||||
|
</template>
|
||||||
新建资源库
|
新建资源库
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="primary" @click="showUploadModal">
|
<a-button type="primary" @click="showUploadModal">
|
||||||
<template #icon><UploadOutlined /></template>
|
<template #icon>
|
||||||
|
<UploadOutlined />
|
||||||
|
</template>
|
||||||
上传资源
|
上传资源
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
@ -55,36 +64,23 @@
|
|||||||
<div class="filter-bar" style="margin-bottom: 16px;">
|
<div class="filter-bar" style="margin-bottom: 16px;">
|
||||||
<a-row :gutter="16">
|
<a-row :gutter="16">
|
||||||
<a-col :span="6">
|
<a-col :span="6">
|
||||||
<a-input-search
|
<a-input-search v-model:value="filters.keyword" placeholder="搜索资源名称" allow-clear @search="fetchItems">
|
||||||
v-model:value="filters.keyword"
|
<template #prefix>
|
||||||
placeholder="搜索资源名称"
|
<SearchOutlined />
|
||||||
allow-clear
|
</template>
|
||||||
@search="fetchItems"
|
|
||||||
>
|
|
||||||
<template #prefix><SearchOutlined /></template>
|
|
||||||
</a-input-search>
|
</a-input-search>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select
|
<a-select v-model:value="filters.libraryId" placeholder="选择资源库" allow-clear style="width: 100%"
|
||||||
v-model:value="filters.libraryId"
|
@change="fetchItems">
|
||||||
placeholder="选择资源库"
|
|
||||||
allow-clear
|
|
||||||
style="width: 100%"
|
|
||||||
@change="fetchItems"
|
|
||||||
>
|
|
||||||
<a-select-option v-for="lib in libraries" :key="lib.id" :value="lib.id">
|
<a-select-option v-for="lib in libraries" :key="lib.id" :value="lib.id">
|
||||||
{{ lib.name }}
|
{{ lib.name }}
|
||||||
</a-select-option>
|
</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :span="4">
|
<a-col :span="4">
|
||||||
<a-select
|
<a-select v-model:value="filters.fileType" placeholder="资源类型" allow-clear style="width: 100%"
|
||||||
v-model:value="filters.fileType"
|
@change="fetchItems">
|
||||||
placeholder="资源类型"
|
|
||||||
allow-clear
|
|
||||||
style="width: 100%"
|
|
||||||
@change="fetchItems"
|
|
||||||
>
|
|
||||||
<a-select-option value="IMAGE">图片</a-select-option>
|
<a-select-option value="IMAGE">图片</a-select-option>
|
||||||
<a-select-option value="PDF">PDF</a-select-option>
|
<a-select-option value="PDF">PDF</a-select-option>
|
||||||
<a-select-option value="VIDEO">视频</a-select-option>
|
<a-select-option value="VIDEO">视频</a-select-option>
|
||||||
@ -96,30 +92,23 @@
|
|||||||
</a-row>
|
</a-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-table
|
<a-table :columns="columns" :data-source="items" :loading="loading" :pagination="pagination"
|
||||||
:columns="columns"
|
:row-selection="rowSelection" @change="handleTableChange" row-key="id">
|
||||||
:data-source="items"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-selection="rowSelection"
|
|
||||||
@change="handleTableChange"
|
|
||||||
row-key="id"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'resource'">
|
<template v-if="column.key === 'resource'">
|
||||||
<div class="resource-item">
|
<div class="resource-item">
|
||||||
<div class="resource-icon">
|
<div class="resource-icon">
|
||||||
<FilePdfOutlined v-if="record.fileType === 'PDF'" />
|
<FilePdfOutlined v-if="getRecordFileType(record) === 'PDF'" />
|
||||||
<VideoCameraOutlined v-else-if="record.fileType === 'VIDEO'" />
|
<VideoCameraOutlined v-else-if="getRecordFileType(record) === 'VIDEO'" />
|
||||||
<AudioOutlined v-else-if="record.fileType === 'AUDIO'" />
|
<AudioOutlined v-else-if="getRecordFileType(record) === 'AUDIO'" />
|
||||||
<PictureOutlined v-else-if="record.fileType === 'IMAGE'" />
|
<PictureOutlined v-else-if="getRecordFileType(record) === 'IMAGE'" />
|
||||||
<FilePptOutlined v-else-if="record.fileType === 'PPT'" />
|
<FilePptOutlined v-else-if="getRecordFileType(record) === 'PPT'" />
|
||||||
<FileOutlined v-else />
|
<FileOutlined v-else />
|
||||||
</div>
|
</div>
|
||||||
<div class="resource-info">
|
<div class="resource-info">
|
||||||
<div class="resource-name">{{ record.title }}</div>
|
<div class="resource-name">{{ record.title ?? record.name }}</div>
|
||||||
<div class="resource-meta">
|
<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;">
|
<span style="color: #999; font-size: 12px;">
|
||||||
{{ formatFileSize(record.fileSize) }}
|
{{ formatFileSize(record.fileSize) }}
|
||||||
</span>
|
</span>
|
||||||
@ -146,12 +135,8 @@
|
|||||||
<a-button type="link" size="small" @click="openEditModal(record as ResourceItem)">
|
<a-button type="link" size="small" @click="openEditModal(record as ResourceItem)">
|
||||||
编辑
|
编辑
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button type="link" size="small" :disabled="!canPreview(record as ResourceItem)"
|
||||||
type="link"
|
@click="previewResource(record as ResourceItem)">
|
||||||
size="small"
|
|
||||||
:disabled="!canPreview(record as ResourceItem)"
|
|
||||||
@click="previewResource(record as ResourceItem)"
|
|
||||||
>
|
|
||||||
预览
|
预览
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button type="link" size="small" @click="downloadResource(record as ResourceItem)">
|
<a-button type="link" size="small" @click="downloadResource(record as ResourceItem)">
|
||||||
@ -180,12 +165,7 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<!-- 新建资源库弹窗 -->
|
<!-- 新建资源库弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="libraryModalVisible" title="新建资源库" @ok="handleCreateLibrary" :confirm-loading="submitting">
|
||||||
v-model:open="libraryModalVisible"
|
|
||||||
title="新建资源库"
|
|
||||||
@ok="handleCreateLibrary"
|
|
||||||
:confirm-loading="submitting"
|
|
||||||
>
|
|
||||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||||
<a-form-item label="资源库名称" required>
|
<a-form-item label="资源库名称" required>
|
||||||
<a-input v-model:value="libraryForm.name" placeholder="请输入资源库名称" />
|
<a-input v-model:value="libraryForm.name" placeholder="请输入资源库名称" />
|
||||||
@ -204,13 +184,8 @@
|
|||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 上传资源弹窗 -->
|
<!-- 上传资源弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="uploadModalVisible" title="上传资源" width="600px" @ok="handleUpload"
|
||||||
v-model:open="uploadModalVisible"
|
:confirm-loading="uploading">
|
||||||
title="上传资源"
|
|
||||||
width="600px"
|
|
||||||
@ok="handleUpload"
|
|
||||||
:confirm-loading="uploading"
|
|
||||||
>
|
|
||||||
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
||||||
<a-form-item label="目标资源库" required>
|
<a-form-item label="目标资源库" required>
|
||||||
<a-select v-model:value="uploadForm.libraryId" placeholder="请选择资源库">
|
<a-select v-model:value="uploadForm.libraryId" placeholder="请选择资源库">
|
||||||
@ -232,15 +207,8 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="选择文件" required>
|
<a-form-item label="选择文件" required>
|
||||||
<a-upload
|
<a-upload v-model:file-list="uploadForm.files" :custom-request="handleCustomUpload" :max-count="10"
|
||||||
v-model:file-list="uploadForm.files"
|
list-type="text" @change="handleUploadChange">
|
||||||
:action="uploadUrl"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
:data="{ type: 'resources' }"
|
|
||||||
:max-count="10"
|
|
||||||
list-type="text"
|
|
||||||
@change="handleUploadChange"
|
|
||||||
>
|
|
||||||
<a-button>
|
<a-button>
|
||||||
<UploadOutlined /> 选择文件
|
<UploadOutlined /> 选择文件
|
||||||
</a-button>
|
</a-button>
|
||||||
@ -251,12 +219,7 @@
|
|||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item label="资源标签">
|
<a-form-item label="资源标签">
|
||||||
<a-select
|
<a-select v-model:value="uploadForm.tags" mode="tags" placeholder="输入标签,按回车添加" style="width: 100%">
|
||||||
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>
|
<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>
|
||||||
|
|
||||||
<!-- 编辑资源弹窗 -->
|
<!-- 编辑资源弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="editModalVisible" title="编辑资源" @ok="handleEditConfirm" :confirm-loading="submitting">
|
||||||
v-model:open="editModalVisible"
|
|
||||||
title="编辑资源"
|
|
||||||
@ok="handleEditConfirm"
|
|
||||||
:confirm-loading="submitting"
|
|
||||||
>
|
|
||||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||||
<a-form-item label="资源名称">
|
<a-form-item label="资源名称">
|
||||||
<a-input v-model:value="editForm.title" placeholder="资源名称" />
|
<a-input v-model:value="editForm.title" placeholder="资源名称" />
|
||||||
@ -282,33 +240,36 @@
|
|||||||
<a-textarea v-model:value="editForm.description" placeholder="描述" :rows="3" />
|
<a-textarea v-model:value="editForm.description" placeholder="描述" :rows="3" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="标签">
|
<a-form-item label="标签">
|
||||||
<a-select
|
<a-select v-model:value="editForm.tags" mode="tags" placeholder="输入标签" style="width: 100%" />
|
||||||
v-model:value="editForm.tags"
|
|
||||||
mode="tags"
|
|
||||||
placeholder="输入标签"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
<!-- 预览弹窗 -->
|
<!-- 预览弹窗 -->
|
||||||
<a-modal
|
<a-modal v-model:open="previewModalVisible" :title="currentPreviewResource?.title" width="800px" :footer="null">
|
||||||
v-model:open="previewModalVisible"
|
|
||||||
:title="currentPreviewResource?.title"
|
|
||||||
width="800px"
|
|
||||||
:footer="null"
|
|
||||||
>
|
|
||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
<div v-if="currentPreviewResource?.fileType === 'IMAGE'">
|
<div v-if="currentPreviewResource?.fileType === 'IMAGE'">
|
||||||
<img :src="getFileUrl(currentPreviewResource?.filePath)" style="max-width: 100%;" />
|
<img :src="getFileUrl(currentPreviewResource?.filePath)" style="max-width: 100%;" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="currentPreviewResource?.fileType === 'VIDEO'">
|
<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>
|
||||||
<div v-else-if="currentPreviewResource?.fileType === 'AUDIO'">
|
<div v-else-if="currentPreviewResource?.fileType === 'AUDIO'">
|
||||||
<audio :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%;"></audio>
|
<audio :src="getFileUrl(currentPreviewResource?.filePath)" controls style="width: 100%;"></audio>
|
||||||
</div>
|
</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">
|
<div v-else class="preview-placeholder">
|
||||||
<FileTextOutlined style="font-size: 64px; color: #ccc;" />
|
<FileTextOutlined style="font-size: 64px; color: #ccc;" />
|
||||||
<p style="color: #999;">此文件类型不支持在线预览</p>
|
<p style="color: #999;">此文件类型不支持在线预览</p>
|
||||||
@ -336,6 +297,7 @@ import {
|
|||||||
FolderOutlined,
|
FolderOutlined,
|
||||||
FolderAddOutlined,
|
FolderAddOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
EyeOutlined,
|
||||||
} from '@ant-design/icons-vue';
|
} from '@ant-design/icons-vue';
|
||||||
import {
|
import {
|
||||||
getLibraries,
|
getLibraries,
|
||||||
@ -348,9 +310,8 @@ import {
|
|||||||
getResourceStats,
|
getResourceStats,
|
||||||
} from '@/api/resource';
|
} from '@/api/resource';
|
||||||
import type { ResourceLibrary, ResourceItem, FileType as FileTypeType } from '@/api/resource';
|
import type { ResourceLibrary, ResourceItem, FileType as FileTypeType } from '@/api/resource';
|
||||||
import { useUserStore } from '@/stores/user';
|
import { fileApi, validateFileType } from '@/api/file';
|
||||||
|
import { openWebOffice } from '@/views/office/webOffice';
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@ -415,11 +376,6 @@ const editForm = reactive({
|
|||||||
tags: [] as string[],
|
tags: [] as string[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadUrl = '/api/v1/files/upload';
|
|
||||||
const uploadHeaders = computed(() => ({
|
|
||||||
Authorization: `Bearer ${userStore.token}`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '资源', key: 'resource', width: 300 },
|
{ title: '资源', key: 'resource', width: 300 },
|
||||||
{ title: '所属资源库', key: 'library', width: 150 },
|
{ title: '所属资源库', key: 'library', width: 150 },
|
||||||
@ -428,6 +384,9 @@ const columns = [
|
|||||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const },
|
{ 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 getFileTypeLabel = (type: FileTypeType) => {
|
||||||
const labels: Record<FileTypeType, string> = {
|
const labels: Record<FileTypeType, string> = {
|
||||||
IMAGE: '图片',
|
IMAGE: '图片',
|
||||||
@ -447,9 +406,10 @@ const formatFileSize = (bytes?: number) => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return '-';
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
return date.toLocaleDateString('zh-CN');
|
return isNaN(date.getTime()) ? '-' : date.toLocaleDateString('zh-CN');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFileUrl = (path?: string) => {
|
const getFileUrl = (path?: string) => {
|
||||||
@ -460,7 +420,56 @@ const getFileUrl = (path?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canPreview = (resource: ResourceItem) => {
|
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 () => {
|
const fetchLibraries = async () => {
|
||||||
@ -480,8 +489,8 @@ const fetchItems = async () => {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
...filters,
|
...filters,
|
||||||
});
|
});
|
||||||
items.value = result.list;
|
items.value = result?.list ?? [];
|
||||||
pagination.total = result.total;
|
pagination.total = result?.total ?? 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取资源列表失败');
|
message.error('获取资源列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
@ -491,7 +500,15 @@ const fetchItems = async () => {
|
|||||||
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch stats:', error);
|
console.error('Failed to fetch stats:', error);
|
||||||
}
|
}
|
||||||
@ -537,6 +554,31 @@ const showUploadModal = () => {
|
|||||||
uploadModalVisible.value = true;
|
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) => {
|
const handleUploadChange = (info: any) => {
|
||||||
if (info.file.status === 'done') {
|
if (info.file.status === 'done') {
|
||||||
message.success(`${info.file.name} 上传成功`);
|
message.success(`${info.file.name} 上传成功`);
|
||||||
@ -728,6 +770,10 @@ onMounted(() => {
|
|||||||
|
|
||||||
.preview-placeholder {
|
.preview-placeholder {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
|
&.office-preview {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,7 +139,11 @@ async function init(mount: HTMLElement | null) {
|
|||||||
|
|
||||||
_temObj.value = temObj;
|
_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);
|
let tokenInfo = await getTokenFun(url, temObj);
|
||||||
const instance = (window as any).aliyun.config({
|
const instance = (window as any).aliyun.config({
|
||||||
mount,
|
mount,
|
||||||
|
|||||||
@ -1,5 +1,24 @@
|
|||||||
import { setTemItem } from "./temObjs";
|
import { setTemItem } from "./temObjs";
|
||||||
import { router } from "@/router";
|
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/type、filePath
|
||||||
|
*/
|
||||||
|
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 res obj
|
||||||
* @param isEdit 是否编辑
|
* @param isEdit 是否编辑
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package com.reading.platform.common.mapper;
|
|||||||
import com.reading.platform.dto.response.ResourceItemResponse;
|
import com.reading.platform.dto.response.ResourceItemResponse;
|
||||||
import com.reading.platform.entity.ResourceItem;
|
import com.reading.platform.entity.ResourceItem;
|
||||||
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;
|
||||||
@ -17,7 +18,10 @@ public interface ResourceItemMapper {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity 转 Response
|
* Entity 转 Response
|
||||||
|
* tags、library 由 Controller 单独处理(JSON 解析、关联查询)
|
||||||
*/
|
*/
|
||||||
|
@Mapping(target = "tags", ignore = true)
|
||||||
|
@Mapping(target = "library", ignore = true)
|
||||||
ResourceItemResponse toVO(ResourceItem entity);
|
ResourceItemResponse toVO(ResourceItem entity);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -28,5 +32,6 @@ public interface ResourceItemMapper {
|
|||||||
/**
|
/**
|
||||||
* Response 转 Entity(用于创建/更新时)
|
* Response 转 Entity(用于创建/更新时)
|
||||||
*/
|
*/
|
||||||
|
@Mapping(target = "tags", ignore = true)
|
||||||
ResourceItem toEntity(ResourceItemResponse vo);
|
ResourceItem toEntity(ResourceItemResponse vo);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import com.reading.platform.entity.ResourceLibrary;
|
|||||||
import com.reading.platform.service.ResourceLibraryService;
|
import com.reading.platform.service.ResourceLibraryService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
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 jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -34,6 +36,7 @@ import java.util.stream.Collectors;
|
|||||||
public class AdminResourceController {
|
public class AdminResourceController {
|
||||||
|
|
||||||
private final ResourceLibraryService resourceLibraryService;
|
private final ResourceLibraryService resourceLibraryService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
// ==================== 资源库管理 ====================
|
// ==================== 资源库管理 ====================
|
||||||
|
|
||||||
@ -124,7 +127,11 @@ public class AdminResourceController {
|
|||||||
@PostMapping("/items")
|
@PostMapping("/items")
|
||||||
@Operation(summary = "创建资源项目")
|
@Operation(summary = "创建资源项目")
|
||||||
@RequireRole(UserRole.ADMIN)
|
@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(
|
ResourceItem item = resourceLibraryService.createItem(
|
||||||
request.getLibraryId(),
|
request.getLibraryId(),
|
||||||
request.getTitle(),
|
request.getTitle(),
|
||||||
@ -132,7 +139,7 @@ public class AdminResourceController {
|
|||||||
request.getFilePath(),
|
request.getFilePath(),
|
||||||
request.getFileSize(),
|
request.getFileSize(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getTags(),
|
tagsStr,
|
||||||
request.getTenantId()
|
request.getTenantId()
|
||||||
);
|
);
|
||||||
return Result.success(toItemResponse(item));
|
return Result.success(toItemResponse(item));
|
||||||
@ -143,12 +150,16 @@ public class AdminResourceController {
|
|||||||
@RequireRole(UserRole.ADMIN)
|
@RequireRole(UserRole.ADMIN)
|
||||||
public Result<ResourceItemResponse> updateItem(
|
public Result<ResourceItemResponse> updateItem(
|
||||||
@PathVariable String id,
|
@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(
|
ResourceItem item = resourceLibraryService.updateItem(
|
||||||
id,
|
id,
|
||||||
request.getTitle(),
|
request.getTitle(),
|
||||||
request.getDescription(),
|
request.getDescription(),
|
||||||
request.getTags()
|
tagsStr
|
||||||
);
|
);
|
||||||
return Result.success(toItemResponse(item));
|
return Result.success(toItemResponse(item));
|
||||||
}
|
}
|
||||||
@ -197,16 +208,43 @@ public class AdminResourceController {
|
|||||||
* 将 ResourceItem 实体转换为 ResourceItemResponse
|
* 将 ResourceItem 实体转换为 ResourceItemResponse
|
||||||
*/
|
*/
|
||||||
private ResourceItemResponse toItemResponse(ResourceItem item) {
|
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()
|
return ResourceItemResponse.builder()
|
||||||
.id(item.getId())
|
.id(item.getId())
|
||||||
.libraryId(item.getLibraryId())
|
.libraryId(item.getLibraryId())
|
||||||
.tenantId(item.getTenantId())
|
.tenantId(item.getTenantId())
|
||||||
.type(item.getFileType())
|
.title(item.getTitle())
|
||||||
.name(item.getTitle())
|
.fileType(item.getFileType())
|
||||||
|
.filePath(item.getFilePath())
|
||||||
|
.fileSize(item.getFileSize())
|
||||||
|
.tags(tagsList)
|
||||||
|
.library(librarySummary)
|
||||||
.description(item.getDescription())
|
.description(item.getDescription())
|
||||||
.status(item.getStatus())
|
.status(item.getStatus())
|
||||||
.createdAt(item.getCreatedAt())
|
.createdAt(item.getCreatedAt())
|
||||||
.updatedAt(item.getUpdatedAt())
|
.updatedAt(item.getUpdatedAt())
|
||||||
.build();
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,8 @@ package com.reading.platform.dto.request;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资源项目创建请求
|
* 资源项目创建请求
|
||||||
*/
|
*/
|
||||||
@ -28,8 +30,8 @@ public class ResourceItemCreateRequest {
|
|||||||
@Schema(description = "资源描述")
|
@Schema(description = "资源描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Schema(description = "标签")
|
@Schema(description = "标签(字符串数组)")
|
||||||
private String tags;
|
private List<String> tags;
|
||||||
|
|
||||||
@Schema(description = "租户 ID")
|
@Schema(description = "租户 ID")
|
||||||
private String tenantId;
|
private String tenantId;
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package com.reading.platform.dto.request;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资源项目更新请求
|
* 资源项目更新请求
|
||||||
*/
|
*/
|
||||||
@ -16,6 +18,6 @@ public class ResourceItemUpdateRequest {
|
|||||||
@Schema(description = "资源描述")
|
@Schema(description = "资源描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Schema(description = "标签")
|
@Schema(description = "标签(字符串数组)")
|
||||||
private String tags;
|
private List<String> tags;
|
||||||
}
|
}
|
||||||
@ -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 lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@ -14,6 +15,7 @@ public class ResourceLibraryCreateRequest {
|
|||||||
private String name;
|
private String name;
|
||||||
|
|
||||||
@Schema(description = "资源库类型")
|
@Schema(description = "资源库类型")
|
||||||
|
@JsonAlias("libraryType")
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@Schema(description = "资源库描述")
|
@Schema(description = "资源库描述")
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import lombok.Builder;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 资源项响应
|
* 资源项响应
|
||||||
@ -24,39 +25,42 @@ public class ResourceItemResponse {
|
|||||||
@Schema(description = "租户 ID")
|
@Schema(description = "租户 ID")
|
||||||
private String tenantId;
|
private String tenantId;
|
||||||
|
|
||||||
@Schema(description = "资源类型")
|
@Schema(description = "资源标题")
|
||||||
private String type;
|
private String title;
|
||||||
|
|
||||||
@Schema(description = "资源名称")
|
@Schema(description = "文件类型 (IMAGE/PDF/VIDEO/AUDIO/PPT/OTHER)")
|
||||||
private String name;
|
private String fileType;
|
||||||
|
|
||||||
@Schema(description = "资源编码")
|
@Schema(description = "文件路径")
|
||||||
private String code;
|
private String filePath;
|
||||||
|
|
||||||
|
@Schema(description = "文件大小(字节)")
|
||||||
|
private Long fileSize;
|
||||||
|
|
||||||
|
@Schema(description = "资源标签")
|
||||||
|
private List<String> tags;
|
||||||
|
|
||||||
|
@Schema(description = "所属资源库信息")
|
||||||
|
private LibrarySummary library;
|
||||||
|
|
||||||
@Schema(description = "资源描述")
|
@Schema(description = "资源描述")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Schema(description = "数量")
|
|
||||||
private Integer quantity;
|
|
||||||
|
|
||||||
@Schema(description = "可用数量")
|
|
||||||
private Integer availableQuantity;
|
|
||||||
|
|
||||||
@Schema(description = "存放位置")
|
|
||||||
private String location;
|
|
||||||
|
|
||||||
@Schema(description = "状态")
|
@Schema(description = "状态")
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
@Schema(description = "创建人")
|
|
||||||
private String createdBy;
|
|
||||||
|
|
||||||
@Schema(description = "更新人")
|
|
||||||
private String updatedBy;
|
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@Schema(description = "更新时间")
|
@Schema(description = "更新时间")
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
/** 资源库简要信息 */
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class LibrarySummary {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String libraryType;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.reading.platform.dto.response;
|
package com.reading.platform.dto.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
@ -28,6 +29,7 @@ public class ResourceLibraryResponse {
|
|||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@Schema(description = "资源库类型")
|
@Schema(description = "资源库类型")
|
||||||
|
@JsonProperty("libraryType")
|
||||||
private String type;
|
private String type;
|
||||||
|
|
||||||
@Schema(description = "创建人")
|
@Schema(description = "创建人")
|
||||||
|
|||||||
@ -63,6 +63,14 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
|
|||||||
return library;
|
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) {
|
Long fileSize, String description, String tags, String tenantId) {
|
||||||
log.info("创建资源项目,libraryId={}, title={}, fileType={}, fileSize={}, tenantId={}", libraryId, title, fileType, fileSize, tenantId);
|
log.info("创建资源项目,libraryId={}, title={}, fileType={}, fileSize={}, tenantId={}", libraryId, title, fileType, fileSize, tenantId);
|
||||||
ResourceItem item = new ResourceItem();
|
ResourceItem item = new ResourceItem();
|
||||||
item.setLibraryId(libraryId);
|
item.setId(null); // 确保由数据库 AUTO_INCREMENT 生成,避免主键冲突
|
||||||
|
item.setLibraryId(libraryId); // 目标资源库 ID
|
||||||
item.setTitle(title);
|
item.setTitle(title);
|
||||||
|
item.setName(title); // 数据库 name 字段 NOT NULL,与 title 保持一致
|
||||||
item.setDescription(description);
|
item.setDescription(description);
|
||||||
item.setFileType(fileType);
|
item.setFileType(fileType);
|
||||||
item.setFilePath(filePath);
|
item.setFilePath(filePath);
|
||||||
@ -237,10 +247,24 @@ public class ResourceLibraryService extends ServiceImpl<ResourceLibraryMapper, R
|
|||||||
Long libraryCount = libraryMapper.selectCount(null);
|
Long libraryCount = libraryMapper.selectCount(null);
|
||||||
Long itemCount = itemMapper.selectCount(null);
|
Long itemCount = itemMapper.selectCount(null);
|
||||||
|
|
||||||
stats.put("libraryCount", libraryCount);
|
stats.put("totalLibraries", libraryCount);
|
||||||
stats.put("itemCount", itemCount);
|
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;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- 修复 resource_item 表 AUTO_INCREMENT
|
||||||
|
-- 版本:V16
|
||||||
|
-- 描述:确保新插入的资源项使用正确的自增 ID,避免主键冲突
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 将 AUTO_INCREMENT 设置为足够大的值(测试数据最大 id 为 12,设为 100 确保安全)
|
||||||
|
ALTER TABLE `resource_item` AUTO_INCREMENT = 100;
|
||||||
Loading…
Reference in New Issue
Block a user