chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,556 @@
/**
* 音频文件上传器
* 处理音频文件上传和语音转文字功能
*/
import { ACCESS_TOKEN } from "@/constants/home.constants";
import { REDIRECT_LOGIN, SUCCESS_CODE } from "@/constants/codes.constants";
import { API_BASE_URL } from "../constants/config";
import { t } from "@/utils/i18n";
// 音频文件接口
export interface AudioFile {
tempFilePath: string;
duration: number;
fileSize: number;
format?: string;
}
// 转录结果接口
export interface TranscriptResult {
success: boolean;
text: string;
confidence: number;
duration: number;
error?: string;
segments?: Array<{
text: string;
start: number;
end: number;
confidence: number;
}>;
}
// 上传配置接口
export interface UploadConfig {
apiUrl?: string; // API地址
timeout?: number; // 超时时间(毫秒)
language?: string; // 语言类型
enableRetry?: boolean; // 是否启用重试
maxRetries?: number; // 最大重试次数
retryDelay?: number; // 重试延迟(毫秒)
}
// 上传进度回调接口
export interface UploadProgress {
loaded: number; // 已上传字节数
total: number; // 总字节数
percent: number; // 上传进度百分比
}
/**
* 音频上传器类
*/
export class AudioUploader {
private config: UploadConfig = {
apiUrl: API_BASE_URL + "/api/audio/stt",
// apiUrl: 'https://stt-api.gd.ddnsto.com' + '/transcribe',
timeout: 30000,
language: "zh-CN",
enableRetry: true,
maxRetries: 1,
retryDelay: 1000,
};
constructor(config: Partial<UploadConfig> = {}) {
this.config = { ...this.config, ...config };
}
/**
* 上传音频文件并转换为文字
*/
async uploadAudio(
audioFile: AudioFile,
onProgress?: (progress: UploadProgress) => void,
): Promise<TranscriptResult> {
if (!audioFile.tempFilePath) {
throw new Error(t("Mobile.AudioUploader.pathRequired"));
}
if (audioFile.fileSize === 0) {
throw new Error(t("Mobile.AudioUploader.fileEmpty"));
}
// 检查文件大小限制10MB
const maxFileSize = 10 * 1024 * 1024;
if (audioFile.fileSize > maxFileSize) {
throw new Error(t("Mobile.AudioUploader.fileTooLarge"));
}
// 检查录音时长最短0.5秒最长10分钟
// if (audioFile.duration < 0.5) {
// throw new Error('录音时长过短至少需要0.5秒')
// }
/**
* 此处的duration是录音停止时的duration而不是录音开始时的duration, 单位为秒
*/
if (audioFile.duration > 600) {
throw new Error(t("Mobile.AudioUploader.durationTooLong"));
}
// 开始上传
return this.performUpload(audioFile, onProgress);
}
/**
* 执行上传操作
*/
private async performUpload(
audioFile: AudioFile,
onProgress?: (progress: UploadProgress) => void,
retryCount: number = 0,
): Promise<TranscriptResult> {
try {
// 模拟上传进度
// if (onProgress) {
// this.simulateProgress(onProgress)
// }
// 暂时使用模拟实现后续可替换为真实API调用
// if (this.config.apiUrl === '/api/voice-to-text') {
// return await this.mockUpload(audioFile)
// }
// 真实的上传实现
return await this.realUpload(audioFile, onProgress);
} catch (error: any) {
// console.error(`上传失败 (尝试 ${retryCount + 1}/${this.config.maxRetries + 1}):`, error)
// // 如果启用重试且未达到最大重试次数
// if (
// this.config.enableRetry &&
// retryCount < this.config.maxRetries! &&
// this.shouldRetry(error)
// ) {
// // 等待一段时间后重试
// await this.delay(this.config.retryDelay! * Math.pow(2, retryCount))
// return this.performUpload(audioFile, onProgress, retryCount + 1)
// }
// 不重试或重试次数已达上限
throw this.formatError(error);
}
}
/**
* 真实的上传实现
*/
private async realUpload(
audioFile: AudioFile,
onProgress?: (progress: UploadProgress) => void,
): Promise<TranscriptResult> {
return new Promise((resolve, reject) => {
const accessToken = uni.getStorageSync(ACCESS_TOKEN);
const header = {};
if (accessToken) {
// #ifdef H5 || WEB
if (process.env.NODE_ENV === "development") {
header["Authorization"] = `Bearer ${accessToken}`;
}
// #endif
// #ifdef MP-WEIXIN
header["Authorization"] = `Bearer ${accessToken}`;
// #endif
}
// uEnvProd
if (process.env.NODE_ENV === "production") {
// #ifdef H5
//仅H5需要添加fragment参数
const fragment = (window.location.hash || "#/").replace("#/", "/");
if (fragment) {
header["fragment"] = fragment;
}
// #endif
}
const uploadTask = uni.uploadFile({
header,
url: this.config.apiUrl,
filePath: audioFile.tempFilePath,
name: "file",
// formData: {
// format: audioFile.format || 'mp3',
// duration: audioFile.duration.toString(),
// language: this.config.language || 'zh-CN',
// fileSize: audioFile.fileSize.toString()
// },
timeout: this.config.timeout,
success: (res) => {
try {
const { code, data } = JSON.parse(res.data);
if (code === SUCCESS_CODE) {
if (data?.text) {
resolve({
success: true,
text: data.text,
});
} else {
reject(
new Error(t("Mobile.AudioUploader.noTextRecognized")),
);
}
} else {
reject(
new Error(
t("Mobile.AudioUploader.requestFailedWithCode", {
code: String(res.statusCode),
}),
),
);
}
} catch (error) {
reject(
new Error(t("Mobile.AudioUploader.responseParseFailed")),
);
}
},
fail: (err) => {
reject(
new Error(
err.errMsg || t("Mobile.AudioUploader.networkFailed"),
),
);
},
});
// 监听上传进度
// if (onProgress) {
// uploadTask.onProgressUpdate((res) => {
// onProgress({
// loaded: res.totalBytesSent,
// total: res.totalBytesExpectedToSend,
// percent: res.progress
// })
// })
// }
});
}
/**
* 模拟上传实现(用于测试)
*/
// private async mockUpload(audioFile: AudioFile): Promise<TranscriptResult> {
// // 模拟网络延迟
// await this.delay(1500 + Math.random() * 1000)
// // 模拟转换结果
// const mockTexts = [
// "这是一段测试语音转文本的内容",
// "语音识别功能正在正常工作",
// "请检查录音质量是否清晰",
// "您好,这是语音转文本测试",
// "录音功能已经成功集成",
// "人工智能语音识别技术日趋成熟",
// "语音交互将成为未来的主要方式",
// "欢迎使用语音录制功能",
// "系统正在处理您的语音信息",
// "语音转文字功能测试成功"
// ]
// // 根据录音时长选择不同长度的文本
// let selectedText: string
// if (audioFile.duration < 2) {
// selectedText = mockTexts[Math.floor(Math.random() * 3)]
// } else if (audioFile.duration < 5) {
// selectedText = mockTexts[Math.floor(Math.random() * 6) + 3]
// } else {
// selectedText = mockTexts[Math.floor(Math.random() * mockTexts.length)]
// }
// // 模拟成功率95% 成功率)
// const isSuccess = Math.random() > 0.05
// if (isSuccess) {
// return {
// success: true,
// text: selectedText,
// confidence: 0.80 + Math.random() * 0.20,
// duration: audioFile.duration,
// segments: this.generateMockSegments(selectedText, audioFile.duration)
// }
// } else {
// // 模拟不同类型的错误
// const errorTypes = [
// '网络连接异常,请检查网络设置',
// '语音内容不清晰,请重新录制',
// '服务器繁忙,请稍后重试',
// '音频格式不支持,请使用标准格式'
// ]
// const errorMessage = errorTypes[Math.floor(Math.random() * errorTypes.length)]
// return {
// success: false,
// text: '',
// confidence: 0,
// duration: audioFile.duration,
// error: errorMessage
// }
// }
// }
/**
* 生成模拟的分段结果
*/
// private generateMockSegments(text: string, duration: number): Array<{
// text: string
// start: number
// end: number
// confidence: number
// }> {
// const words = text.split('')
// const segments = []
// const wordsPerSegment = Math.ceil(words.length / Math.max(1, Math.floor(duration / 2)))
// let startTime = 0
// for (let i = 0; i < words.length; i += wordsPerSegment) {
// const segmentWords = words.slice(i, i + wordsPerSegment)
// const segmentDuration = duration * segmentWords.length / words.length
// segments.push({
// text: segmentWords.join(''),
// start: startTime,
// end: startTime + segmentDuration,
// confidence: 0.75 + Math.random() * 0.25
// })
// startTime += segmentDuration
// }
// return segments
// }
/**
* 模拟上传进度
*/
// private simulateProgress(onProgress: (progress: UploadProgress) => void): void {
// let progress = 0
// const interval = setInterval(() => {
// progress += Math.random() * 20
// if (progress >= 100) {
// progress = 100
// clearInterval(interval)
// }
// const fakeTotal = 1024 * 1024 // 1MB
// const fakeLoaded = Math.floor(fakeTotal * progress / 100)
// onProgress({
// loaded: fakeLoaded,
// total: fakeTotal,
// percent: Math.floor(progress)
// })
// }, 100)
// }
/**
* 判断是否应该重试
*/
// private shouldRetry(error: any): boolean {
// // 网络错误可以重试
// if (error.message?.includes('网络') || error.message?.includes('timeout')) {
// return true
// }
// // 服务器错误可以重试
// if (error.message?.includes('服务器') || error.message?.includes('繁忙')) {
// return true
// }
// // 其他错误不重试
// return false
// }
/**
* 格式化错误信息
*/
private formatError(error: any): Error {
let message = error.message || t("Mobile.AudioUploader.uploadFailed");
// 根据错误类型返回用户友好的提示
if (message.includes("timeout")) {
message = t("Mobile.AudioUploader.networkTimeout");
} else if (message.includes("network")) {
message = t("Mobile.AudioUploader.networkException");
} else if (message.includes("500")) {
message = t("Mobile.AudioUploader.serverError");
} else if (message.includes("413")) {
message = t("Mobile.AudioUploader.fileTooLargeDuration");
} else if (message.includes("415")) {
message = t("Mobile.AudioUploader.formatNotSupported");
}
return new Error(message);
}
/**
* 延迟函数
*/
// private delay(ms: number): Promise<void> {
// return new Promise(resolve => setTimeout(resolve, ms))
// }
/**
* 更新配置
*/
updateConfig(config: Partial<UploadConfig>): void {
this.config = { ...this.config, ...config };
}
/**
* 获取当前配置
*/
getConfig(): UploadConfig {
return { ...this.config };
}
}
/**
* 创建音频上传器实例
*/
export function createAudioUploader(
config?: Partial<UploadConfig>,
): AudioUploader {
return new AudioUploader(config);
}
/**
* 内存管理器
* 用于清理临时音频文件和管理内存使用
*/
export class MemoryManager {
private static audioFiles: Set<string> = new Set();
private static readonly MAX_FILES = 3;
/**
* 添加音频文件到管理列表
*/
static addAudioFile(filePath: string): void {
this.audioFiles.add(filePath);
// 如果文件数量超过限制,清理最老的文件
if (this.audioFiles.size > this.MAX_FILES) {
const oldestFile = this.audioFiles.values().next().value;
this.cleanupAudioFile(oldestFile);
}
}
/**
* 清理音频文件
*/
static cleanupAudioFile(filePath: string): void {
if (!filePath) return;
try {
// 从管理列表中移除
this.audioFiles.delete(filePath);
// 如果是临时文件URL释放内存仅H5平台
// #ifdef H5
if (filePath.startsWith("blob:")) {
URL.revokeObjectURL(filePath);
}
// #endif
// 尝试删除临时文件
uni.removeSavedFile({
filePath: filePath,
success: () => {
console.log("清理音频文件成功:", filePath);
},
fail: (error) => {
console.warn("清理音频文件失败:", error);
},
});
} catch (error) {
console.warn("清理音频文件时发生错误:", error);
}
}
/**
* 清理所有音频文件
*/
static cleanupAllAudioFiles(): void {
for (const filePath of this.audioFiles) {
this.cleanupAudioFile(filePath);
}
this.audioFiles.clear();
}
/**
* 获取当前文件数量
*/
static getFileCount(): number {
return this.audioFiles.size;
}
}
/**
* 工具函数将音频文件转换为Base64
*/
export function audioFileToBase64(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
const fileManager = uni.getFileSystemManager();
fileManager.readFile({
filePath: filePath,
encoding: "base64",
success: (res) => {
resolve(res.data as string);
},
fail: (error) => {
reject(
new Error(
t("Mobile.AudioUploader.fileReadFailed", {
reason: error.errMsg || "",
}),
),
);
},
});
});
}
/**
* 工具函数:检查音频文件格式
*/
export function validateAudioFormat(format: string): boolean {
const supportedFormats = ["mp3", "wav", "aac", "PCM", "webm"];
return supportedFormats.includes(format.toLowerCase());
}
/**
* 工具函数:格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
/**
* 工具函数:格式化时长
*/
export function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (minutes > 0) {
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
} else {
return `${remainingSeconds}s`;
}
}