#36 上传视频

已合併
nieyuge 1 月之前 將 24 次代碼提交從 Web/feature_selfUploadVideo合併至 Web/master
共有 29 個文件被更改,包括 2478 次插入43 次删除
  1. 5 0
      package.json
  2. 9 0
      src/http/api.ts
  3. 105 0
      src/utils/OSSSDK/api/index.ts
  4. 58 0
      src/utils/OSSSDK/api/methodList.ts
  5. 7 0
      src/utils/OSSSDK/errorList.ts
  6. 47 0
      src/utils/OSSSDK/fileOprs.ts
  7. 347 0
      src/utils/OSSSDK/index.ts
  8. 16 0
      src/utils/OSSSDK/utils/InterceptArr.ts
  9. 80 0
      src/utils/OSSSDK/utils/signUtils.ts
  10. 74 0
      src/utils/OSSSDK/utils/tools.ts
  11. 7 0
      src/utils/OSSSDK/utils/validator.ts
  12. 13 2
      src/views/publishContent/publishContent.router.tsx
  13. 150 0
      src/views/publishContent/videos/components/uploadVideoModal/README.md
  14. 269 0
      src/views/publishContent/videos/components/uploadVideoModal/index.module.css
  15. 632 0
      src/views/publishContent/videos/components/uploadVideoModal/index.tsx
  16. 5 0
      src/views/publishContent/videos/index.module.css
  17. 273 0
      src/views/publishContent/videos/index.tsx
  18. 4 0
      src/views/publishContent/videos/type.ts
  19. 2 2
      src/views/publishContent/weCom/components/videoPlayModal/index.tsx
  20. 33 7
      src/views/publishContent/weCom/components/videoSelectModal/index.tsx
  21. 22 11
      src/views/publishContent/weGZH/components/publishPlanModal/index.tsx
  22. 1 0
      src/views/publishContent/weGZH/components/types.ts
  23. 47 10
      src/views/publishContent/weGZH/components/videoSelectModal/index.tsx
  24. 24 11
      src/views/publishContent/weGZH/index.tsx
  25. 40 0
      src/views/setting/setting.router.tsx
  26. 122 0
      src/views/setting/setting.tsx
  27. 5 0
      src/views/setting/type.ts
  28. 52 0
      src/views/setting/wxLogin.ts
  29. 29 0
      yarn.lock

+ 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",

+ 9 - 0
src/http/api.ts

@@ -35,6 +35,7 @@ export const deleteQwPlanApi = `${import.meta.env.VITE_API_URL}/contentPlatform/
 export const getVideoContentCategoryListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentCategoryList`
 export const getVideoContentCoverFrameListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentCoverFrameList`
 export const getVideoContentListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/videoContentList`
