文件直传优化
This commit is contained in:
parent
e7348656ff
commit
a7e22ff35b
@ -1,7 +1,10 @@
|
||||
import axios from 'axios';
|
||||
import { buildOssDirPath } from '@/utils/env';
|
||||
import axios from "axios";
|
||||
import { buildOssDirPath } from "@/utils/env";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
/** 上传请求 AbortController 映射,用于取消上传 */
|
||||
const uploadControllers = new Map<string, AbortController>();
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
@ -17,6 +20,39 @@ export interface DeleteResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定文件的 AbortController(用于取消上传)
|
||||
*/
|
||||
export function getUploadController(file: File): AbortController {
|
||||
const key = `${file.name}_${file.size}_${file.lastModified}`;
|
||||
let controller = uploadControllers.get(key);
|
||||
if (!controller) {
|
||||
controller = new AbortController();
|
||||
uploadControllers.set(key, controller);
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消指定文件的上传
|
||||
*/
|
||||
export function abortUpload(file: File): void {
|
||||
const key = `${file.name}_${file.size}_${file.lastModified}`;
|
||||
const controller = uploadControllers.get(key);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
uploadControllers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有进行中的上传
|
||||
*/
|
||||
export function abortAllUploads(): void {
|
||||
uploadControllers.forEach((controller) => controller.abort());
|
||||
uploadControllers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS 直传 Token 响应
|
||||
*/
|
||||
@ -42,72 +78,117 @@ export const fileApi = {
|
||||
* @param dir 业务目录(如:avatar, course/cover),会自动添加环境前缀
|
||||
* @returns OSS 直传 Token
|
||||
*/
|
||||
getOssToken: async (
|
||||
fileName: string,
|
||||
dir?: string,
|
||||
): Promise<OssToken> => {
|
||||
getOssToken: async (fileName: string, dir?: string): Promise<OssToken> => {
|
||||
// 自动添加环境前缀
|
||||
const fullDir = buildOssDirPath(dir);
|
||||
|
||||
const response = await axios.get<{ data: OssToken }>(`${API_BASE}/api/v1/files/oss/token`, {
|
||||
params: { fileName, dir: fullDir },
|
||||
});
|
||||
const response = await axios.get<{ data: OssToken }>(
|
||||
`${API_BASE}/api/v1/files/oss/token`,
|
||||
{
|
||||
params: { fileName, dir: fullDir },
|
||||
},
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 直接上传文件到阿里云 OSS
|
||||
* 参考 uploadAliOSS 实现:支持取消、进度回调、超时
|
||||
*
|
||||
* @param file 文件
|
||||
* @param token OSS Token
|
||||
* @param options 进度回调函数(兼容旧 API)或配置对象 { onProgress, signal, timeout }
|
||||
*/
|
||||
uploadToOss: async (
|
||||
file: File,
|
||||
token: OssToken,
|
||||
onProgress?: (percent: number) => void,
|
||||
options?:
|
||||
| ((percent: number) => void)
|
||||
| {
|
||||
onProgress?: (percent: number) => void;
|
||||
signal?: AbortSignal;
|
||||
timeout?: number;
|
||||
},
|
||||
): Promise<{ url: string }> => {
|
||||
const opts =
|
||||
typeof options === "function"
|
||||
? { onProgress: options }
|
||||
: options ?? {};
|
||||
const formData = new FormData();
|
||||
|
||||
// 按照阿里云 OSS PostObject 要求构造表单
|
||||
formData.append('OSSAccessKeyId', token.accessid);
|
||||
formData.append('policy', token.policy);
|
||||
formData.append('signature', token.signature);
|
||||
formData.append('key', token.key);
|
||||
formData.append('x-oss-credential', token.accessid);
|
||||
formData.append('file', file);
|
||||
formData.append("success_action_status", "200"); // 成功时返回 200
|
||||
formData.append("OSSAccessKeyId", token.accessid);
|
||||
formData.append("policy", token.policy);
|
||||
formData.append("signature", token.signature);
|
||||
formData.append("key", token.key);
|
||||
formData.append("x-oss-credential", token.accessid);
|
||||
formData.append("file", file); // file 必须为最后一个表单域
|
||||
|
||||
await axios.post(token.host, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total && onProgress) {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percentCompleted);
|
||||
}
|
||||
},
|
||||
});
|
||||
const controller = getUploadController(file);
|
||||
const signal = opts.signal ?? controller.signal;
|
||||
|
||||
// 上传成功后返回文件访问 URL
|
||||
return {
|
||||
url: `${token.host}/${token.key}`,
|
||||
};
|
||||
try {
|
||||
await axios.post(token.host, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
timeout: opts.timeout ?? 1000 * 60 * 5, // 默认 5 分钟
|
||||
signal,
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (opts.onProgress) {
|
||||
const percent =
|
||||
progressEvent.progress != null
|
||||
? progressEvent.progress * 100
|
||||
: progressEvent.total
|
||||
? (progressEvent.loaded * 100) / progressEvent.total
|
||||
: 0;
|
||||
opts.onProgress(Math.round(percent));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${token.host}/${token.key}`,
|
||||
};
|
||||
} finally {
|
||||
abortUpload(file);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 上传文件(使用 OSS 直传方式)
|
||||
* 参考 uploadAliOSS:支持进度回调、取消上传
|
||||
*
|
||||
* @param file 要上传的文件
|
||||
* @param type 文件类型(用于指定 OSS 目录前缀)
|
||||
* @param _courseId 课程 ID(可选,用于关联业务 - 预留参数)
|
||||
* @param options 可选:onProgress 进度回调、signal 取消信号、courseId 预留
|
||||
* @returns 上传结果,包含 OSS 文件 URL
|
||||
*/
|
||||
uploadFile: async (
|
||||
file: File,
|
||||
type: 'cover' | 'ebook' | 'audio' | 'video' | 'ppt' | 'poster' | 'document' | 'other',
|
||||
_courseId?: number,
|
||||
type:
|
||||
| "cover"
|
||||
| "ebook"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "ppt"
|
||||
| "poster"
|
||||
| "document"
|
||||
| "other",
|
||||
options?: {
|
||||
onProgress?: (percent: number) => void;
|
||||
signal?: AbortSignal;
|
||||
courseId?: number;
|
||||
},
|
||||
): Promise<UploadResult> => {
|
||||
// 1. 获取 OSS 直传 Token(自动添加环境前缀)
|
||||
const token = await getOssToken(file.name, type);
|
||||
const token = await fileApi.getOssToken(file.name, type);
|
||||
|
||||
// 2. 上传到 OSS
|
||||
await uploadToOss(file, token);
|
||||
// 2. 上传到 OSS(支持进度、取消)
|
||||
await fileApi.uploadToOss(file, token, {
|
||||
onProgress: options?.onProgress,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
// 3. 返回兼容格式的结果
|
||||
return {
|
||||
@ -124,9 +205,12 @@ export const fileApi = {
|
||||
* 删除文件
|
||||
*/
|
||||
deleteFile: async (filePath: string): Promise<DeleteResult> => {
|
||||
const response = await axios.delete<DeleteResult>(`${API_BASE}/api/v1/files/delete`, {
|
||||
data: { filePath },
|
||||
});
|
||||
const response = await axios.delete<DeleteResult>(
|
||||
`${API_BASE}/api/v1/files/delete`,
|
||||
{
|
||||
data: { filePath },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@ -144,28 +228,28 @@ export const fileApi = {
|
||||
* 文件类型常量
|
||||
*/
|
||||
export const FILE_TYPES = {
|
||||
COVER: 'cover',
|
||||
EBOOK: 'ebook',
|
||||
AUDIO: 'audio',
|
||||
VIDEO: 'video',
|
||||
PPT: 'ppt',
|
||||
POSTER: 'poster',
|
||||
DOCUMENT: 'document',
|
||||
OTHER: 'other',
|
||||
COVER: "cover",
|
||||
EBOOK: "ebook",
|
||||
AUDIO: "audio",
|
||||
VIDEO: "video",
|
||||
PPT: "ppt",
|
||||
POSTER: "poster",
|
||||
DOCUMENT: "document",
|
||||
OTHER: "other",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 文件大小限制(字节)
|
||||
*/
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
COVER: 10 * 1024 * 1024, // 10MB
|
||||
EBOOK: 300 * 1024 * 1024, // 300MB
|
||||
AUDIO: 300 * 1024 * 1024, // 300MB
|
||||
VIDEO: 300 * 1024 * 1024, // 300MB
|
||||
PPT: 300 * 1024 * 1024, // 300MB
|
||||
POSTER: 10 * 1024 * 1024, // 10MB
|
||||
DOCUMENT: 300 * 1024 * 1024, // 300MB
|
||||
OTHER: 300 * 1024 * 1024, // 300MB
|
||||
COVER: 10 * 1024 * 1024, // 10MB
|
||||
EBOOK: 300 * 1024 * 1024, // 300MB
|
||||
AUDIO: 300 * 1024 * 1024, // 300MB
|
||||
VIDEO: 300 * 1024 * 1024, // 300MB
|
||||
PPT: 300 * 1024 * 1024, // 300MB
|
||||
POSTER: 10 * 1024 * 1024, // 10MB
|
||||
DOCUMENT: 300 * 1024 * 1024, // 300MB
|
||||
OTHER: 300 * 1024 * 1024, // 300MB
|
||||
} as const;
|
||||
|
||||
/**
|
||||
|
||||
@ -20,6 +20,7 @@ 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']
|
||||
AInput: typeof import('ant-design-vue/es')['Input']
|
||||
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
|
||||
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
|
||||
@ -35,6 +36,8 @@ declare module 'vue' {
|
||||
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
|
||||
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
ARadio: typeof import('ant-design-vue/es')['Radio']
|
||||
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
|
||||
ARow: typeof import('ant-design-vue/es')['Row']
|
||||
ASelect: typeof import('ant-design-vue/es')['Select']
|
||||
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
|
||||
|
||||
242
reading-platform-frontend/src/views/office/WebOffice.vue
Normal file
242
reading-platform-frontend/src/views/office/WebOffice.vue
Normal file
@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div v-if="!expire" ref="containerRef" class="w-full h-full"></div>
|
||||
<div v-else class="flex justify-center">
|
||||
<div class="my-60px">
|
||||
链接已失效!<span class=" cursor-pointer color-#0085FF ml-10px" @click="home">返回首页</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <Modal ref="modalRef" class="max-w-80%" width="1340px" v-model:open="open" :footer="null" title="在线资源">
|
||||
|
||||
<div class="flex min-h-600px bg-#f5f5f5 flex-col ">
|
||||
<div class="bg-white p-10px">
|
||||
<a-input-search allowClear class="w-full max-w-360px ml-5px" v-model:value="searchName" placeholder="请输入作者"
|
||||
enter-button="搜索" size="large" @search="getImgs" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-wrap pt-15px pl-15px">
|
||||
<div v-for="item in dataSource"
|
||||
class="mr-15px mb-15px w-200px max-w-200px max-h-260px bg-white p-6px flex flex-col rounded-10px">
|
||||
|
||||
<div class="">{{ item.name }}</div>
|
||||
<img class=" object-contain my-auto cursor-pointer" alt="资源过期了" :src="item.url + img_resize"
|
||||
@click="setImgPreview(item.url)" />
|
||||
<div class="flex justify-around mt-5px" style="">
|
||||
<a-button class="mx-auto my-5px" type="primary" @click="setImgPreview(item.url)"> 查看</a-button>
|
||||
<a-button class="mx-auto my-5px" type="primary" @click="addImg(item)" :loading="loading_add">
|
||||
使用</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mx-15px mb-15px bg-white py-10px" v-if="pagination">
|
||||
<div style="margin-left: auto;margin-right: 12px;">
|
||||
<Pagination v-model:current="pagination.current" @change="paginationChange" @showSizeChange="pageSizeChange"
|
||||
:showSizeChanger="true" :total="pagination.total" v-model:pageSize="pagination.pageSize" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal> -->
|
||||
</template>
|
||||
<!-- 阿里云IMM weboffice -->
|
||||
<script lang="ts" name="WebOffice" setup>
|
||||
import { onMounted, ref, nextTick, reactive, watch, onUnmounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
import request from '/@/apis/fetch';
|
||||
// import { Modal, Pagination } from 'ant-design-vue';
|
||||
import {
|
||||
generateWebofficeToken,
|
||||
generateWebofficeTokenReadOnly,
|
||||
refreshWebofficeToken,
|
||||
} from '@/views/cms/teaching-material/CmsTeachingMaterial.api';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
// import { usePermission } from '@/hooks/web/usePermission';
|
||||
// import { insertPPTImage, insertWordImage } from './webOffice';
|
||||
import { getTemItem, TemObj } from './temObjs';
|
||||
import { createImgPreview } from '/@/components/Preview/index';
|
||||
|
||||
const containerRef = ref(null);
|
||||
const route = useRoute();
|
||||
const open = ref(false);
|
||||
const expire = ref(false);
|
||||
const router = useRouter();
|
||||
let updateSizeInterval: any;
|
||||
// const { hasPermission } = usePermission();
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
init(containerRef.value);
|
||||
})
|
||||
updateSizeInterval = setInterval(() => {
|
||||
if (baseInstance.value) {
|
||||
updateSize();
|
||||
}
|
||||
}, 1000 * 60 * 5)
|
||||
});
|
||||
const _temObj = ref<TemObj>({
|
||||
id: '',
|
||||
type: '',
|
||||
isEdit: false,
|
||||
name: '',
|
||||
url: '',
|
||||
});
|
||||
const onUnmountedUpdateSize = ref(true);
|
||||
const updateSize = async () => {
|
||||
if (!onUnmountedUpdateSize.value) {
|
||||
return;
|
||||
}
|
||||
if (!expire.value && _temObj.value.isEdit) {
|
||||
var formData = new FormData();
|
||||
formData.append('id', _temObj.value.id);
|
||||
formData.append('type', _temObj.value.type);
|
||||
navigator.sendBeacon('/activity/cms/cmsFilePublic/updateSize', formData);
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
updateSize();
|
||||
onUnmountedUpdateSize.value = false;
|
||||
clearInterval(updateSizeInterval);
|
||||
clearTimeout(timer.value);
|
||||
})
|
||||
const timer = ref<any>(null);
|
||||
|
||||
const debouncedFn = (time = 1000) => {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
updateSize();
|
||||
}, time);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', updateSize);
|
||||
})
|
||||
window.addEventListener('beforeunload', updateSize);
|
||||
|
||||
function home() {
|
||||
router.replace("/datas");
|
||||
}
|
||||
const baseInstance = ref<any>(null);
|
||||
async function init(mount, timeout = 10 * 60 * 1000) {
|
||||
if (!mount) {
|
||||
console.error('确保挂载节点元素存在。 一般在 onMounted 钩子中调用。');
|
||||
}
|
||||
// IMM vue3接入文档 https://help.aliyun.com/zh/imm/user-guide/vue3-usage?spm=a2c4g.11186623.0.0.3a0244142zAkss
|
||||
// 获取 token
|
||||
// let tokenInfo = await props.getTokenFun(props.teachingMaterialsImmUrl);
|
||||
|
||||
const temObj = getTemItem(route.query._t as string);
|
||||
if (!temObj) {
|
||||
expire.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_temObj.value = temObj;
|
||||
|
||||
const url = decodeURIComponent(`oss://lesingle-activity${new URL(decodeURIComponent(temObj.url)).pathname}`);
|
||||
let tokenInfo = await getTokenFun(url, temObj);
|
||||
const instance = (window as any).aliyun.config({
|
||||
mount,
|
||||
url: tokenInfo.webofficeURL,
|
||||
refreshToken: () => {
|
||||
// timeout过期时刷新 token
|
||||
// return props.refreshTokenFun(tokenInfo).then((data) => {
|
||||
return refreshTokenFun(tokenInfo).then((data) => {
|
||||
Object.assign(tokenInfo, data);
|
||||
return {
|
||||
token: tokenInfo.accessToken,
|
||||
timeout,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
baseInstance.value = instance;
|
||||
instance.setToken({
|
||||
token: tokenInfo.accessToken,
|
||||
timeout,
|
||||
});
|
||||
await instance.ready();
|
||||
// const imgurl = 'http://image.activity.lesingle.com/activitymaterial/poster/%E5%8A%A8%E7%89%A9%E7%BB%98%E6%9C%AC_1736908271102.jpg';
|
||||
// const imgurl = 'https://img0.baidu.com/it/u=3217812679,2585737758&fm=253&fmt=auto&app=138&f=JPEG?w=889&h=500';
|
||||
// const app = instance.Application;
|
||||
// if (app && app.ActiveDocument) {
|
||||
// await insertWordImage(instance, imgurl);
|
||||
// } else if (app && app.ActivePresentation) {
|
||||
// await insertPPTImage(instance, imgurl);
|
||||
// }
|
||||
|
||||
// try {
|
||||
// if (temObj.isEdit) {
|
||||
// const app = instance.Application;
|
||||
// // 获取 InsertTab 标签页下的所有控件
|
||||
// const controls = await app.CommandBars('InsertTab').Controls;
|
||||
|
||||
// const newButton = await controls.Add(1);//:添加一个按钮控件,其中1表示按钮类型。
|
||||
// newButton.Caption = '在线图片';//:设置按钮的标题。
|
||||
// newButton.OnAction = () => open.value = true;//:设置按钮点击事件的回调函数。
|
||||
// newButton.Picture = imgstr;
|
||||
// }
|
||||
|
||||
// } catch (error) {
|
||||
// console.error('下拉框', error)
|
||||
// }
|
||||
instance.on('fileStatus', () => {
|
||||
debouncedFn(5000);
|
||||
});
|
||||
instance.ApiEvent.AddApiEventListener('error', (err) => {
|
||||
console.log('发生错误:', err);
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取IMM凭证信息
|
||||
*/
|
||||
async function getTokenFun(url: any, temObj: TemObj) {
|
||||
let res = temObj.isEdit
|
||||
? await generateWebofficeToken({ url: url, name: temObj.name })
|
||||
: await generateWebofficeTokenReadOnly({ url: url, name: temObj.name });
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新IMM凭证信息
|
||||
*/
|
||||
async function refreshTokenFun(tokenInfo: any) {
|
||||
console.log('refreshWebofficeToken is called');
|
||||
let res = await refreshWebofficeToken({ accessToken: tokenInfo.accessToken, refreshToken: tokenInfo.refreshToken });
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.activity {
|
||||
font-weight: bold;
|
||||
background: rgba(24, 144, 255, 0.08);
|
||||
color: #1890FF;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.cardView {
|
||||
margin-left: 12px;
|
||||
padding-left: 12px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&>div {
|
||||
margin-right: 12px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
/*
|
||||
img {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
*/
|
||||
}
|
||||
</style>
|
||||
69
reading-platform-frontend/src/views/office/temObjs.ts
Normal file
69
reading-platform-frontend/src/views/office/temObjs.ts
Normal file
@ -0,0 +1,69 @@
|
||||
|
||||
import { ref, watch, } from 'vue';
|
||||
/**
|
||||
* 临时localStorage缓存,使用后立即删除,适合跨页面使用
|
||||
*/
|
||||
const TemKel = '_t'
|
||||
/**
|
||||
* 设置缓存超时
|
||||
*/
|
||||
const base_time = 1000 * 60 * 60 * 8;
|
||||
type Tem = {
|
||||
[key: string]: {
|
||||
time: number,
|
||||
val: TemObj
|
||||
}
|
||||
}
|
||||
export function initilTemItem() {
|
||||
const tem: Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
|
||||
const _time = Date.now();
|
||||
const _tem: Tem = {};
|
||||
for (let key in tem) {
|
||||
const val = tem[key];
|
||||
if (_time - val.time < base_time) {
|
||||
_tem[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(TemKel, JSON.stringify(_tem));
|
||||
}
|
||||
export type TemObj = {
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
type: string;
|
||||
isEdit: boolean;
|
||||
}
|
||||
export function setTemItem(val: TemObj, time: Number = Date.now() + base_time): string {
|
||||
const key = `_t${Date.now()}${Math.floor(Math.random() * 100000)}`;
|
||||
let Tem: Tem = {};
|
||||
try {
|
||||
Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
|
||||
} catch (error) { }
|
||||
Tem[key] = {
|
||||
time: Date.now(),
|
||||
val: val
|
||||
}
|
||||
localStorage.setItem(TemKel, JSON.stringify(Tem));
|
||||
|
||||
return key
|
||||
}
|
||||
export function getTemItem(key: string): TemObj | null {
|
||||
let Tem: Tem = {};
|
||||
try {
|
||||
Tem = JSON.parse(localStorage.getItem(TemKel) || "{}");
|
||||
return Tem[key].val
|
||||
} catch (error) { }
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param val 默认值
|
||||
*/
|
||||
export function getCacheVal(key: string, defaultVal: any) {
|
||||
const _val = ref(Number(localStorage.getItem(key) || defaultVal));
|
||||
watch(() => _val.value, () => {
|
||||
localStorage.setItem(key, `${_val.value}`)
|
||||
})
|
||||
return _val
|
||||
}
|
||||
161
reading-platform-frontend/src/views/office/webOffice.ts
Normal file
161
reading-platform-frontend/src/views/office/webOffice.ts
Normal file
File diff suppressed because one or more lines are too long
13
reading-platform-frontend/typed-router.d.ts
vendored
13
reading-platform-frontend/typed-router.d.ts
vendored
@ -191,6 +191,13 @@ declare module 'vue-router/auto-routes' {
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/office/WebOffice': RouteRecordInfo<
|
||||
'/office/WebOffice',
|
||||
'/office/WebOffice',
|
||||
Record<never, never>,
|
||||
Record<never, never>,
|
||||
| never
|
||||
>,
|
||||
'/parent/children/ChildProfileView': RouteRecordInfo<
|
||||
'/parent/children/ChildProfileView',
|
||||
'/parent/children/ChildProfileView',
|
||||
@ -845,6 +852,12 @@ declare module 'vue-router/auto-routes' {
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/views/office/WebOffice.vue': {
|
||||
routes:
|
||||
| '/office/WebOffice'
|
||||
views:
|
||||
| never
|
||||
}
|
||||
'src/views/parent/children/ChildProfileView.vue': {
|
||||
routes:
|
||||
| '/parent/children/ChildProfileView'
|
||||
|
||||
@ -40,6 +40,17 @@ public class OssConfig {
|
||||
*/
|
||||
private Long maxFileSize = 10 * 1024 * 1024L;
|
||||
|
||||
/**
|
||||
* 是否在启动时自动配置 OSS Bucket CORS(解决前端直传跨域)
|
||||
*/
|
||||
private Boolean corsEnabled = false;
|
||||
|
||||
/**
|
||||
* CORS 允许的来源,逗号分隔(如:http://localhost:5173,https://example.com)
|
||||
* 使用 * 表示允许所有来源
|
||||
*/
|
||||
private String corsAllowedOrigins = "http://localhost:5173,http://localhost:5174";
|
||||
|
||||
/**
|
||||
* 允许的 fileExtension
|
||||
*/
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.reading.platform.common.config;
|
||||
|
||||
import com.reading.platform.common.util.OssUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* OSS Bucket CORS 初始化
|
||||
* <p>
|
||||
* 应用启动时自动配置 OSS 跨域规则,解决前端直传跨域问题。
|
||||
* 需在配置中开启:aliyun.oss.cors-enabled=true
|
||||
* </p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(100) // 较晚执行,确保其他组件已就绪
|
||||
@RequiredArgsConstructor
|
||||
public class OssCorsInitRunner implements ApplicationRunner {
|
||||
|
||||
private final OssUtils ossUtils;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ossUtils.configureBucketCors();
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.ConstraintViolation;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.BadCredentialsException;
|
||||
@ -28,6 +29,9 @@ import java.util.stream.Collectors;
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public Result<Void> handleBusinessException(BusinessException e, HttpServletRequest request) {
|
||||
log.warn("业务异常 at {}: {}", request.getRequestURI(), e.getMessage());
|
||||
@ -100,7 +104,19 @@ public class GlobalExceptionHandler {
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
public Result<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
log.error("系统异常 at {}: ", request.getRequestURI(), e);
|
||||
return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), "系统内部错误");
|
||||
// 开发/测试环境返回详细错误信息,便于排查
|
||||
boolean isDev = activeProfile != null && (activeProfile.contains("dev") || activeProfile.contains("test"));
|
||||
String msg = isDev ? getExceptionMessage(e) : "系统内部错误";
|
||||
return Result.error(ErrorCode.INTERNAL_ERROR.getCode(), msg);
|
||||
}
|
||||
|
||||
private String getExceptionMessage(Throwable e) {
|
||||
Throwable cause = e;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
String m = cause.getMessage();
|
||||
return m != null && !m.isBlank() ? m : cause.getClass().getSimpleName();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -498,10 +498,8 @@ public class OssUtils {
|
||||
* @return Base64 编码的 Policy
|
||||
*/
|
||||
private String buildPostPolicy(String objectKey, long expiration) {
|
||||
// ISO 8601 格式的时间
|
||||
String expireTime = java.time.Instant.ofEpochMilli(expiration)
|
||||
.toString()
|
||||
.replace("Z", "+00:00");
|
||||
// ISO 8601 GMT 格式(阿里云要求 YYYY-MM-DDTHH:mm:ss.sssZ,必须以 Z 结尾)
|
||||
String expireTime = java.time.Instant.ofEpochMilli(expiration).toString();
|
||||
|
||||
// 构建 Policy JSON
|
||||
String policyJson = String.format(
|
||||
@ -541,4 +539,48 @@ public class OssUtils {
|
||||
throw new RuntimeException("计算 OSS 签名失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置 OSS Bucket CORS 规则,解决前端直传跨域问题
|
||||
* <p>
|
||||
* 需确保 OSS 账号有 oss:PutBucketCors 权限
|
||||
* </p>
|
||||
*/
|
||||
public void configureBucketCors() {
|
||||
if (!Boolean.TRUE.equals(ossConfig.getCorsEnabled())) {
|
||||
return;
|
||||
}
|
||||
OSS ossClient = getOssClient();
|
||||
try {
|
||||
SetBucketCORSRequest request = new SetBucketCORSRequest(ossConfig.getBucketName());
|
||||
SetBucketCORSRequest.CORSRule rule = new SetBucketCORSRequest.CORSRule();
|
||||
|
||||
// 允许的来源(开发:localhost,生产:需配置实际域名)
|
||||
String origins = ossConfig.getCorsAllowedOrigins();
|
||||
if (origins == null || origins.isBlank()) {
|
||||
origins = "http://localhost:5173,http://localhost:5174";
|
||||
}
|
||||
rule.setAllowedOrigins(Arrays.asList(origins.trim().split("\\s*,\\s*")));
|
||||
|
||||
// 允许的方法:POST(直传)、GET、PUT、DELETE、HEAD
|
||||
rule.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "HEAD"));
|
||||
|
||||
// 允许的请求头(* 表示全部,PostObject 需要)
|
||||
rule.setAllowedHeaders(Arrays.asList("*"));
|
||||
|
||||
// 暴露给前端的响应头
|
||||
rule.setExposeHeaders(Arrays.asList("ETag", "x-oss-request-id"));
|
||||
|
||||
// 预检请求缓存时间(秒)
|
||||
rule.setMaxAgeSeconds(600);
|
||||
|
||||
request.setCorsRules(List.of(rule));
|
||||
ossClient.setBucketCORS(request);
|
||||
log.info("OSS Bucket CORS 配置成功,允许来源: {}", origins);
|
||||
} catch (Exception e) {
|
||||
log.warn("OSS Bucket CORS 配置失败(可在阿里云控制台手动配置): {}", e.getMessage());
|
||||
} finally {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,6 +76,9 @@ aliyun:
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course}
|
||||
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
|
||||
# 前端直传跨域:启动时自动配置 OSS CORS
|
||||
cors-enabled: ${OSS_CORS_ENABLED:true}
|
||||
cors-allowed-origins: ${OSS_CORS_ORIGINS:http://localhost:5173,http://localhost:5174}
|
||||
|
||||
# 日志配置(开发环境 - DEBUG 级别)
|
||||
logging:
|
||||
|
||||
@ -66,7 +66,7 @@ jwt:
|
||||
secret: ${JWT_SECRET}
|
||||
expiration: ${JWT_EXPIRATION:86400000}
|
||||
|
||||
# 阿里云 OSS 配置(开发环境)
|
||||
# 阿里云 OSS 配置(生产环境)
|
||||
aliyun:
|
||||
oss:
|
||||
endpoint: ${OSS_ENDPOINT:oss-cn-shenzhen.aliyuncs.com}
|
||||
@ -74,6 +74,8 @@ aliyun:
|
||||
access-key-secret: ${OSS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
bucket-name: ${OSS_BUCKET_NAME:lesingle-kid-course}
|
||||
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
|
||||
cors-enabled: ${OSS_CORS_ENABLED:false}
|
||||
cors-allowed-origins: ${OSS_CORS_ORIGINS:}
|
||||
|
||||
# 日志配置(生产环境 - 降低日志级别)
|
||||
logging:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user