瀏覽代碼

支持视频自定义上传

jihuaqiang 3 周之前
父節點
當前提交
b1873e6bfb

+ 5 - 0
package.json

@@ -14,20 +14,25 @@
     "@tailwindcss/cli": "^4.1.3",
     "antd": "^5.27.4",
     "axios": "^1.8.4",
+    "crypto-js": "^4.2.0",
+    "lodash": "^4.17.21",
     "lodash-es": "^4.17.21",
     "qrcode.react": "^4.2.0",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
     "react-router-dom": "^6.23.1",
+    "xml-js": "^1.6.11",
     "zustand": "^4.5.2"
   },
   "devDependencies": {
     "@eslint/js": "^9.24.0",
     "@tailwindcss/postcss": "^4.1.3",
+    "@types/lodash": "^4.17.20",
     "@types/lodash-es": "^4.17.12",
     "@types/node": "^22.14.0",
     "@types/react": "^18.3.3",
     "@types/react-dom": "^18.3.0",
+    "@types/xml-js": "^1.0.0",
     "@typescript-eslint/parser": "^6.0.0",
     "@vitejs/plugin-react-swc": "^3.5.0",
     "autoprefixer": "^10.4.21",

+ 4 - 0
src/http/api.ts

@@ -53,4 +53,8 @@ export const getNoticeList = `${import.meta.env.VITE_API_URL}/contentPlatform/no
 export const readNotice = `${import.meta.env.VITE_API_URL}/contentPlatform/notice/read`
 export const readAllNotice = `${import.meta.env.VITE_API_URL}/contentPlatform/notice/readAll`
 
+// 文件上传
+export const fileUpload = `${import.meta.env.VITE_API_URL}/file/upload`
+export const getTempStsToken = `${import.meta.env.VITE_API_URL}/file/getTempStsToken`
+
 

+ 102 - 0
src/utils/OSSSDK/api/index.ts

@@ -0,0 +1,102 @@
+import axios from 'axios';
+import convert from 'xml-js';
+
+const instance = axios.create({
+    timeout: 240000,
+    validateStatus: function (status) {
+        return status < 600;
+    },
+    headers: {
+        'content-type': 'application/octet-stream',
+    }
+});
+
+instance.interceptors.response.use((res) => {
+    return res;
+}, function (err) {
+    return Promise.reject(err);
+});
+
+export default {
+    post (url: string, params?: any, config?: any) {
+        const myConfig: any = {};
+        if (config) {
+            Object.assign(myConfig, config);
+        }
+
+        return new Promise((resolve, reject) => {
+            const _startTime = new Date().getTime();
+            instance.post(url, params as any, myConfig).then(res => {
+                const _endTime = new Date().getTime();
+                (res as any).dTime = _endTime - _startTime;
+                if (res.status === 200) {
+                    resolve(res);
+                } else {
+                    const parseData = JSON.parse(convert.xml2json(res.data as any, {
+                        compact: true,
+                        spaces: 4,
+                        ignoreCdata: true,
+                        ignoreDeclaration: true
+                    } as any));
+                    (res as any).data = (parseData as any)['Error'];
+                    reject(res);
+                }
+            }).catch((err: any) => {
+                const _endTime = new Date().getTime();
+                err.dTime = _endTime - _startTime;
+                err.url = url;
+                reject(err);
+            });
+        });
+    },
+    get (url: string, params?: any, config?: any) {
+        const myConfig: any = {};
+        if (config) {
+            Object.assign(myConfig, config);
+        }
+        return new Promise((resolve, reject) => {
+            instance.get(url, { params: params }, myConfig).then(res => {
+                if (res.status === 200) {
+                    resolve(res);
+                } else {
+                    reject(res);
+                }
+            }).catch((err: any) => {
+                reject(err);
+            });
+        });
+    },
+
+    upload (url: string, file: Blob, config?: any) {
+        const myConfig: any = {};
+        if (config) {
+            Object.assign(myConfig, config);
+        }
+        return new Promise((resolve, reject) => {
+            const _startTime = new Date().getTime();
+            instance.put(url, file, myConfig).then(res => {
+                const _endTime = new Date().getTime();
+                (res as any).dTime = _endTime - _startTime;
+                if (res.status === 200) {
+                    resolve(res);
+                } else {
+                    const parseData = JSON.parse(convert.xml2json(res.data as any, {
+                        compact: true,
+                        spaces: 4,
+                        ignoreCdata: true,
+                        ignoreDeclaration: true
+                    } as any));
+                    (res as any).data = (parseData as any)['Error'];
+                    reject(res);
+                }
+            }).catch((err: any) => {
+                const _endTime = new Date().getTime();
+                err.dTime = _endTime - _startTime;
+                err.url = url;
+                reject(err);
+            });
+        });
+    }
+};
+
+

+ 58 - 0
src/utils/OSSSDK/api/methodList.ts

@@ -0,0 +1,58 @@
+import ajax from './index';
+import signUtils from '../utils/signUtils';
+import { xml2js } from 'xml-js';
+
+const initiateMultipartUpload = (creds: any, subres: any, headerConfig?: Record<string, any>) => {
+    const _initializeUrl = `${creds['host']}${creds['fileName']}?uploads=`;
+    const requestHeader = signUtils.authorization({
+        method: 'post'
+    }, creds, subres, headerConfig);
+    return new Promise((resolve, reject) => {
+        ajax.post(_initializeUrl, null, {
+            headers: requestHeader
+        }).then((res: any) => {
+            const parseRes: any = (xml2js as any)(res.data, { compact: true, spaces: 4 } as any);
+            const _params: any = {};
+            Object.keys(parseRes['InitiateMultipartUploadResult'] || {}).forEach((key: string) => {
+                _params[key] = parseRes['InitiateMultipartUploadResult'][key]['_text'];
+            });
+            resolve(_params);
+        }).catch((err: any) => {
+            reject(err);
+        });
+    });
+};
+
+const completeMultipartUpload = (creds: any, listData: any[], _subres: any) => {
+    const partsXml = listData.map((item: any) => {
+        return `<Part><PartNumber>${item.partNumber}</PartNumber><ETag>${item.eTag}</ETag></Part>`;
+    }).join('');
+
+    const xmlData = `<?xml version="1.0" encoding="utf-8"?><CompleteMultipartUpload>${partsXml}</CompleteMultipartUpload>`;
+
+    const _completeMultipartUploadUrl = `${creds['host']}${creds['fileName']}?uploadId=${_subres['uploadId']}`;
+    const requestHeader = signUtils.authorization({
+        method: 'post',
+        'content-type': 'application/xml'
+    }, creds, _subres);
+    delete (requestHeader as any)['content-type'];
+    return new Promise((resolve, reject) => {
+        ajax.post(_completeMultipartUploadUrl, xmlData as any, {
+            headers: {
+                'content-type': 'application/xml',
+                ...requestHeader
+            }
+        }).then((res: any) => {
+            resolve(res);
+        }).catch((err: any) => {
+            reject(err);
+        });
+    });
+};
+
+export {
+    initiateMultipartUpload,
+    completeMultipartUpload
+};
+
+

+ 7 - 0
src/utils/OSSSDK/errorList.ts

@@ -0,0 +1,7 @@
+export default {
+    cancel: 'cancel',
+    netErr: 'network error',
+    serverErr: 'server Error'
+};
+
+

+ 47 - 0
src/utils/OSSSDK/fileOprs.ts

@@ -0,0 +1,47 @@
+export default class FileOpr {
+    _file: File | any = null;
+    _defaultChunkSize: number = 1024 * 1024;
+    _minChunkSize: number = 0;
+    _partsNumber: number = 0;
+
+    constructor (file: File) {
+        this._file = file;
+        if (Math.ceil(file.size / this._defaultChunkSize) <= 1) {
+            this._minChunkSize = 500 * 1024;
+            this._partsNumber = Math.ceil(file.size / this._minChunkSize);
+        } else if (Math.ceil(file.size / this._defaultChunkSize) < 10000) {
+            this._partsNumber = Math.ceil(file.size / this._defaultChunkSize);
+        } else {
+            this._partsNumber = 10000;
+            this._minChunkSize = Math.ceil(this._file.size / 10000);
+        }
+    }
+
+    _fileSlice (sliceIndex: number) {
+        const partSize = this._minChunkSize === 0 ? this._defaultChunkSize : this._minChunkSize,
+            _start = sliceIndex * partSize,
+            _end = _start + partSize > this._file.size ? this._file.size : _start + partSize;
+
+        const partBlob = this._file.slice(_start, _end);
+
+        return {
+            file: partBlob as Blob,
+            partNumber: sliceIndex + 1,
+            partSize: (partBlob as Blob).size
+        };
+    }
+
+    getSliceArr (startPartNumber: number, num: number) {
+        const sliceArr: any[] = [];
+        while (sliceArr.length < num) {
+            sliceArr.push(this._fileSlice(startPartNumber + sliceArr.length - 1));
+        }
+        return sliceArr;
+    }
+
+    getPartsNumber () {
+        return this._partsNumber;
+    }
+}
+
+

+ 345 - 0
src/utils/OSSSDK/index.ts

@@ -0,0 +1,345 @@
+import ajax from './api/index';
+import FileOprs from './fileOprs';
+import axios from 'axios';
+import { arrayUnique, errHandler } from './utils/tools';
+import signUtils from './utils/signUtils';
+import errList from './errorList';
+import { initiateMultipartUpload, completeMultipartUpload } from './api/methodList';
+import defaultsDeep from 'lodash/defaultsDeep';
+
+type AnyFunction = (...args: any[]) => any;
+
+export default class OSSSDK {
+    static opts: { log: boolean; timeGap: number } = {
+        log: false,
+        timeGap: 0,
+    };
+
+    _fileOperator: any = null;
+    _userProcessHandler: AnyFunction | null = null;
+    _uploadParams: any = null;
+    _creds: any = null;
+    _checkpoint: any[] = [];
+    _lastGeneratePartNum: number = 0;
+    _parrallel: number = 5;
+    _threadQueue: Array<any> = [];
+    _nowThreadNum: number = 0;
+    _retryTimes: number = 0;
+    _delayTime: number = 0;
+    _isManualStop: boolean = false;
+    _lastSpeedCheckIndex: number = 0;
+    _speedAnalyzeTimes: number = 1;
+    _speed: string = '0.00';
+    _isComplete: boolean = false;
+    _headerConfig: any = null;
+
+    constructor (file: File, creds: any, handler?: AnyFunction, options?: Record<string, any>) {
+        try {
+            if (!file) throw new Error('file is should not null');
+            if (!creds) throw new Error('creds is should not null');
+            if (handler) {
+                this._userProcessHandler = handler;
+            }
+            this._creds = creds;
+            this._fileOperator = new FileOprs(file);
+            if (options) {
+                Object.keys(options).forEach(key => {
+                    (this as any)['_' + key] = (options as any)[key];
+                });
+            }
+        } catch (err: any) {
+            throw new Error(err.toString());
+        }
+    }
+
+    static config (options?: Partial<typeof OSSSDK.opts>) {
+        OSSSDK.opts = defaultsDeep(options || {}, OSSSDK.opts);
+    }
+
+    updateConfig (creds: any) {
+        this._creds = creds;
+    }
+
+    _initUpload (): Promise<any> {
+        return new Promise((resolve, reject) => {
+            if (!this._uploadParams) {
+                const _subres: any = { 'uploads': '' };
+                initiateMultipartUpload(this._creds, _subres, this._headerConfig).then(res => {
+                    this._uploadParams = res || {};
+                    resolve(res);
+                }).catch((err: any) => {
+                    this._uploadParams = null;
+                    reject(errHandler(err, 'initUpload'));
+                });
+            } else {
+                resolve(this._uploadParams);
+            }
+        });
+    }
+
+    _generateJob (threadIndex: number): any {
+        if (this._lastGeneratePartNum < this._fileOperator.getPartsNumber()) {
+            this._lastGeneratePartNum = this._lastGeneratePartNum + 1;
+
+            const fileItem = this._fileOperator._fileSlice(this._lastGeneratePartNum - 1);
+
+            const url = this._creds['host'] + this._creds['fileName'] +
+                `?partNumber=${fileItem.partNumber}&uploadId=${this._uploadParams['UploadId']}`;
+
+            const _subres: any = { 'partNumber': fileItem['partNumber'], 'uploadId': this._uploadParams['UploadId'] };
+            const requestHeader = signUtils.authorization({
+                method: 'put'
+            }, this._creds, _subres, this._headerConfig);
+            if (this._isManualStop) {
+                return {
+                    threadIndex: threadIndex,
+                    run: () => {
+                        return new Promise((resolve, reject) => {
+                            this._reset(threadIndex, fileItem);
+                            reject('stop');
+                        });
+                    }
+                };
+            } else {
+                if (new Date().getTime() > new Date(this._creds.expiration).getTime()) {
+                    return {
+                        threadIndex: threadIndex,
+                        run: () => {
+                            return new Promise((resolve, reject) => {
+                                this._reset(threadIndex, fileItem);
+                                reject(errHandler(new Error('InvalidAccessKeyId') as any, 'multipartUpload', fileItem));
+                            });
+                        }
+                    };
+                } else {
+                    return this._jobThread(fileItem, url, requestHeader, threadIndex);
+                }
+            }
+        } else {
+            return {
+                threadIndex: threadIndex,
+                run: () => {
+                    return new Promise((resolve) => {
+                        this._threadQueue[threadIndex - 1].status = 'complete';
+                        resolve('complete');
+                    });
+                }
+            };
+        }
+    }
+
+    _jobThread (fileItem: any, url: string, requestHeader: any, threadIndex: number) {
+        return {
+            threadIndex: threadIndex,
+            run: () => {
+                return new Promise((resolve, reject) => {
+                    if (this._threadQueue.filter((item: any) => item.status === 'stop').length > 0) {
+                        this._reset(threadIndex, fileItem);
+                        reject(errList.cancel);
+                    } else {
+                        ajax.upload(url, fileItem.file, {
+                            cancelToken: new axios.CancelToken((c) => {
+                                this._threadQueue[threadIndex - 1].cancel = () => {
+                                    c('stop');
+                                };
+                            }),
+                            headers: requestHeader
+                        }).then((res: any) => {
+                            this._processHandler(fileItem, res, this._userProcessHandler || undefined);
+                            if (this._checkpoint.length === this._fileOperator.getPartsNumber()) {
+                                this._threadQueue[threadIndex - 1].status = 'complete';
+                                resolve('complete');
+                            } else {
+                                resolve(this._generateJob(threadIndex).run());
+                            }
+                        }).catch((err: any) => {
+                            this._reset(threadIndex, fileItem);
+                            reject(errHandler(err, 'multipartUpload', fileItem));
+                        });
+                    }
+                });
+            }
+        };
+    }
+
+    _processHandler (fileItem: any, res: any, _userProcessHandler?: AnyFunction) {
+        const processItem = {
+            donePartNum: fileItem.partNumber,
+            etag: res.headers.etag,
+            dTime: res.dTime,
+            percent: (Array.from(new Set(this._checkpoint.map(item => item.donePartNum))).length + 1) / this._fileOperator.getPartsNumber()
+        };
+        this._checkpoint.push(processItem);
+
+        if (_userProcessHandler) {
+            res.partSize = fileItem.partSize;
+            _userProcessHandler(this._checkpoint, OSSSDK.opts.log ? res : undefined);
+        }
+    }
+
+    _reset (threadIndex: number, fileItem: any) {
+        this._threadQueue[threadIndex - 1].status = 'stop';
+        this._threadQueue[threadIndex - 1].startPartNumber = fileItem.partNumber;
+        this._threadQueue[threadIndex - 1].cancel = null;
+        this._lastGeneratePartNum--;
+        this._lastSpeedCheckIndex = this._checkpoint.length ? this._checkpoint.length : 0;
+        this._speedAnalyzeTimes = 1;
+        this._nowThreadNum = 0;
+    }
+
+    async _createThreads (): Promise<any> {
+        try {
+            const jobQueue: any[] = [];
+            while (this._nowThreadNum < this._parrallel) {
+                this._nowThreadNum++;
+                jobQueue.push(this._generateJob(this._nowThreadNum));
+            }
+
+            jobQueue.forEach((job: any) => {
+                this._threadQueue.push({
+                    threadIndex: job.threadIndex,
+                    status: 'processing',
+                    thread: null,
+                    cancel: null,
+                    startPartNumber: null,
+                });
+                this._threadQueue[this._threadQueue.length - 1].thread = job.run();
+            });
+        } catch (err: any) {
+            return errHandler(err, 'createThreads');
+        }
+    }
+
+    async _activeThreads (): Promise<any> {
+        try {
+            if (this._threadQueue.length === 0) {
+                return this._createThreads();
+            } else {
+                const _partNumList = this._threadQueue.map((item: any) => {
+                    return item.startPartNumber;
+                }).filter((num: any) => !!num).sort((a: number, b: number) => a - b);
+
+                this._lastGeneratePartNum = _partNumList.length > 0 ? _partNumList[0] - 1 : 0;
+                const jobQueue: any[] = [];
+                while (this._nowThreadNum < this._parrallel) {
+                    this._nowThreadNum++;
+                    jobQueue.push(this._generateJob(this._nowThreadNum));
+                    this._threadQueue[this._nowThreadNum - 1].status = 'processing';
+                    this._threadQueue[this._nowThreadNum - 1].cancel = null;
+                    this._threadQueue[this._nowThreadNum - 1].startPartNumber = null;
+                }
+
+                jobQueue.forEach((job: any, index: number) => {
+                    this._threadQueue[index].thread = job.run();
+                });
+            }
+        } catch (err: any) {
+            return errHandler(err, 'activeThreads');
+        }
+    }
+
+    mergeMultiParts (): Promise<any> {
+        return new Promise((resolve, reject) => {
+            try {
+                const _listData = arrayUnique(this._checkpoint, 'donePartNum').map((item: any) => {
+                    return {
+                        partNumber: item.donePartNum,
+                        eTag: item.etag
+                    };
+                }).sort((c1: any, c2: any) => {
+                    if(c1.partNumber > c2.partNumber) {
+                        return 1;
+                    } else if(c1.partNumber < c2.partNumber) {
+                        return -1;
+                    } else {
+                        return 0;
+                    }
+                });
+                const _subres: any = { 'uploadId': this._uploadParams['UploadId'] };
+                completeMultipartUpload(this._creds, _listData, _subres).then(() => {
+                    resolve(void 0);
+                }).catch((err: any) => {
+                    reject(errHandler(err, 'mergeMultiParts'));
+                });
+            } catch (err: any) {
+                reject(errHandler(err, 'mergeMultiParts'));
+            }
+        });
+    }
+
+    multipartUpload (): Promise<any> {
+        return new Promise((resolve, reject) => {
+            this._initUpload().then(() => {
+                this._createThreads().then(() => {
+                    Promise.all(this._threadQueue.map((item: any) => item.thread)).then(() => {
+                        this._isComplete = true;
+                        this.mergeMultiParts().then(() => {
+                            resolve();
+                        }).catch((err: any) => {
+                            reject(err);
+                        });
+                    }).catch((err: any) => {
+                        this.cancelUpload();
+                        setTimeout(() => {
+                            reject(err);
+                        }, 300);
+                    });
+                }).catch((err: any) => {
+                    reject(err);
+                });
+            }).catch((err: any) => {
+                reject(err);
+            });
+        });
+    }
+
+    resumeMultipartUpload (): Promise<any> {
+        if(this._isManualStop) this._isManualStop = false;
+        return new Promise((resolve, reject) => {
+            this._initUpload().then(() => {
+                this._activeThreads().then(() => {
+                    Promise.all(this._threadQueue.map((item: any) => item.thread)).then(() => {
+                        this._isComplete = true;
+                        this.mergeMultiParts().then((res: any) => {
+                            resolve(res);
+                        }).catch((err: any) => {
+                            reject(err);
+                        });
+                    }).catch((err: any) => {
+                        this.cancelUpload();
+                        setTimeout(() => {
+                            reject(err);
+                        }, 300);
+                    });
+                }).catch((err: any) => {
+                    reject(err);
+                });
+            }).catch((err: any) => {
+                reject(err);
+            });
+        });
+    }
+
+    cancelUpload () {
+        if (!this._isComplete) {
+            this._isManualStop = true;
+            this._threadQueue.forEach((thread: any) => {
+                thread.cancel && thread.cancel();
+                thread.cancel = null;
+            });
+        }
+    }
+
+    getSpeed (callback?: (speed: string) => void) {
+        return setInterval(() => {
+            const _partNums = this._checkpoint.length ? this._checkpoint.length - this._lastSpeedCheckIndex : 0;
+            this._speed = ((_partNums) / this._speedAnalyzeTimes).toFixed(2);
+            this._speedAnalyzeTimes += 1;
+            if (callback) {
+                callback(this._speed);
+            }
+        }, 1000);
+    }
+}
+
+

+ 16 - 0
src/utils/OSSSDK/utils/InterceptArr.ts

@@ -0,0 +1,16 @@
+export const arrIntercept = (arr: any[], proxyMethods: string[], handler: () => void) => {
+    const arrProto = Array.prototype as any;
+    proxyMethods.forEach(method => {
+        Object.defineProperty(arr, method, {
+            value: function mutator (...args: any[]) {
+                handler();
+                return arrProto[method].apply(this, args);
+            },
+            enumerable: false,
+            writable: true,
+            configurable: true
+        });
+    });
+};
+
+

+ 80 - 0
src/utils/OSSSDK/utils/signUtils.ts

@@ -0,0 +1,80 @@
+import cryptoBase64 from 'crypto-js/enc-base64';
+import cryptoHmacSHA1 from 'crypto-js/hmac-sha1';
+import OSSSDK from '../index';
+export default {
+    authorization (options: any, creds: any, subres: any, headerConfig?: Record<string, any>) {
+        const requestHeader = this._generateRequestHeader(options, creds, headerConfig);
+        const authResource = this._getResource(creds, subres);
+        const stringToSign = this._buildCanonicalString(options.method.toUpperCase(), authResource, requestHeader);
+        return {
+            'authorization': `OSS ${creds.accessKeyId}:${this._computeSignature(creds.accessKeySecret, stringToSign)}`,
+            ...requestHeader
+        };
+    },
+    _generateRequestHeader (options: any, creds: any, headerConfig?: Record<string, any>) {
+        const headers: Record<string, string> = {
+            'x-oss-date': new Date(new Date().getTime() - OSSSDK.opts.timeGap).toUTCString(),
+            'content-type': options['content-type'] || 'application/octet-stream'
+        };
+
+        if (headerConfig && Object.keys(headerConfig).length > 0) {
+            Object.keys(headerConfig).forEach(key => {
+                (headers as any)[key] = (headerConfig as any)[key]
+            });
+        }
+
+        if (creds['securityToken']) {
+            (headers as any)['x-oss-security-token'] = creds['securityToken'];
+        }
+
+        return headers;
+    },
+    _getResource (creds: any, subres: any) {
+        let resource = '/';
+        if (creds.bucket) resource += `${creds.bucket}/`;
+        if (creds.fileName) resource += `${creds.fileName}`;
+        Object.keys(subres).forEach((key, index) => {
+            resource += `${index ? '&' : '?'}${key}${subres[key] ? '=' + subres[key] : ''}`;
+        });
+        return resource;
+    },
+    _buildCanonicalString (method: string, resourcePath: string, requestHeader: any) {
+        requestHeader = requestHeader || {};
+        const headers = requestHeader || {};
+        const OSS_PREFIX = 'x-oss-';
+        const ossHeaders: string[] = [];
+        const headersToSign: Record<string, string> = {} as any;
+
+        let signContent: any[] = [
+            method.toUpperCase(),
+            headers['Content-Md5'] || '',
+            headers['Content-Type'] || headers['Content-Type'.toLowerCase()],
+            headers['x-oss-date']
+        ];
+
+        Object.keys(headers).forEach((key) => {
+            const lowerKey = key.toLowerCase();
+            if (lowerKey.indexOf(OSS_PREFIX) === 0) {
+                (headersToSign as any)[lowerKey] = String(headers[key]).trim();
+            }
+        });
+
+        Object.keys(headersToSign).sort().forEach((key) => {
+            ossHeaders.push(`${key}:${(headersToSign as any)[key]}`);
+        });
+
+        signContent = signContent.concat(ossHeaders);
+        signContent.push(resourcePath);
+        return signContent.join('\n');
+    },
+    _computeSignature (accessKeySecret: string, canonicalString: string) {
+        try {
+            return cryptoBase64.stringify(cryptoHmacSHA1(canonicalString, accessKeySecret));
+        } catch (e) {
+            console.log(new Error(e as any));
+        }
+    },
+
+};
+
+

+ 74 - 0
src/utils/OSSSDK/utils/tools.ts

@@ -0,0 +1,74 @@
+export const arrange = (source: number[]) => {
+    let t: number | undefined;
+    let ta: number[] | undefined;
+    const r: number[][] = [];
+
+    source.forEach(function (v) {
+        if (t === v) {
+            (ta as number[]).push(t as number);
+            t = (t as number) + 1;
+            return;
+        }
+
+        ta = [v];
+        t = v + 1;
+        r.push(ta);
+    });
+
+    return r;
+};
+
+export const arrayUnique = (arr: any[], name: string) => {
+    const _map = new Map<string, { index: number }>();
+    const _result: any[] = [];
+
+    return arr.reduce((item: any[], next: any) => {
+        if(_map.has(next[name] + '')) {
+            item.splice((_map.get(next[name] + '') as { index: number }).index, 1, next);
+        } else {
+            item.push(next);
+            _map.set(next[name] + '', { index: item.length - 1 });
+        }
+
+        return item;
+    }, _result);
+};
+
+export const errHandler = (err: any, step: string, fileItem?: any) => {
+    try {
+        const errLog: any = { step: step, message: {} };
+        if (err.status) {
+            errLog.errName = err.data['Code']['_text'];
+            const _logSet: any = {
+                ...err.config.headers,
+                ...err.headers,
+                url: err.config['url'],
+                hostId: err.data['HostId']['_text'],
+                status: err.status,
+                statusText: err.statusText,
+                dTime: err.dTime,
+            };
+            if (fileItem) _logSet.partSize = fileItem.partSize;
+            Object.keys(_logSet).forEach(key => {
+                errLog.message[key.toLowerCase()] = _logSet[key.toString()];
+            });
+        } else {
+            errLog.errName = err.message;
+            errLog.dTime = err.dTime;
+            errLog.url = err.url;
+            if (fileItem) errLog.partSize = fileItem.partSize;
+        }
+        return errLog;
+    } catch (e) {
+        const errLog: any = { step: step };
+        errLog.errName = 'parseLogError';
+        (errLog as any).message = err;
+        return errLog;
+    }
+};
+
+export const timeSkewedHandle = () => {
+
+};
+
+

+ 7 - 0
src/utils/OSSSDK/utils/validator.ts

@@ -0,0 +1,7 @@
+export const configValid = (config: any) => {
+    if (!config) {
+        throw new Error();
+    }
+};
+
+

+ 12 - 1
src/views/publishContent/publishContent.router.tsx

@@ -1,4 +1,4 @@
-import Icon, { DesktopOutlined } from '@ant-design/icons'
+import Icon, { DesktopOutlined, UploadOutlined } from '@ant-design/icons'
 import { AdminRouterItem } from "../../router";
 import React, { Suspense } from 'react';
 import WeComIcon from "@src/assets/images/publishContent/wxCom.svg?react";
@@ -9,6 +9,7 @@ import { Outlet } from "react-router-dom";
 // Lazy load components
 const WeCom = React.lazy(() => import('./weCom/index'));
 const WeGZH = React.lazy(() => import('./weGZH/index'));
+const Videos = React.lazy(() => import('./videos/index'));
 
 // Loading fallback component
 // eslint-disable-next-line react-refresh/only-export-components
@@ -59,6 +60,16 @@ const demoRoutes: AdminRouterItem[] = [
 					icon: <Icon component={WeComIcon} className="!text-[20px]"/>,
 				}
 			},
+			{
+				path: 'videos',
+				element: <LazyComponent Component={Videos} />,
+				meta: {
+					label: "上传内容管理",
+					title: "上传内容管理",
+					key: "/publishContent/videos",
+					icon: <UploadOutlined className="!text-[20px]"/>,
+				}
+			},
 		]
 	}
 ]

+ 150 - 0
src/views/publishContent/videos/components/uploadVideoModal/README.md

@@ -0,0 +1,150 @@
+# UploadVideoModal 组件
+
+基于Vue代码重构的React视频上传模态框组件,集成了OSSSDK进行文件上传。
+
+## 功能特性
+
+- ✅ 视频文件选择和验证(仅支持视频文件,最大500MB)
+- ✅ 封面图片选择和验证(仅支持图片文件,最大5MB)
+- ✅ 自动上传:选择文件后立即开始上传
+- ✅ 分片上传进度显示
+- ✅ 实时上传速度监控
+- ✅ 断点续传和重试机制
+- ✅ 视频标题编辑
+- ✅ 错误处理和用户友好的提示
+- ✅ 响应式设计
+
+## 使用方法
+
+```tsx
+import UploadVideoModal from './components/uploadVideoModal';
+
+function VideoUploadPage() {
+  const [modalVisible, setModalVisible] = useState(false);
+  const [loading, setLoading] = useState(false);
+
+  const handleUploadSuccess = (videoInfo: any) => {
+    console.log('视频上传成功:', videoInfo);
+    // 处理上传成功后的逻辑
+  };
+
+  return (
+    <div>
+      <Button onClick={() => setModalVisible(true)}>
+        上传视频
+      </Button>
+      
+      <UploadVideoModal
+        visible={modalVisible}
+        onClose={() => setModalVisible(false)}
+        onOk={handleUploadSuccess}
+        isLoading={loading}
+      />
+    </div>
+  );
+}
+```
+
+## Props
+
+| 属性 | 类型 | 默认值 | 说明 |
+|------|------|--------|------|
+| visible | boolean | - | 模态框是否显示 |
+| onClose | () => void | - | 关闭模态框回调 |
+| onOk | (videoInfo: any) => void | - | 上传成功回调 |
+| isLoading | boolean | false | 发布按钮加载状态 |
+
+## 组件结构
+
+```
+uploadVideoModal/
+├── index.tsx          # 主组件文件
+├── index.module.css   # 样式文件
+└── README.md          # 使用文档
+```
+
+## 技术实现
+
+### 核心依赖
+- **OSSSDK**: 本地OSS上传SDK,支持分片上传和断点续传
+- **Ant Design**: UI组件库
+- **React Hooks**: 状态管理和生命周期
+
+### 主要功能模块
+
+1. **视频文件上传管理**
+   - 视频文件类型和大小验证(最大500MB)
+   - 文件信息显示
+   - 上传状态管理
+   - 实时速度监控
+   - 选择文件后自动开始上传
+
+2. **封面图片上传管理**
+   - 图片文件类型和大小验证(最大5MB)
+   - 文件信息显示
+   - 上传状态管理
+   - 使用标准HTTP上传(参考editTitleCoverModal逻辑)
+   - 选择文件后自动开始上传
+
+3. **OSS集成**
+   - 获取上传凭证(仅支持视频文件)
+   - 初始化上传器
+   - 分片上传进度回调
+   - 断点续传支持
+
+4. **进度监控**
+   - 实时进度显示
+   - 上传速度计算(仅视频)
+   - 状态文本提示
+
+5. **错误处理**
+   - 重试机制
+   - 用户友好的错误提示
+   - 手动重试功能
+
+6. **视频信息编辑**
+   - 视频标题编辑
+   - 表单验证
+
+## 样式特性
+
+- 现代化UI设计
+- 响应式布局
+- 动画效果
+- 状态指示器
+- 主题色彩统一
+
+## 注意事项
+
+1. **API接口**: 需要根据实际后端API调整接口地址和参数格式
+2. **文件限制**: 当前限制视频文件最大500MB,图片文件最大5MB,可根据需要调整
+3. **重试机制**: 支持手动重试功能
+4. **浏览器兼容**: 需要支持File API和现代JavaScript特性
+
+## 自定义配置
+
+可以通过修改组件内部配置来调整:
+- 视频文件大小限制
+- 图片文件大小限制
+- 上传超时时间
+- 样式主题
+
+## 更新日志
+
+- v1.2.0: 自动上传版本
+  - 选择文件后自动开始上传
+  - 移除手动上传按钮
+  - 优化用户体验和操作流程
+
+- v1.1.0: 简化版本
+  - 支持视频和封面上传
+  - 仅保留视频标题编辑
+  - 移除复杂的分类和设置选项
+  - 优化UI布局和用户体验
+  - 封面上传使用标准HTTP上传(参考editTitleCoverModal逻辑)
+
+- v1.0.0: 基于Vue代码重构的初始版本
+  - 实现基础文件上传功能
+  - 集成OSSSDK
+  - 添加视频信息编辑
+  - 实现错误处理和重试机制

+ 269 - 0
src/views/publishContent/videos/components/uploadVideoModal/index.module.css

@@ -0,0 +1,269 @@
+.upload-video-modal {
+  padding: 20px 0;
+}
+
+.upload-section {
+  margin-bottom: 24px;
+  padding: 20px;
+  background: #fafafa;
+  border-radius: 8px;
+  border: 1px dashed #d9d9d9;
+}
+
+.file-info {
+  margin-top: 12px;
+  padding: 12px;
+  background: #fff;
+  border-radius: 6px;
+  border: 1px solid #e8e8e8;
+}
+
+.file-info p {
+  margin: 4px 0;
+  color: #666;
+  font-size: 14px;
+}
+
+.upload-progress-section {
+  margin-bottom: 24px;
+  padding: 20px;
+  background: #fff;
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.progress-info {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+
+.file-name {
+  font-weight: 500;
+  color: #333;
+  font-size: 14px;
+}
+
+.upload-speed {
+  color: #1890ff;
+  font-size: 12px;
+  font-weight: 500;
+}
+
+.progress-text {
+  margin-top: 8px;
+  text-align: center;
+  color: #666;
+  font-size: 12px;
+}
+
+.video-info-section {
+  margin-bottom: 24px;
+  padding: 20px;
+  background: #fff;
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.action-section {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16px 20px;
+  background: #f8f9fa;
+  border-radius: 8px;
+  border: 1px solid #e8e8e8;
+}
+
+.left-actions {
+  flex: 1;
+}
+
+.right-actions {
+  flex: 1;
+  text-align: right;
+}
+
+/* 上传按钮样式 */
+.upload-section .ant-upload {
+  width: 100%;
+}
+
+.upload-section .ant-upload .ant-btn {
+  width: 100%;
+  height: 48px;
+  border: 1px dashed #d9d9d9;
+  background: #fff;
+  color: #666;
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s;
+}
+
+.upload-section .ant-upload .ant-btn:hover {
+  border-color: #1890ff;
+  color: #1890ff;
+}
+
+/* 进度条样式 */
+.upload-progress-section .ant-progress {
+  margin: 12px 0;
+}
+
+.upload-progress-section .ant-progress-bg {
+  background: linear-gradient(90deg, #ff4383 0%, #ff6b9d 100%);
+}
+
+/* 更多设置按钮样式 */
+.more-btn {
+  display: inline-flex;
+  align-items: center;
+  color: #acacac;
+  font-size: 14px;
+  cursor: pointer;
+  transition: color 0.3s;
+}
+
+.more-btn:hover {
+  color: #1890ff;
+}
+
+.more-btn .anticon {
+  margin-right: 4px;
+}
+
+/* 发布按钮样式 */
+.publish-btn {
+  width: 104px;
+  height: 42px;
+  background: rgba(17, 17, 17, 0.01);
+  border: 1px solid rgba(236, 92, 142, 0.3);
+  border-radius: 6px;
+  color: #ec5c8e;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.3s;
+  font-size: 14px;
+}
+
+.publish-btn:hover {
+  background: rgba(236, 92, 142, 0.1);
+  border-color: rgba(236, 92, 142, 0.5);
+}
+
+.publish-btn.disable {
+  background: rgba(17, 17, 17, 0.01) !important;
+  opacity: 0.5 !important;
+  cursor: not-allowed;
+}
+
+/* 重试按钮样式 */
+.retry-btn {
+  color: #fff;
+  background-color: #f2584f;
+  border-color: #f2584f;
+}
+
+.retry-btn:hover {
+  background-color: rgba(242, 88, 79, 0.8);
+  border-color: rgba(242, 88, 79, 0.8);
+}
+
+/* 表单样式 */
+.video-info-section .ant-form-item {
+  margin-bottom: 16px;
+}
+
+.video-info-section .ant-form-item-label > label {
+  color: #333;
+  font-weight: 500;
+}
+
+.video-info-section .ant-input,
+.video-info-section .ant-select-selector,
+.video-info-section .ant-input-number {
+  border-radius: 6px;
+}
+
+.video-info-section .ant-input:focus,
+.video-info-section .ant-input-focused {
+  border-color: #1890ff;
+  box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .action-section {
+    flex-direction: column;
+    gap: 12px;
+  }
+  
+  .left-actions,
+  .right-actions {
+    width: 100%;
+    text-align: center;
+  }
+  
+  .upload-section,
+  .upload-progress-section,
+  .video-info-section,
+  .action-section {
+    margin: 0 0 16px 0;
+    padding: 16px;
+  }
+}
+
+/* 动画效果 */
+.upload-progress-section {
+  animation: fadeInUp 0.3s ease-out;
+}
+
+.video-info-section {
+  animation: fadeInUp 0.3s ease-out;
+}
+
+@keyframes fadeInUp {
+  from {
+    opacity: 0;
+    transform: translateY(20px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* 状态指示器 */
+.status-indicator {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  padding: 2px 8px;
+  border-radius: 12px;
+}
+
+.status-indicator.uploading {
+  color: #1890ff;
+  background: rgba(24, 144, 255, 0.1);
+}
+
+.status-indicator.uploaded {
+  color: #52c41a;
+  background: rgba(82, 196, 26, 0.1);
+}
+
+.status-indicator.error {
+  color: #f2584f;
+  background: rgba(242, 88, 79, 0.1);
+}
+
+.status-indicator.waiting {
+  color: #faad14;
+  background: rgba(250, 173, 20, 0.1);
+}

+ 587 - 0
src/views/publishContent/videos/components/uploadVideoModal/index.tsx

@@ -0,0 +1,587 @@
+import { Button, Modal, Upload, Progress, message, Form, Input, Space } from "antd";
+import { UploadOutlined, ReloadOutlined, PlusOutlined } from "@ant-design/icons";
+import React, { useState, useEffect, useCallback } from "react";
+import OSSSDK from "../../../../../utils/OSSSDK";
+import http from "../../../../../http";
+import styles from "./index.module.css";
+import type { UploadFile, UploadProps } from "antd/es/upload/interface";
+import { getAccessToken } from "../../../../../http/sso";
+import { adFileUpload, getTempStsToken } from "../../../../../http/api";
+
+interface UploadVideoModalProps {
+  visible: boolean;
+  onClose: () => void;
+  onOk?: (videoInfo: any) => void;
+  isLoading?: boolean;
+}
+
+interface UploadStatus {
+  isUploading: boolean;
+  isUploaded: boolean;
+  isError: boolean;
+  errType?: string;
+}
+
+
+interface UploadCreds {
+  host: string;
+  hosts: string[];
+  fileName: string;
+  upload: string;
+  accessKeyId: string;
+  accessKeySecret: string;
+  securityToken: string;
+  expiration: string;
+}
+
+const UploadVideoModal: React.FC<UploadVideoModalProps> = ({ 
+  visible, 
+  onClose, 
+  onOk, 
+  isLoading = false 
+}) => {
+  // 视频文件状态
+  const [videoFile, setVideoFile] = useState<File & { localUrl?: string } | null>(null);
+  const [videoUploadProgress, setVideoUploadProgress] = useState(0);
+  const [videoUploadSpeed, setVideoUploadSpeed] = useState('0Mb/s');
+  const [videoUploadStatus, setVideoUploadStatus] = useState<UploadStatus>({
+    isUploading: false,
+    isUploaded: false,
+    isError: false
+  });
+
+  // 封面文件状态
+  const [coverFileList, setCoverFileList] = useState<UploadFile[]>([]);
+  const [coverUploadStatus, setCoverUploadStatus] = useState<UploadStatus>({
+    isUploading: false,
+    isUploaded: false,
+    isError: false
+  });
+
+  // OSS相关状态
+  const [videoCreds, setVideoCreds] = useState<UploadCreds | null>(null);
+  const [videoUploader, setVideoUploader] = useState<OSSSDK | null>(null);
+  const [videoUrl, setVideoUrl] = useState('');
+  
+  // 重试相关状态
+  const [speedTimer, setSpeedTimer] = useState<NodeJS.Timeout | null>(null);
+
+  // 表单引用
+  const [form] = Form.useForm();
+
+  // 视频预览
+  const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
+  const [isVideoHovering, setIsVideoHovering] = useState(false);
+
+  // 重置状态
+  const resetStates = useCallback(() => {
+    // 重置视频文件状态
+    setVideoFile(null);
+    setVideoUploadProgress(0);
+    setVideoUploadSpeed('0Mb/s');
+    setVideoUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+    
+    // 重置封面文件状态
+    setCoverFileList([]);
+    setCoverUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+    
+    // 重置OSS状态
+    setVideoCreds(null);
+    setVideoUploader(null);
+    setVideoUrl('');
+    
+    if (speedTimer) {
+      clearInterval(speedTimer);
+      setSpeedTimer(null);
+    }
+    form.resetFields();
+  }, [speedTimer, form]);
+
+  // 组件卸载时清理
+  useEffect(() => {
+    return () => {
+      if (speedTimer) clearInterval(speedTimer);
+      if (videoUploader) {
+        videoUploader.cancelUpload();
+      }
+    };
+	}, [speedTimer, videoUploader]);
+	
+	useEffect(() => {
+		if (videoFile) {
+			startVideoUpload(videoFile);
+		}
+	}, [videoFile]);
+
+  // 获取上传凭证
+  const getSignature = async (fileType: number, uploadId?: string): Promise<UploadCreds> => {
+    try {
+      const params: any = { fileType };
+      if (uploadId) {
+        params.uploadId = uploadId;
+      }
+      // 这里需要根据实际API接口调整
+      const response = await http.post<any>(getTempStsToken, params);
+      
+      if (response.code === 0) {
+        const credsData = response.data;
+        if (fileType === 2) { // 视频文件
+          setVideoUrl(credsData.fileName);
+        }
+        return credsData;
+      } else {
+        throw new Error(response.data.msg || '获取签名失败');
+      }
+    } catch (error) {
+      console.error('获取签名失败:', error);
+      throw error;
+    }
+  };
+
+  // 初始化视频上传器
+  const initVideoUploader = async (creds: UploadCreds): Promise<OSSSDK> => {
+    if (!videoFile) {
+      throw new Error('视频文件不存在');
+    }
+
+    const uploader = new OSSSDK(videoFile, creds, (checkpoint: any[]) => {
+      // 更新上传进度
+      const progress = Number((checkpoint[checkpoint.length - 1].percent * 100).toFixed(2));
+      setVideoUploadProgress(progress);
+    });
+
+    return uploader;
+  };
+
+
+  // 开始视频上传
+  const startVideoUpload = async (file?: File) => {
+    const targetFile = file || videoFile;
+    if (!targetFile) {
+      message.error('请先选择视频文件');
+      return;
+    }
+
+    try {
+      setVideoUploadStatus(prev => ({ ...prev, isUploading: true, isError: false }));
+      
+      // 获取上传凭证
+      const uploadCreds = await getSignature(2); // 2表示视频文件
+      setVideoCreds(uploadCreds);
+
+      // 初始化上传器
+      const uploaderInstance = await initVideoUploader(uploadCreds);
+      setVideoUploader(uploaderInstance);
+
+      // 开始速度监控
+      startSpeedMonitoring(uploaderInstance);
+
+      // 开始上传
+      await uploaderInstance.multipartUpload();
+      
+      // 上传完成
+      setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
+      setVideoUploadSpeed('');
+      message.success('视频上传成功');
+
+    } catch (error: any) {
+      console.error('视频上传失败:', error);
+      setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isError: true, errType: 'uploadError' }));
+      message.error('视频上传失败,请重试');
+    }
+  };
+
+  // 封面上传处理函数
+  const handleCoverUploadChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
+    // 只保留最新的文件
+    setCoverFileList(newFileList.slice(-1));
+    
+    // 如果上传成功,更新状态
+    if (newFileList.length > 0 && newFileList[0].status === 'done') {
+      setCoverUploadStatus(prev => ({ ...prev, isUploaded: true, isError: false }));
+      message.success('封面上传成功');
+    } else if (newFileList.length > 0 && newFileList[0].status === 'error') {
+      setCoverUploadStatus(prev => ({ ...prev, isError: true }));
+      message.error('封面上传失败');
+    } else if (newFileList.length > 0 && newFileList[0].status === 'uploading') {
+      setCoverUploadStatus(prev => ({ ...prev, isUploading: true, isError: false }));
+    }
+  };
+
+  // 封面文件验证
+  const checkCoverFile = (file: UploadFile) => {
+    if ((file.size || 0) > 5 * 1024 * 1024) {
+      message.error('图片大小不能超过5MB');
+      return Upload.LIST_IGNORE; // 阻止上传
+    }
+    return true; // 允许上传
+  };
+
+  // 开始速度监控
+  const startSpeedMonitoring = (uploaderInstance: OSSSDK) => {
+    const timer = uploaderInstance.getSpeed((speed: string) => {
+      setVideoUploadSpeed(speed + 'Mb/s');
+    });
+    setSpeedTimer(timer);
+  };
+
+  // 停止速度监控
+  const stopSpeedMonitoring = () => {
+    if (speedTimer) {
+      clearInterval(speedTimer);
+      setSpeedTimer(null);
+    }
+    setVideoUploadSpeed('');
+  };
+
+  // 重试视频上传
+  const handleVideoRetry = async () => {
+    setVideoUploadStatus(prev => ({ ...prev, isError: false }));
+    
+    if (videoUploader && videoCreds) {
+      try {
+        setVideoUploadStatus(prev => ({ ...prev, isUploading: true }));
+        
+        // 重新获取凭证
+        const newCreds = await getSignature(2, videoCreds.upload);
+        setVideoCreds(newCreds);
+        videoUploader.updateConfig(newCreds);
+        
+        // 开始速度监控
+        startSpeedMonitoring(videoUploader);
+        
+        // 断点续传
+        await videoUploader.resumeMultipartUpload();
+        
+        setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
+        setVideoUploadSpeed('');
+        message.success('视频上传成功');
+        
+      } catch (error) {
+        console.error('重试视频上传失败:', error);
+        setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isError: true }));
+        message.error('重试失败');
+      }
+    }
+  };
+
+  // 取消视频上传
+  const cancelVideoUpload = () => {
+    if (videoUploader) {
+      videoUploader.cancelUpload();
+    }
+    stopSpeedMonitoring();
+    setVideoUploadStatus(prev => ({ ...prev, isUploading: false }));
+    message.info('已取消视频上传');
+  };
+
+  // 发布视频
+  const publishVideo = async () => {
+    if (!videoUploadStatus.isUploaded) {
+      message.warning('请等待视频上传完成');
+      return;
+    }
+
+    try {
+			const formData = form.getFieldsValue();
+			
+      const publishData = {
+        ...formData,
+        videoPath: videoCreds?.fileName || videoUrl,
+        coverPath: coverFileList.length > 0 ? coverFileList[0].url : '',
+        fileExtensions: 'mp4', // 可以根据文件类型动态设置
+			};
+
+      // 这里需要根据实际API接口调整
+      const response = await http.post<any>('/contentPlatform/video/publish', publishData);
+      
+      if (response.data.code === 0) {
+        message.success('发布成功');
+        onOk?.(response.data.data);
+        onClose();
+        resetStates();
+      } else {
+        message.error(response.data.msg || '发布失败');
+      }
+    } catch (error) {
+      console.error('发布失败:', error);
+      message.error('发布失败,请重试');
+    }
+  };
+
+  // 视频文件上传前处理
+  const beforeVideoUpload = (file: File & { localUrl?: string }) => {
+    // 验证文件类型
+    const isVideo = file.type.startsWith('video/');
+    if (!isVideo) {
+      message.error('只能上传视频文件!');
+      return false;
+    }
+
+    // 验证文件大小 (例如:限制500MB)
+    const isLt500M = file.size / 1024 / 1024 < 500;
+    if (!isLt500M) {
+      message.error('视频大小不能超过500MB!');
+      return false;
+		}
+		
+		file.localUrl = URL.createObjectURL(file);
+
+    setVideoFile(file);
+    setVideoUploadProgress(0);
+    setVideoUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+    
+    return false; // 阻止自动上传
+  };
+
+
+  // 删除视频文件
+  const handleVideoRemove = () => {
+    setVideoFile(null);
+    setVideoUploadProgress(0);
+    setVideoUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+    stopSpeedMonitoring();
+    if (videoUploader) {
+      videoUploader.cancelUpload();
+    }
+  };
+
+  // 删除封面文件
+  const handleCoverRemove = () => {
+    setCoverFileList([]);
+    setCoverUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+  };
+
+  // 获取视频进度文本
+  const getVideoProgressText = () => {
+    if (videoUploadStatus.isError) {
+      return '上传失败,点击重试重新上传';
+    } else if (!videoUploadStatus.isUploading && videoUploadStatus.isUploaded) {
+      return '上传完成';
+    } else if (!videoUploadStatus.isUploading && !videoUploadStatus.isUploaded) {
+      return '等待中...';
+    } else {
+      return '';
+    }
+  };
+
+  // 获取封面进度文本
+  const getCoverProgressText = () => {
+    if (coverUploadStatus.isError) {
+      return '上传失败,请重新选择文件';
+    } else if (!coverUploadStatus.isUploading && coverUploadStatus.isUploaded) {
+      return '上传完成';
+    } else if (coverFileList.length > 0 && coverFileList[0].status === 'uploading') {
+      return '上传中...';
+    } else {
+      return '';
+    }
+  };
+
+
+  return (
+    <>
+    <Modal 
+      open={visible} 
+      onCancel={() => {
+        onClose();
+        resetStates();
+      }} 
+      footer={null}
+      width={800}
+      title="上传视频"
+      destroyOnHidden
+    >
+      <div className={styles['upload-video-modal']}>
+        {/* 视频上传区域:上传成功后隐藏选择模块 */}
+        {!videoFile && (
+          <div className={styles['upload-section']}>
+            <h4>视频文件</h4>
+            <Upload
+              fileList={[]}
+              beforeUpload={beforeVideoUpload}
+              onRemove={handleVideoRemove}
+              accept="video/*"
+              maxCount={1}
+              showUploadList={false}
+            >
+              <Button icon={<UploadOutlined />} disabled={videoUploadStatus.isUploading}>
+                选择视频文件
+              </Button>
+            </Upload>
+          </div>
+        )}
+
+        {/* 视频上传进度区域 */}
+        {videoFile && (
+          <div className={styles['upload-progress-section']}>
+            <div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
+              <div 
+                style={{ width: 104, height: 104, background: '#fafafa', border: '1px dashed #d9d9d9', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', position: 'relative' }}
+                onMouseEnter={() => setIsVideoHovering(true)}
+                onMouseLeave={() => setIsVideoHovering(false)}
+              >
+                <video src={videoFile.localUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} muted />
+                {!videoUploadStatus.isUploading && isVideoHovering && (
+                  <div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.35)' }}>
+                    <div style={{ position: 'absolute', left: 0, right: 0, bottom: 0, display: 'flex', justifyContent: 'space-between', padding: 8 }}>
+                      <Button size="small" type="primary" onClick={() => setVideoPreviewOpen(true)}>
+                        预览
+                      </Button>
+                      <Button size="small" danger onClick={handleVideoRemove}>
+                        删除
+                      </Button>
+                    </div>
+                  </div>
+                )}
+              </div>
+
+              <div style={{ flex: 1 }}>
+                <div className={styles['progress-info']}>
+                  <span className={styles['file-name']}>{videoFile.name}</span>
+                  {videoUploadSpeed && <span className={styles['upload-speed']}>{videoUploadSpeed}</span>}
+                </div>
+                <Progress 
+                  percent={videoUploadProgress} 
+                  status={videoUploadStatus.isError ? 'exception' : 'active'}
+                  strokeColor={videoUploadStatus.isError ? '#F2584F' : '#FF4383'}
+                />
+                <div className={styles['progress-text']}>{getVideoProgressText()}</div>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* 封面上传区域 */}
+        <div className={styles['upload-section']}>
+          <h4>封面图片</h4>
+          <Upload
+            action={adFileUpload}
+            headers={{
+              token: getAccessToken()
+            }}
+            accept="image/*"
+            listType="picture-card"
+            beforeUpload={checkCoverFile}
+            onChange={handleCoverUploadChange}
+            fileList={coverFileList}
+            showUploadList={{ showPreviewIcon: false }}
+            maxCount={1}
+            data={{ fileType: 'PICTURE' }}
+            onRemove={handleCoverRemove}
+          >
+            {coverFileList.length >= 1 ? null : (
+              <button style={{ border: 0, background: 'none' }} type="button">
+                <PlusOutlined />
+                <div style={{ marginTop: 8 }}>
+                  {coverUploadStatus.isUploaded ? '已选择封面' : '上传封面'}
+                </div>
+              </button>
+            )}
+          </Upload>
+          
+          {getCoverProgressText() && (
+            <div className={styles['progress-text']}>{getCoverProgressText()}</div>
+          )}
+        </div>
+
+        {/* 视频信息编辑区域 */}
+        <div className={styles['video-info-section']}>
+          <Form form={form} layout="vertical">
+            <Form.Item
+              label="视频标题"
+              name="title"
+              rules={[{ required: true, message: '请输入视频标题' }]}
+            >
+              <Input placeholder="请输入视频标题" />
+            </Form.Item>
+          </Form>
+        </div>
+
+        {/* 操作按钮区域 */}
+        <div className={styles['action-section']}>
+          <div className={styles['right-actions']}>
+            <Space>
+              {/* 视频上传操作 */}
+              {videoFile && (
+                <>
+                  {videoUploadStatus.isUploading && (
+                    <Button onClick={cancelVideoUpload}>
+                      取消视频上传
+                    </Button>
+                  )}
+                  
+                  {videoUploadStatus.isError && (
+                    <Button 
+                      type="primary" 
+                      danger
+                      icon={<ReloadOutlined />}
+                      onClick={handleVideoRetry}
+                      className={styles['retry-btn']}
+                    >
+                      重试视频
+                    </Button>
+                  )}
+
+                  {!videoUploadStatus.isUploading && videoUploadStatus.isUploaded && (
+                    <Button 
+                      danger
+                      onClick={handleVideoRemove}
+                    >
+                      删除已上传视频
+                    </Button>
+                  )}
+                </>
+              )}
+
+              {/* 发布按钮 */}
+              {videoUploadStatus.isUploaded && (
+                <Button 
+                  type="primary" 
+                  onClick={publishVideo}
+                  loading={isLoading}
+                >
+                  发布视频
+                </Button>
+              )}
+            </Space>
+          </div>
+        </div>
+      </div>
+    </Modal>
+    {/* 视频预览弹窗 */}
+    <Modal
+      open={videoPreviewOpen}
+      onCancel={() => setVideoPreviewOpen(false)}
+      footer={null}
+      width={720}
+      title="预览视频"
+      destroyOnHidden
+    >
+      <video src={videoFile?.localUrl} style={{ height: '100%', margin: '0 auto' }} controls />
+    </Modal>
+    </>
+  );
+};
+
+export default UploadVideoModal;

+ 5 - 0
src/views/publishContent/videos/index.module.css

@@ -0,0 +1,5 @@
+.antTable {
+	:global(.ant-table-title) {
+		padding: 0;
+	}
+}

+ 109 - 0
src/views/publishContent/videos/index.tsx

@@ -0,0 +1,109 @@
+import { Button, Input, Select, Table, Spin } from 'antd';
+import React, { useEffect, useState } from 'react';
+import styles from './index.module.css';
+import UploadVideoModal from './components/uploadVideoModal';
+import { enumToOptions } from '@src/utils/helper';
+// Define a type for the expected API response (adjust if needed based on actual API)
+const TableHeight = window.innerHeight - 380;
+
+enum AuditStatus {
+	审核中 = '审核中',
+	审核通过 = '审核通过',
+	审核拒绝 = '审核拒绝',
+}
+
+const MyVideos: React.FC = () => {
+	const [isLoading, setIsLoading] = useState(false);
+	const [auditStatus, setAuditStatus] = useState<string>();
+	const [videoTitle, setVideoTitle] = useState<string>();
+	const [tableData, setTableData] = useState<any[]>([]);
+	const [totalSize, setTotalSize] = useState(0);
+	const [pageNum, setPageNum] = useState(1);
+	const [pageSize, setPageSize] = useState(10);
+
+	const [isShowUploadVideoModal, setIsShowUploadVideoModal] = useState(false);
+	const [isAddPlanLoading, setIsAddPlanLoading] = useState(false);
+
+	const getTableData = async (pageNum?: number, pageSize?: number) => {
+		setIsLoading(true);
+		
+	}
+
+	const columns = [
+		{
+			title: '视频标题',
+			dataIndex: 'title',
+			key: 'title',
+		},
+	]
+
+	const handleUploadVideo = () => {
+		setIsShowUploadVideoModal(true);
+	}
+
+	return (
+		<Spin spinning={isLoading}>
+			<div className="rounded-lg">
+				<div className="flex justify-between items-center mb-3">
+					<div className="text-lg font-medium">上传内容管理</div>
+					<Button type="primary" onClick={handleUploadVideo}>+ 上传视频</Button>
+				</div>
+				{/* 搜索区域 */}
+				<div className="flex flex-wrap gap-4 mb-3">
+
+					<div className="flex items-center gap-2">
+						<span className="text-gray-600">审核状态:</span>
+						<Select
+							placeholder="选择审核状态"
+							style={{ width: 150 }}
+							value={auditStatus}
+							onChange={setAuditStatus}
+							options={enumToOptions(AuditStatus)}
+							allowClear
+						/>
+					</div>
+
+					<div className="flex items-center gap-2">
+						<span className="text-gray-600">视频标题:</span>
+						<Input
+							placeholder="搜索视频标题"
+							style={{ width: 200 }}
+							value={videoTitle}
+							allowClear
+							onChange={e => setVideoTitle(e.target.value)}
+						/>
+					</div>
+
+					<Button type="primary" className="ml-2" onClick={() => getTableData(1)}>搜索</Button>
+				</div>
+				
+				<Table
+					rowKey={(record) => record.id}
+					className={styles.antTable}
+					columns={columns}
+					dataSource={tableData}
+					scroll={{ x: 'max-content', y: TableHeight }}
+					pagination={{
+						current: pageNum,
+						total: totalSize,
+						pageSize: pageSize,
+						showTotal: (total) => `共 ${total} 条`,
+						onChange: (page, pageSize) => {
+							getTableData(page, pageSize);
+						}
+					}}
+				/>
+				<UploadVideoModal
+					visible={isShowUploadVideoModal}
+					onClose={() => {
+						setIsShowUploadVideoModal(false);
+					}}
+					onOk={handleUploadVideo}
+					isLoading={isAddPlanLoading}
+				/>
+			</div>
+		</Spin>
+	);
+};
+
+export default MyVideos;

+ 2 - 2
src/views/publishContent/weCom/components/videoPlayModal/index.tsx

@@ -14,7 +14,7 @@ const VideoPlayModal: React.FC<VideoPlayModalProps> = ({ visible, onClose, title
       open={visible}
       onCancel={onClose}
       footer={null}
-			destroyOnClose
+			destroyOnHidden
 			title={title}
     >
       <video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={videoUrl}>
@@ -24,4 +24,4 @@ const VideoPlayModal: React.FC<VideoPlayModalProps> = ({ visible, onClose, title
   );
 };
 
-export default VideoPlayModal;
+export default VideoPlayModal;

+ 40 - 0
src/views/setting/setting.router.tsx

@@ -0,0 +1,40 @@
+import Icon, { SettingOutlined } from '@ant-design/icons'
+import { AdminRouterItem } from "../../router/index.tsx";
+import React, { Suspense } from 'react';
+import LogoIcon from "@src/assets/images/login/logo.svg?react";
+
+// Lazy load components
+const Setting = React.lazy(() => import('./setting.tsx'));
+
+// Loading fallback component
+// eslint-disable-next-line react-refresh/only-export-components
+const LazyLoadingFallback = () => (
+  <div className="flex items-center justify-center flex-col h-[50vh]">
+		<Icon component={LogoIcon} className="text-[50px] opacity-50" />
+		<div className="text-gray-500 text-xl">加载中</div>
+  </div>
+);
+
+// Wrapper component with Suspense
+// eslint-disable-next-line react-refresh/only-export-components
+const LazyComponent = ({ Component }: { Component: React.ComponentType<any> }) => (
+  <Suspense fallback={<LazyLoadingFallback />}>
+    <Component />
+  </Suspense>
+);
+
+const demoRoutes: AdminRouterItem[] = [
+  {
+		path: 'setting',
+		element: <LazyComponent Component={Setting} />,
+		sortKey: 5,
+		meta: {
+			label: "设置",
+			title: "设置",
+			key: "/setting",
+			icon: <SettingOutlined />,
+		},
+	}
+]
+
+export default demoRoutes

+ 55 - 0
src/views/setting/setting.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+import wxLogin from './wxLogin'
+
+const CONFIG = {
+	test: {
+		appid: 'wx853a8d12eea0e682',
+		url: 'https://piaoquantv.yishihui.com'
+	},
+	prod: {
+		appid: 'wx73a6cb4d85be594f',
+		url: 'https://www.piaoquantv.com'
+	}
+}
+
+const Setting = () => {
+
+	useEffect(() => {
+		// 获取链接参数 code
+		const code = new URLSearchParams(window.location.search).get('code')
+		if (code) {
+			// 获取用户信息
+			getPiaoQuanUserInfo(code)
+		} else { 
+			renderQrcode()
+		}
+	}, [])
+
+	const getPiaoQuanUserInfo = async (code: string) => {
+		console.log(code)
+	}
+
+	const renderQrcode = () => {
+		const env = window.location.host.includes('content.piaoquantv.com') ? 'prod' : 'test'
+		wxLogin({
+			id: 'code',
+			appid: CONFIG[env].appid,
+			scope: 'snsapi_login',
+			redirect_uri: encodeURIComponent(CONFIG[env].url + '?jumpTo=contentCooper'),
+		})
+	}
+
+  return (
+		<div className='w-full h-full'>
+			<div className='px-6 py-1 flex flex-row justify-between items-center border-b border-gray-300'>
+				<div className='text-2xl font-bold'>视频上传归属用户</div>
+			</div>
+			<div className='px-4 py-2 max-h-[calc(100vh-200px)] h-[calc(100vh-200px)] overflow-y-auto'>
+				<div id='code'></div>
+			</div>
+			
+		</div>
+  )
+}
+
+export default Setting

+ 51 - 0
src/views/setting/wxLogin.ts

@@ -0,0 +1,51 @@
+interface WxLoginOptions {
+  id: string;
+  appid: string;
+  scope: string;
+  redirect_uri: string;
+  state?: string;
+  self_redirect?: boolean;
+  styletype?: string;
+  sizetype?: string;
+  bgcolor?: string;
+  rst?: string;
+  style?: string;
+  href?: string;
+}
+
+export default function wxLogin(a: WxLoginOptions): void {
+  let c = 'default';
+  if (a.self_redirect === true) {
+    c = 'true';
+  } else if (a.self_redirect === false) {
+    c = 'false';
+  }
+
+  const d: HTMLIFrameElement = document.createElement('iframe');
+
+  let e = 'https://open.weixin.qq.com/connect/qrconnect?appid=' + a.appid +
+      '&scope=' + a.scope +
+      '&redirect_uri=' + a.redirect_uri +
+      '&state=' + (a.state || '') +
+      '&login_type=jssdk&self_redirect=' + c +
+      '&styletype=' + (a.styletype || '') +
+      '&sizetype=' + (a.sizetype || '') +
+      '&bgcolor=' + (a.bgcolor || '') +
+      '&rst=' + (a.rst || '');
+
+  e += a.style ? '&style=' + a.style : '';
+  e += a.href ? '&href=' + a.href : '';
+
+  d.src = e;
+  d.setAttribute('sandbox', 'allow-scripts allow-top-navigation');
+  d.frameBorder = '0';
+  d.setAttribute('allowTransparency', 'true');
+  d.setAttribute('scrolling', 'no');
+  d.width = '300px';
+  d.height = '400px';
+
+  const f = document.getElementById(a.id);
+  if (!f) return;
+  f.innerHTML = '';
+  f.appendChild(d);
+}

+ 29 - 0
yarn.lock

@@ -1825,6 +1825,11 @@
   resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.5.tgz"
   integrity sha512-MBIOHVZqVqgfro1euRDWX7OO0fBVUUMrN6Pwm8LQsz8cWhEpihlvR70ENj3f40j58TNxZaWv2ndSkInykNBBJw==
 
+"@types/lodash@^4.17.20":
+  version "4.17.20"
+  resolved "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz#1ca77361d7363432d29f5e55950d9ec1e1c6ea93"
+  integrity sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==
+
 "@types/node@^22.14.0":
   version "22.14.0"
   resolved "https://registry.npmmirror.com/@types/node/-/node-22.14.0.tgz"
@@ -1857,6 +1862,13 @@
   resolved "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz"
   integrity sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==
 
+"@types/xml-js@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.npmmirror.com/@types/xml-js/-/xml-js-1.0.0.tgz#f0535aea1cc126fb34fc0d0a278ca59997c899ee"
+  integrity sha512-tRJYQN/uAD8Br9K+pqqzJNd/htIxQaFy6ppfNEWbwsAoWRK3oAxzROCGA39GHT+E3BHLyuBnSB7XKsnJ0s4w2g==
+  dependencies:
+    xml-js "*"
+
 "@typescript-eslint/eslint-plugin@8.29.1":
   version "8.29.1"
   resolved "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.29.1.tgz#593639d9bb5239b2d877d65757b7e2c9100a2e84"
@@ -2382,6 +2394,11 @@ cross-spawn@^7.0.2:
     shebang-command "^2.0.0"
     which "^2.0.1"
 
+crypto-js@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
+  integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
+
 css-blank-pseudo@^7.0.1:
   version "7.0.1"
   resolved "https://registry.npmmirror.com/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz#32020bff20a209a53ad71b8675852b49e8d57e46"
@@ -4985,6 +5002,11 @@ safe-regex-test@^1.0.3:
   resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+sax@^1.2.4:
+  version "1.4.1"
+  resolved "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"
+  integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==
+
 scheduler@^0.20.2:
   version "0.20.2"
   resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz"
@@ -5527,6 +5549,13 @@ wrappy@1:
   resolved "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz"
   integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
 
+xml-js@*, xml-js@^1.6.11:
+  version "1.6.11"
+  resolved "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9"
+  integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==
+  dependencies:
+    sax "^1.2.4"
+
 yallist@^3.0.2:
   version "3.1.1"
   resolved "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"