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"
|
||||
| "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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
// ==================== 统计数据 ====================
|
||||
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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/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 isEdit 是否编辑
|
||||
|
||||
@ -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
|
||||
* tags、library 由 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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 = "资源库描述")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = "创建人")
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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