Merge remote-tracking branch 'origin/feature/v1.2灵机空间-内容上传审核_rxd' into test

# Conflicts:
#	pnpm-lock.yaml
#	src/components/text-over-tips/index.vue
#	src/layouts/Basic.vue
#	src/layouts/Page.vue
#	src/main.ts
#	src/router/constants.ts
#	src/router/index.ts
#	src/router/typeings.d.ts
#	src/utils/tools.ts
This commit is contained in:
rd
2025-08-13 09:51:13 +08:00
171 changed files with 12617 additions and 1276 deletions

View File

@ -111,6 +111,235 @@ export function genRandomId() {
return `id_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 修改函数以同时获取视频时长和首帧
export function getVideoInfo(file: File): Promise<{ duration: number; firstFrame: string }> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.crossOrigin = 'anonymous'; // 避免跨域问题
video.muted = true; // 静音,避免意外播放声音
video.style.position = 'fixed'; // 确保视频元素在DOM中
video.style.top = '-1000px'; // 但不可见
document.body.appendChild(video); // 添加到DOM
let hasResolved = false;
// 先获取元数据(时长)
video.onloadedmetadata = function () {
// 视频时长
const duration = video.duration;
// 尝试将视频定位到非常小的时间点,确保有帧可捕获
if (duration > 0) {
video.currentTime = Math.min(0.1, duration / 2);
}
};
// 当视频定位完成后尝试捕获首帧
video.onseeked = function () {
if (hasResolved) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
// 清理
window.URL.revokeObjectURL(video.src);
document.body.removeChild(video);
// 返回结果
hasResolved = true;
resolve({
duration: video.duration,
firstFrame: canvas.toDataURL('image/jpeg', 0.9), // 提高质量
});
};
// 作为备选方案监听loadeddata事件
video.onloadeddata = function () {
if (hasResolved) return;
// 尝试捕获帧
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
// 检查是否捕获到有效帧(非全黑)
const imageData = ctx?.getImageData(0, 0, canvas.width, canvas.height);
if (imageData) {
let isAllBlack = true;
for (let i = 0; i < imageData.data.length; i += 4) {
if (imageData.data[i] > 10 || imageData.data[i + 1] > 10 || imageData.data[i + 2] > 10) {
isAllBlack = false;
break;
}
}
if (!isAllBlack) {
// 清理
window.URL.revokeObjectURL(video.src);
document.body.removeChild(video);
// 返回结果
hasResolved = true;
resolve({
duration: video.duration,
firstFrame: canvas.toDataURL('image/jpeg', 0.9),
});
return;
}
}
// 如果是全黑帧尝试定位到0.1秒
if (video.duration > 0) {
video.currentTime = 0.1;
}
};
// 设置视频源以触发加载
video.src = URL.createObjectURL(file);
// 设置超时,防止长时间无响应
setTimeout(() => {
if (!hasResolved) {
document.body.removeChild(video);
resolve({
duration: 0,
firstFrame: '',
});
}
}, 5000); // 5秒超时
});
}
export const formatDuration = (seconds: number) => {
const hours = Math.floor(seconds / 3600);
const remainingSecondsAfterHours = seconds % 3600;
const minutes = Math.floor(remainingSecondsAfterHours / 60);
const remainingSeconds = Math.floor(remainingSecondsAfterHours % 60);
if (hours > 0) {
if (remainingSecondsAfterHours === 0) {
return `${hours}小时`;
}
if (remainingSeconds === 0) {
return `${hours}小时${minutes}`;
}
return `${hours}小时${minutes}${remainingSeconds}`;
} else if (minutes > 0) {
if (remainingSeconds === 0) {
return `${minutes}`;
}
return `${minutes}${remainingSeconds}`;
} else {
return `${remainingSeconds}`;
}
};
export const formatUploadSpeed = (bytesPerSecond: number): string => {
if (bytesPerSecond < 1024) {
return `${bytesPerSecond.toFixed(2)} B/s`;
} else if (bytesPerSecond < 1024 * 1024) {
return `${(bytesPerSecond / 1024).toFixed(2)} KB/s`;
} else {
return `${(bytesPerSecond / (1024 * 1024)).toFixed(2)} MB/s`;
}
};
export function convertVideoUrlToCoverUrl(videoUrl: string): string {
if (!videoUrl || typeof videoUrl !== 'string') {
console.error('Invalid video URL');
return '';
}
const urlWithCovers = videoUrl.replace('/videos/', '/covers/');
const lastDotIndex = urlWithCovers.lastIndexOf('.');
if (lastDotIndex !== -1) {
return urlWithCovers.substring(0, lastDotIndex) + '.jpg';
}
return urlWithCovers + '.jpg';
}
/**
* 生成包含协议、域名和参数的完整URL
*/
export const generateFullUrl = (pathTemplate: string, params: Record<string, string | number> = {}): string => {
const protocol = window.location.protocol;
const hostname = window.location.hostname;
const port = window.location.port ? `:${window.location.port}` : '';
const baseUrl = `${protocol}//${hostname}${port}`;
let path = pathTemplate;
Object.entries(params).forEach(([key, value]) => {
path = path.replace(`:${key}`, String(value));
});
return `${baseUrl}${path}`;
};
/** 图片朝向类型 */
export type ImageOrientation = 'landscape' | 'portrait' | 'square';
/**
* 根据宽高判断图片是横图/竖图/正方形
*/
export const getImageOrientationBySize = (width: number, height: number): ImageOrientation => {
if (!width || !height) return 'square';
if (width > height) return 'landscape';
if (height > width) return 'portrait';
return 'square';
};
/**
* 加载图片自然尺寸(跨域下需服务端允许匿名访问)
*/
export const getImageNaturalSize = (url: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
// 若是跨域资源并允许匿名访问,可尝试设置
try {
img.crossOrigin = 'anonymous';
} catch (_) {}
img.src = url;
});
};
/**
* 基于图片 URL 判断图片横竖
*/
export const getImageOrientationByUrl = async (url: string): Promise<ImageOrientation> => {
const { width, height } = await getImageNaturalSize(url);
return getImageOrientationBySize(width, height);
};
export function getImageMainColor(imageUrl: string): Promise<string> {
return new Promise((resolve, reject) => {
const img = new Image();
@ -168,7 +397,7 @@ export function getImageMainColor(imageUrl: string): Promise<string> {
const avgColor = {
r: Math.round(maxGroup.sumR / maxGroup.count),
g: Math.round(maxGroup.sumG / maxGroup.count),
b: Math.round(maxGroup.sumB / maxGroup.count)
b: Math.round(maxGroup.sumB / maxGroup.count),
};
resolve(`rgb(${avgColor.r},${avgColor.g},${avgColor.b})`);
@ -198,28 +427,38 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
if (a < 128) continue;
colors.push({
r, g, b,
r,
g,
b,
count: 1,
sumR: r, sumG: g, sumB: b
sumR: r,
sumG: g,
sumB: b,
});
}
// 如果没有颜色数据,返回默认白色
if (colors.length === 0) {
return [{
count: 1,
sumR: 255, sumG: 255, sumB: 255
}];
return [
{
count: 1,
sumR: 255,
sumG: 255,
sumB: 255,
},
];
}
// 开始中位数切分
let colorGroups = [{
colors,
count: colors.length,
sumR: colors.reduce((sum, c) => sum + c.r, 0),
sumG: colors.reduce((sum, c) => sum + c.g, 0),
sumB: colors.reduce((sum, c) => sum + c.b, 0)
}];
let colorGroups = [
{
colors,
count: colors.length,
sumR: colors.reduce((sum, c) => sum + c.r, 0),
sumG: colors.reduce((sum, c) => sum + c.g, 0),
sumB: colors.reduce((sum, c) => sum + c.b, 0),
},
];
for (let i = 0; i < levels; i++) {
const newGroups = [];
@ -231,12 +470,12 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
}
// 找出颜色范围最大的通道
const rMin = Math.min(...group.colors.map(c => c.r));
const rMax = Math.max(...group.colors.map(c => c.r));
const gMin = Math.min(...group.colors.map(c => c.g));
const gMax = Math.max(...group.colors.map(c => c.g));
const bMin = Math.min(...group.colors.map(c => c.b));
const bMax = Math.max(...group.colors.map(c => c.b));
const rMin = Math.min(...group.colors.map((c) => c.r));
const rMax = Math.max(...group.colors.map((c) => c.r));
const gMin = Math.min(...group.colors.map((c) => c.g));
const gMax = Math.max(...group.colors.map((c) => c.g));
const bMin = Math.min(...group.colors.map((c) => c.b));
const bMax = Math.max(...group.colors.map((c) => c.b));
const rRange = rMax - rMin;
const gRange = gMax - gMin;
@ -250,7 +489,9 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
}
// 按最大范围通道排序
group.colors.sort((a, b) => a[sortChannel] - b[sortChannel]);
type ColorChannel = 'r' | 'g' | 'b';
const safeSortChannel = sortChannel as ColorChannel;
group.colors.sort((a, b) => a[safeSortChannel] - b[safeSortChannel]);
// 切分中位数
const mid = Math.floor(group.colors.length / 2);
@ -263,7 +504,7 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
count: group1.length,
sumR: group1.reduce((sum, c) => sum + c.r, 0),
sumG: group1.reduce((sum, c) => sum + c.g, 0),
sumB: group1.reduce((sum, c) => sum + c.b, 0)
sumB: group1.reduce((sum, c) => sum + c.b, 0),
});
newGroups.push({
@ -271,7 +512,7 @@ function medianCut(data: Uint8ClampedArray, levels: number): any[] {
count: group2.length,
sumR: group2.reduce((sum, c) => sum + c.r, 0),
sumG: group2.reduce((sum, c) => sum + c.g, 0),
sumB: group2.reduce((sum, c) => sum + c.b, 0)
sumB: group2.reduce((sum, c) => sum + c.b, 0),
});
}