+export const getUploadVideoContentListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/upload/videoContentList`
 export const getShareQrPic = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/qw/getSharePic`
 export const getShareQrLink = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/getShareUrlLink`
 
@@ -53,4 +54,12 @@ 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 getTempStsToken = `${import.meta.env.VITE_API_URL}/file/getTempStsToken`
+export const uploadContentList = `${import.meta.env.VITE_API_URL}/contentPlatform/uploadContent/list`
+export const uploadPublishVideo = `${import.meta.env.VITE_API_URL}/contentPlatform/uploadContent/publishVideo`
+export const uploadDeleteVideo = `${import.meta.env.VITE_API_URL}/contentPlatform/uploadContent/deleteVideo`
 
+// 设置绑定微信用户
+export const getBindPQUserInfo = `${import.meta.env.VITE_API_URL}/contentPlatform/setting/getBindPQUserInfo`
+export const bindPQUser = `${import.meta.env.VITE_API_URL}/contentPlatform/setting/webLogin`

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

@@ -0,0 +1,105 @@
+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) => {
+            if (params) {
+                (myConfig as any).params = params;
+            }
+            instance.get(url, 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;
+    }
+}
+
+

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

@@ -0,0 +1,347 @@
+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((_, 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((_, 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(void 0);
+                        }).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) => {
+                if (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();
+    }
+};
+
+

+ 13 - 2
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
@@ -40,7 +41,7 @@ const demoRoutes: AdminRouterItem[] = [
 		},
 		children: [
 			{
-				path: 'wegzh',
+				path: 'wegzh/:code?',
 				element: <LazyComponent Component={WeGZH} />,
 				meta: {
 					label: "公众号",
@@ -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);
+}

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

@@ -0,0 +1,632 @@
+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, uploadPublishVideo } from "../../../../../http/api";
+
+interface UploadVideoModalProps {
+  visible: boolean;
+  onClose: () => void;
+  onOk?: (videoInfo: any) => void;
+  isLoading?: boolean;
+  videoInfo?: any;
+}
+
+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, 
+  videoInfo
+}) => {
+  // 视频文件状态
+  const [videoFile, setVideoFile] = useState<File & { localUrl?: string } | null>(null);
+  const [videoUploadProgress, setVideoUploadProgress] = useState(0);
+  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 [isEditMode, setIsEditMode] = useState(false);
+
+  // 视频预览
+  const [videoPreviewOpen, setVideoPreviewOpen] = useState(false);
+  const [isVideoHovering, setIsVideoHovering] = useState(false);
+  // 发布视频loading状态
+  const [publishLoading, setPublishLoading] = useState(false);
+
+  // 重置状态
+  const resetStates = useCallback(() => {
+    // 重置视频文件状态
+    setVideoFile(null);
+    setVideoUploadProgress(0);
+    setVideoUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+
+    // 重置封面文件状态
+    setCoverFileList([]);
+    setCoverUploadStatus({
+      isUploading: false,
+      isUploaded: false,
+      isError: false
+    });
+
+    // 重置OSS状态
+    setVideoCreds(null);
+    setVideoUploader(null);
+    setVideoUrl('');
+    setIsEditMode(false);
+
+    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]);
+
+	// 当videoInfo发生变化时,初始化表单和状态
+	useEffect(() => {
+		if (visible && videoInfo) {
+			setIsEditMode(true);
+			setVideoUploadProgress(100);
+			// 填充表单数据
+			form.setFieldsValue({
+				title: videoInfo.title,
+			});
+			// 设置视频URL和状态
+			setVideoUrl(videoInfo.videoUrl);
+			setVideoUploadStatus({
+				isUploading: false,
+				isUploaded: true,
+				isError: false
+			});
+			// 设置封面文件
+			if (videoInfo.coverUrl) {
+				setCoverFileList([{
+					uid: '-1',
+					name: videoInfo.coverName || 'cover.jpg',
+					status: 'done',
+					url: videoInfo.coverUrl,
+					response: {
+						data: {
+							fileUrl: videoInfo.coverUrl
+						}
+					}
+				}]);
+				setCoverUploadStatus({
+					isUploading: false,
+					isUploaded: true,
+					isError: false
+				});
+			}
+			// 对于编辑模式,我们需要创建一个模拟的videoFile对象来显示视频预览
+			if (videoInfo.videoUrl) {
+				// 提取文件名作为显示名称
+				const fileName = videoInfo.videoName || videoInfo.videoUrl.split('/').pop() || 'video.mp4';
+				setVideoFile({
+					localUrl: videoInfo.videoUrl,
+					name: fileName,
+					type: 'video/mp4',
+					size: 0 // 实际项目中可能需要从服务器获取文件大小
+				} as File & { localUrl?: string });
+			}
+		}
+	}, [visible, videoInfo, form]);
+
+  // 获取上传凭证
+  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);
+
+      // 开始上传
+      await uploaderInstance.multipartUpload();
+
+      // 上传完成
+      setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
+			if (!isEditMode) {
+				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 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);
+        
+        // 断点续传
+        await videoUploader.resumeMultipartUpload();
+        
+        setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isUploaded: true }));
+        message.success('视频上传成功');
+        
+      } catch (error) {
+        console.error('重试视频上传失败:', error);
+        setVideoUploadStatus(prev => ({ ...prev, isUploading: false, isError: true }));
+        message.error('重试失败');
+      }
+    }
+  };
+
+  // 取消视频上传
+  const cancelVideoUpload = () => {
+    if (videoUploader) {
+      videoUploader.cancelUpload();
+    }
+    setVideoUploadStatus(prev => ({ ...prev, isUploading: false }));
+    message.info('已取消视频上传');
+  };
+
+  // 发布视频
+  const publishVideo = async () => {
+    if (!videoUploadStatus.isUploaded) {
+      message.warning('请等待视频上传完成');
+      return;
+    }
+
+    // 表单校验
+    try {
+      await form.validateFields();
+    } catch (error) {
+      message.warning('请填写完整的视频信息');
+      return;
+    }
+
+    // 校验封面是否上传
+    if (coverFileList.length === 0 || !coverUploadStatus.isUploaded) {
+      message.warning('请上传视频封面');
+      return;
+    }
+
+    try {
+      // 设置loading状态为true
+      setPublishLoading(true);
+      
+      const formData = form.getFieldsValue();
+
+      const publishData = {
+        ...formData,
+        videoUrl: isEditMode ? videoUrl : (videoCreds?.fileName || videoUrl),
+        coverUrl: coverFileList.length > 0 ? coverFileList[0].response.data.fileUrl : '',
+        fileExtensions: 'mp4', // 可以根据文件类型动态设置
+        ...(isEditMode && videoInfo?.videoId && { videoId: videoInfo.videoId })
+      };
+
+      // 这里需要根据实际API接口调整
+      const response = await http.post<any>(uploadPublishVideo, publishData);
+      if (response.code === 0) {
+        message.success('发布成功');
+        onOk?.(response.data);
+        onClose();
+        resetStates();
+      } else {
+        message.error(response.msg || '发布失败');
+      }
+    } catch (error) {
+      console.error('发布失败:', error);
+      message.error('发布失败,请重试');
+    } finally {
+      // 请求结束后(无论成功或失败),设置loading状态为false
+      setPublishLoading(false);
+    }
+  };
+
+  // 视频文件上传前处理
+  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
+    });
+    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={isEditMode ? "修改视频" : "上传视频"}
+      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: 124, 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: 'center', padding: 8, gap: 3 }}>
+                      <Button size="small" type="primary" onClick={() => setVideoPreviewOpen(true)}>
+                        预览
+                      </Button>
+                      {!isEditMode &&
+                        <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>
+                </div>
+                <Progress 
+                  percent={videoUploadProgress} 
+                  status={videoUploadStatus.isError ? 'exception' : 'active'}
+                  strokeColor={videoUploadStatus.isError ? '#F2584F' : '#FF4383'}
+                />
+                {!isEditMode ? <div className={styles['progress-text']}>{getVideoProgressText()}</div> : null}
+              </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>
+          
+          {!isEditMode && 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 && !isEditMode && (
+                <>
+                  {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={publishLoading}
+                >
+                  发布视频
+                </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;
+	}
+}

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

@@ -0,0 +1,273 @@
+import { Button, Input, Select, Table, Spin, Popconfirm, message, Popover, Modal } from 'antd';
+import React, { useState, useEffect } from 'react';
+import styles from './index.module.css';
+import http from '@src/http/index';
+import { uploadContentList, uploadDeleteVideo } from "@src/http/api";
+import UploadVideoModal from './components/uploadVideoModal';
+import { enumToOptions } from '@src/utils/helper';
+import { UploadContentResponse } from './type';
+import dayjs from 'dayjs';
+import VideoPlayModal from '../weCom/components/videoPlayModal';
+import { UserInfo } from '../../setting/type';
+// Define a type for the expected API response (adjust if needed based on actual API)
+const TableHeight = window.innerHeight - 380;
+
+enum AuditStatus {
+	待审核 = 0,
+	审核中 = 1,
+	审核通过 = 2,
+	审核不通过 = 3,
+}
+
+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 [isVideoPlayModalVisible, setIsVideoPlayModalVisible] = useState<boolean>(false);
+	const [isShowUploadVideoModal, setIsShowUploadVideoModal] = useState(false);
+	const [playVideoUrl, setPlayVideoUrl] = useState('');
+	const [playVideoTitle, setPlayVideoTitle] = useState('');
+	const [isAddPlanLoading] = useState(false);
+	const [editingVideo, setEditingVideo] = useState<any>(null);
+	const [pqUserInfo, setPqUserInfo] = useState<UserInfo | null>(null);
+	const [showLoginModal, setShowLoginModal] = useState(false);
+	
+	// 页面加载时调用getTableData和检查用户登录状态
+	useEffect(() => {
+		getTableData();
+		checkUserLoginStatus();
+	}, []);
+
+	// 检查用户登录状态
+	const checkUserLoginStatus = () => {
+		const userInfoStr = localStorage.getItem('pq_userInfo');
+		if (userInfoStr) {
+			try {
+				const userInfo = JSON.parse(userInfoStr) as UserInfo;
+				setPqUserInfo(userInfo);
+			} catch (error) {
+				console.error('解析用户信息失败', error);
+				setPqUserInfo(null);
+			}
+		} else {
+			setPqUserInfo(null);
+		}
+	};
+
+	const getTableData = async (_pageNum?: number, _pageSize?: number) => {
+		setIsLoading(true);
+		http.post<UploadContentResponse>(uploadContentList, {
+			auditStatus: auditStatus,
+			title: videoTitle,
+			pageNum: _pageNum ?? pageNum,
+			pageSize: _pageSize || pageSize,
+		}).then(res => {
+			const { code, data } = res;
+			if (code === 0) {
+				setTableData(data.objs);
+				setTotalSize(data.totalSize);
+			}
+		}).finally(() => {
+			setIsLoading(false);
+		});
+	}
+
+	const columns = [
+		{
+			title: '视频ID',
+			dataIndex: 'videoId',
+		},
+		{
+			title: '视频封面',
+			dataIndex: 'cover',
+			render: (_, record) => {
+				return <img src={record.coverUrl} alt="cover" className="w-20 h-10 object-cover" />
+			}
+		},
+		{
+			title: '视频标题',
+			dataIndex: 'title',
+		},
+		{
+			title: '视频播放地址',
+			render: (_, record) => {
+				return <span className="text-blue-500 underline cursor-pointer" onClick={playVideo(record)}>播放视频</span>
+			}
+		},
+		{
+			title: '上传时间',
+			render: (_, record) => {
+				return <span>{record.createTimeStamp ? dayjs(record.createTimeStamp).format('YYYY-MM-DD HH:mm:ss') : ''}</span>
+			}
+		},
+		{
+			title: '审核状态',
+			render: (_, record) => {
+				return <>
+					{record.auditStatus === 3 ? <><span className="text-red-500">审核不通过</span> <span className="text-blue-500 underline cursor-pointer"><Popover placement="top" content={record.auditReason}>(查看原因)</Popover></span></> : record.auditStatus === 2 ? <span className="text-green-500">审核通过</span> : record.auditStatus === 1 ? <span className="text-black-500">审核中</span> : <span className="text-gray-500">待审核</span>}
+				</>
+			}
+		},
+		{
+			title: '操作',
+			render: (_, record) => {
+				return <>
+					{record.auditStatus === 3 ? <><span className="text-blue-500 underline cursor-pointer" onClick={() => { handleEditVideo(record) }}>修改视频</span>&emsp;</> : record.auditStatus === 2 ? <><span className="text-blue-500 underline cursor-pointer" onClick={() => { uploadPublishVideo(record) }}>创建发布</span>&emsp;</> : ''}
+					<Popconfirm
+						title="确定删除该视频吗?"
+						okText="确定"
+						cancelText="取消"
+						onConfirm={() => deleteVideo(record)}>
+						<span className="text-blue-500 underline cursor-pointer">删除</span>
+					</Popconfirm>
+				</>
+			}
+		}
+	]
+
+	const handleUploadVideo = () => {
+		// 检查用户是否已登录
+		if (!pqUserInfo) {
+			setShowLoginModal(true);
+			return;
+		}
+		setEditingVideo(null);
+		setIsShowUploadVideoModal(true);
+		getTableData(pageNum, pageSize)
+	}
+
+	const handleEditVideo = (record: any) => {
+		// 检查用户是否已登录
+		if (!pqUserInfo) {
+			setShowLoginModal(true);
+			return;
+		}
+		setEditingVideo(record);
+		setIsShowUploadVideoModal(true);
+	}
+
+	const playVideo = (record: any) => () => {
+		setPlayVideoUrl(record.videoUrl);
+		setIsVideoPlayModalVisible(true);
+		setPlayVideoTitle(record.title);
+	}
+
+	const deleteVideo = (record: any) => {
+		http.post(uploadDeleteVideo, {
+			videoId: record.videoId,
+		}).then(res => {
+			const { code } = res;
+			if (code === 0) {
+				message.success('删除成功');
+				getTableData(pageNum, pageSize);
+			} else {
+				message.error(res.msg || '删除失败');
+			}
+		}).catch(err => {
+			message.error(err.msg || '删除失败');
+		})
+	}
+
+	const uploadPublishVideo = (record: any) => {
+		return location.href = '/publishContent/wegzh/' + record.videoId;
+	}
+
+	// 引导用户去登录
+	const goToLogin = () => {
+		setShowLoginModal(false);
+		window.location.href = '/setting';
+	}
+
+	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) => {
+							setPageNum(page);
+							setPageSize(pageSize);
+							getTableData(page, pageSize);
+						}
+					}}
+				/>
+				<UploadVideoModal
+					visible={isShowUploadVideoModal}
+					onClose={() => {
+						setIsShowUploadVideoModal(false);
+					}}
+					onOk={handleUploadVideo}
+					isLoading={isAddPlanLoading}
+					videoInfo={editingVideo}
+				/>
+				<VideoPlayModal
+					visible={isVideoPlayModalVisible}
+					onClose={() => setIsVideoPlayModalVisible(false)}
+					videoUrl={playVideoUrl}
+					title={playVideoTitle}
+				/>
+				<Modal
+					visible={showLoginModal}
+					title="请先登录"
+					onCancel={() => setShowLoginModal(false)}
+					footer={null}
+				>
+					<div className="text-center py-6">
+						<p className="mb-4">您需要先登录才能上传或编辑视频</p>
+						<Button type="primary" onClick={goToLogin}>
+							去登录
+						</Button>
+					</div>
+				</Modal>
+			</div>
+		</Spin>
+	);
+};
+
+export default MyVideos;

+ 4 - 0
src/views/publishContent/videos/type.ts

@@ -0,0 +1,4 @@
+export interface UploadContentResponse {
+  objs: any[];
+  totalSize: number;
+}

+ 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;

+ 33 - 7
src/views/publishContent/weCom/components/videoSelectModal/index.tsx

@@ -14,11 +14,16 @@ import {
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { VideoListResponse } from '@src/views/publishContent/weGZH/components/types';
 import http from '@src/http';
-import { getVideoContentListApi } from '@src/http/api';
+import { getVideoContentListApi, getUploadVideoContentListApi } from '@src/http/api';
 import { useVideoCategoryOptions } from '@src/views/publishContent/weGZH/hooks/useVideoCategoryOptions';
 import { WeComPlanType, WeVideoItem, VideoSearchPlanType } from '@src/views/publishContent/weCom/type'
 import { enumToOptions } from '@src/utils/helper';
 
+export enum VideoLibraryType {
+	平台视频库 = 0,
+	我的上传 = 1,
+}
+
 const { Text, Paragraph } = Typography;
 
 interface VideoSelectModalProps {
@@ -48,20 +53,27 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 	const [videoListAll, setVideoListAll] = useState<WeVideoItem[]>([]);
 	const [selectedVideoIds, setSelectedVideoIds] = useState<Set<number>>(new Set(initialSelectedIds));
 	const [playingVideo, setPlayingVideo] = useState<WeVideoItem | null>(null);
+	const [videoLibraryType, setVideoLibraryType] = useState<VideoLibraryType>(VideoLibraryType.平台视频库);
 	const MAX_SELECTION = 3;
 
 	const getVideoList = async (pageNum?: number, _pageSize?: number) => {
 		setLoading(true);
 		setCurrentPage(pageNum || currentPage);
 		setPageSize(_pageSize || pageSize);
-		const res = await http.post<VideoListResponse>(getVideoContentListApi, {
+
+		// 根据视频库类型选择不同的API
+		const apiUrl = videoLibraryType === VideoLibraryType.平台视频库 ? getVideoContentListApi : getUploadVideoContentListApi;
+
+		const requestParams = {
 			category,
 			title: searchTerm,
 			sort,
 			type: planType === WeComPlanType.社群 ? VideoSearchPlanType.企微社群 : VideoSearchPlanType.企微自动回复,
 			pageNum: pageNum || currentPage,
 			pageSize: _pageSize || pageSize,
-		}).catch(() => {
+		};
+
+		const res = await http.post<VideoListResponse>(apiUrl, requestParams).catch(() => {
 			message.error('获取视频列表失败');
 		}).finally(() => {
 			setLoading(false);
@@ -74,6 +86,11 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 		}
 	}
 
+	// 监听视频库类型变化,重新加载数据
+	useEffect(() => {
+		getVideoList();
+	}, [videoLibraryType]);
+
 	useEffect(() => {
 		getVideoList();
 	}, []);
@@ -151,7 +168,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 				title="内容选取"
 				open={visible}
 				onClose={onClose}
-				width={800}
+				width={900}
 				placement="right"
 				loading={loading}
 				destroyOnClose
@@ -180,7 +197,16 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 					</div>
 				}
 			>
-				<div className="flex flex-wrap gap-4 mb-6">
+				<div className="flex flex-wrap gap-2 mb-6">
+					<div className="flex items-center gap-2">
+						<span className="text-gray-600">视频来源:</span>
+						<Select
+							style={{ width: 120 }}
+							value={videoLibraryType}
+							onChange={setVideoLibraryType}
+							options={enumToOptions(VideoLibraryType)}
+						/>
+					</div>
 					<div className="flex items-center gap-2">
 						<span className="text-gray-600">排序选项:</span>
 						<Select
@@ -194,7 +220,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 						<span className="text-gray-600">品类:</span>
 						<Select
 							placeholder="选择品类"
-							style={{ width: 160 }}
+							style={{ width: 120 }}
 							value={category}
 							allowClear
 							onChange={setCategory}
@@ -205,7 +231,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ visible, onClose, o
 						<span className="text-gray-600">视频标题:</span>
 						<Input
 							placeholder="搜索视频标题"
-							style={{ width: 180 }}
+							style={{ width: 120 }}
 							value={searchTerm}
 							allowClear
 							onChange={e => setSearchTerm(e.target.value)}

+ 22 - 11
src/views/publishContent/weGZH/components/publishPlanModal/index.tsx

@@ -6,6 +6,7 @@ import EditTitleCoverModal from '../editTitleCoverModal';
 import { VideoItem } from '../types'; // Import from common types
 import { GzhPlanDataType, GzhPlanType } from '../../hooks/useGzhPlanList';
 import { useAccountOptions } from '../../hooks/useAccountOptions';
+import { VideoLibraryType } from '@src/views/publishContent/weCom/components/videoSelectModal';
 
 const { Option } = Select;
 const { Paragraph } = Typography;
@@ -18,17 +19,10 @@ interface AddPunlishPlanModalProps {
 	planType: GzhPlanType;
 	editPlanData?: GzhPlanDataType;
 	isSubmiting?: boolean;
+	videoId?: string;
 }
 
-const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({
-	visible,
-	isSubmiting,
-	onCancel,
-	onOk,
-	actionType,
-	planType,
-	editPlanData
-}) => {
+const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSubmiting, onCancel, onOk, actionType, planType, editPlanData, videoId }) => {
 	const [form] = Form.useForm();
 	const type = Form.useWatch('type', form);
 	const selectVideoType = Form.useWatch('selectVideoType', form);
@@ -36,6 +30,7 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({
 	const [isVideoSelectVisible, setIsVideoSelectVisible] = useState(false);
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null); // State for video player modal
 	const [editingVideo, setEditingVideo] = useState<VideoItem | null>(null); // State for editing modal
+	const [initialSelectedVideoId, setInitialSelectedVideoId] = useState<string | null>(null); // State for initial video selection
 	const { accountOptions, getAccountList } = useAccountOptions();
 
 	useEffect(() => {
@@ -60,6 +55,21 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({
 		getAccountList({accountType: planType});
 	}, [planType]);
 
+	// 处理videoId参数
+	useEffect(() => {
+		if (visible && videoId) {
+			setInitialSelectedVideoId(videoId);
+			setIsVideoSelectVisible(true);
+		}
+	}, [visible, videoId]);
+
+	// 重置状态
+	useEffect(() => {
+		if (!visible) {
+			setInitialSelectedVideoId(null);
+		}
+	}, [visible]);
+
 	const onTypeChange = (value: string) => {
 		form.setFieldsValue({ accountId: undefined });
 		getAccountList({ accountType: value });
@@ -308,12 +318,13 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({
 
 			{/* Video Selection Drawer */}
 			<VideoSelectModal
-				planType={type}
+				planType={planType}
 				visible={isVideoSelectVisible}
 				onClose={handleVideoSelectionCancel}
 				onOk={handleVideoSelectionOk}
 				selectedVideos={selectedVideos}
-				initialSelectedIds={selectedVideos.map(v => v.videoId)} // Pass current selection IDs
+				initialSelectedIds={initialSelectedVideoId ? [Number(initialSelectedVideoId)] : selectedVideos.map(v => v.videoId)}
+				defaultVideoLibraryType={VideoLibraryType.我的上传}
 			/>
 
 			{/* Video Player Modal */}

+ 1 - 0
src/views/publishContent/weGZH/components/types.ts

@@ -11,6 +11,7 @@ export interface VideoItem {
 	videoId: number,
 	industryFissionRate: number,
 	channelFissionRate: number,
+	videoLibraryType?: number;
 } 
 
 export interface VideoListResponse {

+ 47 - 10
src/views/publishContent/weGZH/components/videoSelectModal/index.tsx

@@ -14,9 +14,9 @@ import {
 import { CheckCircleFilled, CaretRightFilled } from '@ant-design/icons';
 import { VideoItem, VideoListResponse } from '../types';
 import http from '@src/http';
-import { getVideoContentListApi } from '@src/http/api';
+import { getVideoContentListApi, getUploadVideoContentListApi } from '@src/http/api';
 import { useVideoCategoryOptions } from '../../hooks/useVideoCategoryOptions';
-import { VideoSortType } from '@src/views/publishContent/weCom/components/videoSelectModal';
+import { VideoSortType, VideoLibraryType } from '@src/views/publishContent/weCom/components/videoSelectModal';
 import { GzhPlanType } from '../../hooks/useGzhPlanList';
 import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
 import { enumToOptions } from '@src/utils/helper';
@@ -30,9 +30,10 @@ interface VideoSelectModalProps {
 	onOk: (selectedVideos: VideoItem[]) => void;
 	initialSelectedIds?: number[];
 	selectedVideos?: VideoItem[];
+	defaultVideoLibraryType?: VideoLibraryType;
 }
 
-const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [] }) => {
+const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [], defaultVideoLibraryType }) => {
 	const { videoCategoryOptions } = useVideoCategoryOptions();
 	const [category, setCategory] = useState<string>();
 	const [sort, setSort] = useState<VideoSortType>(VideoSortType.平台推荐);
@@ -45,8 +46,16 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	const [videoListAll, setVideoListAll] = useState<VideoItem[]>([]);
 	const [selectedVideoIds, setSelectedVideoIds] = useState<Set<number>>(new Set(initialSelectedIds));
 	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
+	const [videoLibraryType, setVideoLibraryType] = useState<VideoLibraryType>(defaultVideoLibraryType || VideoLibraryType.平台视频库);
 	const MAX_SELECTION = 3;
 
+	// 当默认视频来源变化时更新状态
+	useEffect(() => {
+		if (defaultVideoLibraryType) {
+			setVideoLibraryType(defaultVideoLibraryType);
+		}
+	}, [defaultVideoLibraryType]);
+
 	const getVideoListType = (planType: GzhPlanType) => {
 		if (planType === GzhPlanType.自动回复) {
 			return VideoSearchPlanType.自动回复;
@@ -61,25 +70,38 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 		setLoading(true);
 		setCurrentPage(pageNum || currentPage);
 		setPageSize(_pageSize || pageSize);
-		const res = await http.post<VideoListResponse>(getVideoContentListApi, {
+		// 根据视频库类型选择不同的API
+		const apiUrl = videoLibraryType === VideoLibraryType.平台视频库 ? getVideoContentListApi : getUploadVideoContentListApi;
+
+		const requestParams = {
 			category,
 			title: searchTerm,
 			sort,
 			type: getVideoListType(planType),
 			pageNum: pageNum || currentPage,
 			pageSize: _pageSize || pageSize,
-		}).catch(() => {
+		};
+
+		const res = await http.post<VideoListResponse>(apiUrl, requestParams).catch(() => {
 			message.error('获取视频列表失败');
 		}).finally(() => {
 			setLoading(false);
 		});
 		if (res && res.code === 0) {
+			selectedVideos = selectedVideos.filter(item => item.videoLibraryType === videoLibraryType)
 			setVideoList([...selectedVideos, ...res.data.objs.filter(v => !selectedVideos.find(ov => ov.videoId === v.videoId))]);
 			setVideoListAll(old => [...old, ...res.data.objs.filter(v => !old.find(ov => ov.videoId === v.videoId))]);
 			setTotal(res.data.totalSize);
 		}
 	}
 
+	// 监听视频库类型变化,重新加载数据
+	useEffect(() => {
+		if (visible) {
+			getVideoList();
+		}
+	}, [videoLibraryType, visible]);
+
 	useEffect(() => {
 		if (visible) {
 			setVideoList(selectedVideos);
@@ -119,6 +141,12 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 
 	const handleOk = () => {
 		const _selectedVideos = videoListAll.filter(video => selectedVideoIds.has(video.videoId));
+		_selectedVideos.forEach(video => {
+			// 加入视频库类型
+			if (!('videoLibraryType' in video)) {
+				video.videoLibraryType = videoLibraryType
+			}
+		});
 		onOk(_selectedVideos);
 		onClose();
 	};
@@ -134,10 +162,10 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	return (
 		<>
 			<Drawer
-				title="内容选取"
+				title="选择视频"
 				open={visible}
 				onClose={onClose}
-				width={800}
+				width={900}
 				placement="right"
 				loading={loading}
 				styles={{ footer: { textAlign: 'right', padding: '10px 24px' } }}
@@ -165,7 +193,16 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 					</div>
 				}
 			>
-				<div className="flex flex-wrap gap-4 mb-6">
+				<div className="flex flex-wrap gap-2 mb-6">
+					<div className="flex items-center gap-2">
+						<span className="text-gray-600">视频来源:</span>
+						<Select
+							style={{ width: 120 }}
+							value={videoLibraryType}
+							onChange={setVideoLibraryType}
+							options={enumToOptions(VideoLibraryType)}
+						/>
+					</div>
 					<div className="flex items-center gap-2">
 						<span className="text-gray-600">排序选项:</span>
 						<Select
@@ -179,7 +216,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 						<span className="text-gray-600">品类:</span>
 						<Select
 							placeholder="选择品类"
-							style={{ width: 160 }}
+							style={{ width: 120 }}
 							value={category}
 							allowClear
 							onChange={setCategory}
@@ -190,7 +227,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 						<span className="text-gray-600">视频标题:</span>
 						<Input
 							placeholder="搜索视频标题"
-							style={{ width: 180 }}
+							style={{ width: 120 }}
 							value={searchTerm}
 							onPressEnter={handleSearch}
 							allowClear

+ 24 - 11
src/views/publishContent/weGZH/index.tsx

@@ -1,4 +1,5 @@
 import React, { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
 import { Space, Table, Button, Input, Select, DatePicker, Tabs, message, Typography, Spin, Popconfirm } from 'antd';
 import type { TableProps } from 'antd';
 import dayjs, { Dayjs } from 'dayjs';
@@ -15,6 +16,7 @@ const TableHeight = window.innerHeight - 380;
 
 const WeGZHContent: React.FC = () => {
 	const [planType, setPlanType] = useState<GzhPlanType>(GzhPlanType.自动回复);
+	const { code } = useParams<{ code?: string }>();
 	// 状态管理
 	const [selectedAccount, setSelectedAccount] = useState<string>();
 	const [videoTitle, setVideoTitle] = useState<string>('');
@@ -31,6 +33,15 @@ const WeGZHContent: React.FC = () => {
 	const [pageNum, setPageNum] = useState<number>(1);
 	const [pageSize, setPageSize] = useState<number>(10);
 	const [isShowAddPunlishDetailPlan, setIsShowAddPunlishDetailPlan] = useState<boolean>(false);
+	const [videoId, setVideoId] = useState<string | undefined>(undefined);
+
+	// 处理code参数
+	useEffect(() => {
+		if (code) {
+			setVideoId(code);
+			setIsShowAddPunlishPlan(true);
+		}
+	}, [code]);
 
 	// 表格列配置
 	const columns: TableProps<GzhPlanDataType>['columns'] = [
@@ -338,17 +349,19 @@ const WeGZHContent: React.FC = () => {
 					}}
 				/>
 				<PunlishPlanModal
-					visible={isShowAddPunlishPlan}
-					onCancel={() => {
-						setEditPlanData(undefined);
-						setIsShowAddPunlishPlan(false);
-					}}
-					onOk={handleAddPunlishPlan}
-					actionType={actionType}
-					editPlanData={editPlanData}
-					isSubmiting={isSubmiting}
-					planType={ planType}
-				/>
+			visible={isShowAddPunlishPlan}
+			onCancel={() => {
+				setEditPlanData(undefined);
+				setIsShowAddPunlishPlan(false);
+				setVideoId(undefined);
+			}}
+			onOk={handleAddPunlishPlan}
+			actionType={actionType}
+			editPlanData={editPlanData}
+			isSubmiting={isSubmiting}
+			planType={ planType}
+			videoId={videoId}
+		/>
 				<PunlishPlanDetailModal
 					visible={isShowAddPunlishDetailPlan}
 					onCancel={() => { 

+ 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/:code?',
+		element: <LazyComponent Component={Setting} />,
+		sortKey: 5,
+		meta: {
+			label: "设置",
+			title: "设置",
+			key: "/setting",
+			icon: <SettingOutlined />,
+		},
+	}
+]
+
+export default demoRoutes

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

@@ -0,0 +1,122 @@
+import { useEffect, useState } from 'react'
+import { useParams } from 'react-router-dom';
+import wxLogin from './wxLogin'
+import { Spin, Button } from 'antd';
+import { UserInfo } from './type';
+import http from '@src/http/index';
+import { getBindPQUserInfo, bindPQUser } from "@src/http/api";
+
+const CONFIG = {
+	test: {
+		appid: 'wx853a8d12eea0e682',
+		url: 'https://piaoquantv.yishihui.com'
+	},
+	prod: {
+		appid: 'wx73a6cb4d85be594f',
+		url: 'https://www.piaoquantv.com'
+	}
+}
+
+const Setting = () => {
+	const { code } = useParams<{ code?: string }>();
+	const [isLoading, setIsLoading] = useState(false);
+	const [pqUserInfo, setPqUserInfo] = useState<UserInfo>();
+
+	useEffect(() => {
+		// 设置pq登录信息
+		const pq_userInfo = localStorage.getItem('pq_userInfo');
+		if (pq_userInfo) {
+			setPqUserInfo(JSON.parse(pq_userInfo))
+		} else {
+			http.get(getBindPQUserInfo).then(res => {
+				const { code, data } = res;
+				if (code === 0 && data) {
+					setPqUserInfo(data as UserInfo)
+					localStorage.setItem('pq_userInfo', JSON.stringify(data as UserInfo))
+				} else {
+					renderQrcode()
+				}
+			})
+		}
+	}, [])
+
+	useEffect(() => {
+		// 获取用户信息
+		if (code) {
+			getPiaoQuanUserInfo(code)
+		} else {
+			renderQrcode()
+		}
+	}, [])
+
+	const getPiaoQuanUserInfo = async (code: string) => {
+		setIsLoading(true)
+		http.post(bindPQUser, {
+			code,
+			appType: 8,
+			appId: 'wx853a8d12eea0e682'
+		}, {
+			headers: {
+				'Content-Type': 'application/x-www-form-urlencoded',
+			},
+		}).then(res => {
+			const { code, data } = res;
+			if (code === 0 && data) {
+				localStorage.setItem('pq_userInfo', JSON.stringify(data as UserInfo))
+				setPqUserInfo(data as UserInfo)
+				setIsLoading(false)
+			}
+		})
+	}
+
+	const renderQrcode = () => {
+		const env = window.location.host === ('content.piaoquantv.com') ? 'prod' : 'test'
+		wxLogin({
+			id: 'code',
+			appid: CONFIG[env].appid,
+			scope: 'snsapi_login',
+			redirect_uri: encodeURIComponent(CONFIG[env].url + '?jumpTo=contentCooper'),
+		})
+	}
+
+	const postVideo = () => {
+		window.location.href = '/publishContent/videos'
+	}
+
+  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>
+			{
+				pqUserInfo || code ? ( isLoading ? <Spin spinning={isLoading} className='mt-10! ml-10!' tip='账号关联中...'></Spin> : (
+						<div className="w-[450px] m-auto mt-[50px] text-center">
+							<div className='text-xl text-black-800 font-bold'>已完成视频归属用户的绑定!</div>
+							<div className='text-black-600 mt-8'>
+								视频归属用户的微信昵称:{pqUserInfo?.nickName}
+							</div>
+							<div className='text-black-600 mt-4'>
+								视频归属用户的票圈UID:{pqUserInfo?.uid}
+							</div>
+							<div className='text-black-600 mt-10'>
+								<Button type="primary" onClick={postVideo}>去发视频</Button>
+							</div>
+						</div>
+					)
+				) : (
+					<div className='px-4 py-2 max-h-[calc(100vh-200px)] h-[calc(100vh-200px)] overflow-y-auto'>
+							<div id='code' className='m-[50px]'></div>
+							<div className='text-gray-500 w-[450px] m-auto'>
+								1、手动上传的视频需要绑定一名微信用户作为视频的所有者,请使用所有者的微信账号扫描上方二维码完成绑定。
+							</div>
+							<div className='text-gray-500 w-[450px] m-auto mt-2'>
+								2、完成一次绑定后,后续无需重复绑定。若需要更换视频所有者对应的微信账号,可解绑后重新绑定(后续支持)。
+							</div>
+					</div>
+				)	
+			}
+		</div>
+  )
+}
+
+export default Setting

+ 5 - 0
src/views/setting/type.ts

@@ -0,0 +1,5 @@
+export interface UserInfo {
+	nickName: string;
+	phoneNumber: string;
+	uid: number;
+}

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

@@ -0,0 +1,52 @@
+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';
+	d.style.margin = 'auto';
+
+  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"