feat: init

This commit is contained in:
2025-09-04 11:29:00 +08:00
parent 2c9059ce2f
commit ddbee614e8
89 changed files with 3476 additions and 349 deletions

0
packages/hooks/index.ts Normal file
View File

View File

@@ -0,0 +1,12 @@
import zhCN from '@/locales/zh_CN.json'
import enUS from '@/locales/en_US.json'
import {useI18nStore} from '@/store/i18n';
export default function useI18n() {
const {lang} = useI18nStore();
const locales = lang === 'en_US' ? enUS : zhCN;
return (name: string) => {
return locales[name as keyof typeof locales] || name;
}
}

View File

@@ -0,0 +1,60 @@
import {useState, useEffect} from 'react';
import useI18n from "@/hooks/i18n";
// 定义位置坐标的类型
export interface Coordinates {
lat: number;
lng: number;
}
// 定义返回的位置状态的类型
export interface LocationState {
loaded: boolean;
coordinates: Coordinates | null;
}
// 定义错误状态的类型
export type ErrorState = string | null;
export const useLocation = (): { location: LocationState; error: ErrorState } => {
const t = useI18n();
// 用于存储位置信息和加载状态的状态
const [location, setLocation] = useState<LocationState>({
loaded: false,
coordinates: null,
});
// 用于存储任何错误消息的状态
const [error, setError] = useState<ErrorState>(null);
// 地理位置成功处理函数
const onSuccess = (location: GeolocationPosition) => {
setLocation({
loaded: true,
coordinates: {
lat: location.coords.latitude,
lng: location.coords.longitude,
},
});
};
// 地理位置错误处理函数
const onError = (error: GeolocationPositionError) => {
setError(error.message);
};
// 使用 useEffect 在组件挂载时执行地理位置请求
useEffect(() => {
// 检查浏览器是否支持地理位置
if (!navigator.geolocation) {
setError(t('hooks.location.unsupported'));
return;
}
// 请求用户的当前位置
navigator.geolocation.getCurrentPosition(onSuccess, onError);
}, []);
return {location, error};
};

View File

@@ -0,0 +1,28 @@
import {useState, useEffect} from 'react';
import isEqual from 'lodash.isequal';
function useSessionStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
// 初始化状态
const [storedValue, setStoredValue] = useState<T>(() => {
const item = sessionStorage.getItem(key);
if (item !== null) {
// 如果 sessionStorage 中有数据,则使用现有数据
return JSON.parse(item);
} else {
// 当 sessionStorage 中没有相应的键时,使用 initialValue 初始化,并写入 sessionStorage
sessionStorage.setItem(key, JSON.stringify(initialValue));
return initialValue;
}
});
// 监听并保存变化到 sessionStorage
useEffect(() => {
if (!isEqual(JSON.parse(sessionStorage.getItem(key) || 'null'), storedValue)) {
sessionStorage.setItem(key, JSON.stringify(storedValue));
}
}, [key, storedValue]);
return [storedValue, setStoredValue];
}
export default useSessionStorage;

View File

@@ -0,0 +1,198 @@
// hooks/useFileUpload.ts (更新)
import { useState, useCallback } from "react";
import {
UploadConfig,
UploadProgress,
UploadResponse,
VoiceUploadStatus,
} from "../types/upload";
interface UseFileUploadReturn {
uploadStatus: VoiceUploadStatus;
uploadFile: (
file: Blob,
fileName: string,
config: UploadConfig
) => Promise<UploadResponse>;
resetUpload: () => void;
}
export const useFileUpload = (): UseFileUploadReturn => {
const [uploadStatus, setUploadStatus] = useState<VoiceUploadStatus>({
status: "idle",
});
// 检测文件类型并转换文件名
const getFileExtension = (mimeType: string): string => {
const mimeToExt: Record<string, string> = {
"audio/webm": ".webm",
"audio/mp4": ".m4a",
"audio/aac": ".aac",
"audio/wav": ".wav",
"audio/ogg": ".ogg",
"audio/mpeg": ".mp3",
};
// 处理带codecs的MIME类型
const baseMimeType = mimeType.split(";")[0];
return mimeToExt[baseMimeType] || ".webm";
};
const uploadFile = useCallback(
async (
file: Blob,
fileName: string,
config: UploadConfig
): Promise<UploadResponse> => {
// 检查文件大小
if (config.maxFileSize && file.size > config.maxFileSize) {
const error = `文件大小超过限制 (${Math.round(
config.maxFileSize / 1024 / 1024
)}MB)`;
setUploadStatus({
status: "error",
error,
});
throw new Error(error);
}
// 更宽松的文件类型检查支持iOS格式
const allowedTypes = config.allowedTypes || [
"audio/webm",
"audio/mp4",
"audio/aac",
"audio/wav",
"audio/ogg",
"audio/mpeg",
];
const baseMimeType = file.type.split(";")[0];
const isTypeAllowed = allowedTypes.some(
(type) => baseMimeType === type || baseMimeType === type.split(";")[0]
);
if (!isTypeAllowed) {
console.warn(`文件类型 ${file.type} 不在允许列表中,但继续上传`);
}
// 根据实际MIME类型调整文件名
const extension = getFileExtension(file.type);
const adjustedFileName = fileName.replace(/\.[^/.]+$/, "") + extension;
setUploadStatus({
status: "uploading",
progress: { loaded: 0, total: file.size, percentage: 0 },
});
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append(config.fieldName || "file", file, adjustedFileName);
// 添加额外的元数据
formData.append("fileName", adjustedFileName);
formData.append("originalFileName", fileName);
formData.append("fileSize", file.size.toString());
formData.append("fileType", file.type);
formData.append("uploadTime", new Date().toISOString());
formData.append("userAgent", navigator.userAgent);
const xhr = new XMLHttpRequest();
// 上传进度监听
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) {
const progress: UploadProgress = {
loaded: event.loaded,
total: event.total,
percentage: Math.round((event.loaded / event.total) * 100),
};
setUploadStatus({
status: "uploading",
progress,
});
}
});
// 上传完成监听
xhr.addEventListener("load", () => {
try {
const response: UploadResponse = JSON.parse(xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300 && response.success) {
setUploadStatus({
status: "success",
response,
progress: {
loaded: file.size,
total: file.size,
percentage: 100,
},
});
resolve(response);
} else {
const error = response.error || `上传失败: ${xhr.status}`;
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
}
} catch (parseError) {
const error = "服务器响应格式错误";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
}
});
// 上传错误监听
xhr.addEventListener("error", () => {
const error = "网络错误,上传失败";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
});
// 上传中断监听
xhr.addEventListener("abort", () => {
const error = "上传已取消";
setUploadStatus({
status: "error",
error,
});
reject(new Error(error));
});
// 设置请求头
const headers = {
"X-Requested-With": "XMLHttpRequest",
...config.headers,
};
Object.entries(headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
// 发送请求
xhr.open(config.method || "POST", config.url);
xhr.send(formData);
});
},
[]
);
const resetUpload = useCallback(() => {
setUploadStatus({ status: "idle" });
}, []);
return {
uploadStatus,
uploadFile,
resetUpload,
};
};

8
packages/shared/index.ts Normal file
View File

@@ -0,0 +1,8 @@
// 导出所有组件
import FloatingMenu, { FloatMenuItemConfig } from "./src/floatingMenu";
import XPopup from "./src/xpopup";
import QRscanner from "./src/qr-scanner";
import VoiceIcon from "./src/voiceIcon";
export { FloatingMenu, XPopup, QRscanner, VoiceIcon };
export type { FloatMenuItemConfig };

View File

@@ -0,0 +1,23 @@
{
"name": "@workspace/shared",
"version": "1.0.0",
"main": "./index.ts",
"types": "./index.ts",
"exports": {
".": {
"import": "./index.ts",
"types": "./index.ts"
}
},
"scripts": {
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit"
},
"dependencies": {
"react": "^18.2.0",
"antd-mobile": "^5.34.0"
},
"peerDependencies": {
"react": ">=18.0.0"
}
}

View File

@@ -0,0 +1,7 @@
.carousel {
.carousel-item {
.carousel-image {
border-radius: 15px;
}
}
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import Slider, {Settings} from 'react-slick';
import 'slick-carousel/slick/slick.css';
import 'slick-carousel/slick/slick-theme.css';
import './index.less';
export interface CarouselComponentProps {
images: string[];
height?: number;
}
/**
* 轮播图组件
* @param images 图片地址数组
* @param height 图片高度
* @constructor Carousel
*/
const Carousel: React.FC<CarouselComponentProps> = ({images, height = 180}) => {
const settings: Settings = {
dots: false,
infinite: true,
speed: 3000,
slidesToShow: 1,
slidesToScroll: 1,
autoplay: true,
autoplaySpeed: 2000,
responsive: [
{
breakpoint: 768,
settings: {
arrows: false,
}
}
]
};
return (
<Slider {...settings} className="carousel">
{images.map((image, index) => (
<div key={index} className="carousel-item">
<img className="carousel-image" src={image} alt={`Slide ${index}`}
style={{width: '100%', height: `${height}px`}}/>
</div>
))}
</Slider>
);
};
export default Carousel;

View File

@@ -0,0 +1,42 @@
/* FloatingFanMenu.css */
@keyframes menuItemPop {
0% {
opacity: 0;
transform: scale(0) rotate(-180deg);
}
70% {
opacity: 1;
transform: scale(1.1) rotate(-10deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
/* 悬停效果 */
.menu-item:hover {
transform: scale(1.1) !important;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important;
transition: all 0.2s ease !important;
}
.adm-floating-bubble-button {
z-index: 999;
width: 72px;
height: 72px;
overflow: visible;
.cat {
position: absolute;
width: 70px;
font-size: 12px;
bottom: -10px;
background: rgba(255, 204, 199, 1);
padding: 4px 0px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,184 @@
import React, { useState, useRef } from "react";
import { FloatingBubble } from "antd-mobile";
import { createPortal } from "react-dom";
import "./index.less";
export interface FloatMenuItemConfig {
icon: React.ReactNode;
type?: string;
}
const FloatingFanMenu: React.FC<{
menuItems: FloatMenuItemConfig[];
value?: FloatMenuItemConfig;
onChange?: (item: FloatMenuItemConfig) => void;
}> = (props) => {
const { menuItems = [] } = props;
const [visible, setVisible] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const bubbleRef = useRef<HTMLDivElement>(null);
// 点击时获取FloatingBubble的位置
const handleMainClick = () => {
if (!visible) {
// 显示菜单时获取当前FloatingBubble的位置
if (bubbleRef.current) {
const bubble = bubbleRef.current.querySelector(
".adm-floating-bubble-button"
);
if (bubble) {
const rect = bubble.getBoundingClientRect();
setMenuPosition({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
}
}
setVisible(!visible);
};
const handleItemClick = (item: FloatMenuItemConfig) => {
props.onChange?.(item);
setVisible(false);
};
// 计算菜单项位置
const getMenuItemPosition = (index: number) => {
const positions = [
{ x: 0, y: -80 }, // 上方
{ x: -60, y: -60 }, // 左上
{ x: -80, y: 0 }, // 左方
{ x: -60, y: 60 }, // 左下
];
const pos = positions[index] || { x: 0, y: 0 };
let x = menuPosition.x + pos.x;
let y = menuPosition.y + pos.y;
// // 边界检测
// const itemSize = 48;
// const margin = 20;
// 边界检测
const itemSize = 48;
// const margin = 0;
// x = Math.max(
// // margin + itemSize / 2,
// Math.min(window.innerWidth - margin - itemSize / 2, x)
// );
// y = Math.max(
// // margin + itemSize / 2,
// Math.min(window.innerHeight - margin - itemSize / 2, y)
// );
return {
left: x - itemSize / 2,
top: y - itemSize / 2,
};
};
// 菜单组件
const MenuComponent = () => (
<>
{/* 背景遮罩 */}
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.1)",
zIndex: 9998,
}}
onClick={() => setVisible(false)}
/>
{/* 菜单项 */}
{menuItems.map((item, index) => {
const position = getMenuItemPosition(index);
return (
<div
key={index}
style={{
position: "fixed",
...position,
width: 48,
height: 48,
borderRadius: "50%",
backgroundColor: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 20,
cursor: "pointer",
zIndex: 9999,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.2)",
animation: `menuItemPop 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards`,
animationDelay: `${index * 0.1}s`,
opacity: 0,
transform: "scale(0)",
}}
onClick={() => handleItemClick(item)}
// title={item.label}
>
{item.icon}
</div>
);
})}
</>
);
return (
<>
{/* 主按钮 */}
<div ref={bubbleRef}>
<FloatingBubble
style={{
"--initial-position-bottom": "200px",
"--initial-position-right": "24px",
"--edge-distance": "24px",
}}
onClick={handleMainClick}
>
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 24,
color: "white",
background: visible
? "linear-gradient(135deg, #ff4d4f, #ff7875)"
: "linear-gradient(135deg, #fff, #fff)",
borderRadius: "50%",
transition: "all 0.3s ease",
transform: visible ? "rotate(45deg)" : "rotate(0deg)",
boxShadow: visible
? "0 6px 16px rgba(255, 77, 79, 0.4)"
: "0 4px 12px #eee",
}}
>
{props.value?.icon}
<div className="cat">
{/* {!visible && <CheckOutline style={{ marginRight: "3px" }} />} */}
</div>
</div>
</FloatingBubble>
</div>
{/* 菜单 - 只在有位置信息时渲染 */}
{visible &&
menuPosition.x > 0 &&
menuPosition.y > 0 &&
createPortal(<MenuComponent />, document.body)}
</>
);
};
export default FloatingFanMenu;

View File

@@ -0,0 +1,88 @@
import React, {useRef, useEffect, useState} from 'react';
import jsQR from 'jsqr';
export interface QRScannerProps {
width?: number;
height?: number;
onScan: (data: string) => void;
}
/**
* 二维码扫描组件
* @param width 画布宽度
* @param height 画布高度
* @param onScan 扫描成功的回调函数
* @constructor QRScanner
*/
const QRScanner: React.FC<QRScannerProps> = ({width = 300, height = 300, onScan}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScanning, setIsScanning] = useState(true);
const [scanSuccess, setScanSuccess] = useState(false); // 新的状态变量
let streamRef: MediaStream | null = null; // 存储摄像头流的引用
useEffect(() => {
navigator.mediaDevices.getUserMedia({video: {facingMode: "environment"}})
.then(stream => {
streamRef = stream; // 存储对摄像头流的引用
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.addEventListener('loadedmetadata', () => {
videoRef.current?.play().then(() => {
requestAnimationFrame(tick);
}).catch(err => console.error("Error playing video: ", err));
});
}
}).catch(err => {
console.error("Error accessing media devices: ", err);
setIsScanning(false);
});
}, []);
const tick = () => {
// 如果已经成功扫描就不再继续执行tick
if (!isScanning) return;
if (videoRef.current && canvasRef.current) {
if (videoRef.current.readyState === videoRef.current.HAVE_ENOUGH_DATA) {
let video = videoRef.current;
let canvas = canvasRef.current;
let ctx = canvas.getContext('2d');
if (ctx) {
canvas.height = height;
canvas.width = width;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
let code = jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
onScan(code.data); // 扫码成功
setIsScanning(false); // 更新状态为不再扫描
setScanSuccess(true); // 显示扫描成功的蒙层
if (streamRef) {
let tracks = streamRef.getTracks();
tracks.forEach(track => track.stop()); // 关闭摄像头
}
return; // 直接返回,避免再次调用 requestAnimationFrame
}
}
}
requestAnimationFrame(tick);
}
};
return (
<div>
<video ref={videoRef} style={{display: 'none'}}></video>
<canvas ref={canvasRef}
style={{
display: isScanning ? 'block' : 'none',
width: `${width}px`,
height: `${height}px`
}}></canvas>
{scanSuccess && <div className="scan-success-overlay"></div>} {/* 显示识别成功的蒙层 */}
</div>
);
}
export default QRScanner;

View File

@@ -0,0 +1,69 @@
/* VoiceIcon.css */
.voice-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
cursor: pointer;
gap: 2px;
}
.audio-recorder {
display: none !important;
}
.wave {
width: 1px;
height: 13px;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 2px;
transition: all 0.3s ease;
}
.wave1 {
height: 4px;
}
.wave2 {
height: 13px;
}
.wave3 {
height: 4px;
}
.wave3 {
height: 8px;
}
.wave4 {
height: 4px;
}
/* 播放动画 */
.voice-icon.playing .wave {
animation: voice-wave 1.2s ease-in-out infinite;
}
.voice-icon.playing .wave1 {
animation-delay: 0s;
}
.voice-icon.playing .wave2 {
animation-delay: 0.2s;
}
.voice-icon.playing .wave3 {
animation-delay: 0.4s;
}
@keyframes voice-wave {
0%,
100% {
transform: scaleY(0.3);
opacity: 0.5;
}
50% {
transform: scaleY(1);
opacity: 1;
}
}

View File

@@ -0,0 +1,22 @@
import React, { useCallback, useState } from "react";
import "./index.less";
const VoiceIcon = (props: { isPlaying: boolean; onChange?: () => void }) => {
const { isPlaying = false } = props;
const onChange = useCallback(() => {
props.onChange?.();
}, [isPlaying]);
return (
<div
className={`voice-icon ${isPlaying ? "playing" : ""}`}
onClick={onChange}
>
<div className="wave wave1"></div>
<div className="wave wave2"></div>
<div className="wave wave3"></div>
<div className="wave wave4"></div>
</div>
);
};
export default React.memo(VoiceIcon);

View File

@@ -0,0 +1,11 @@
.xpopup {
.adm-popup-body {
background-color: rgba(245, 245, 245, 1);
padding: 16px;
.header {
font-size: 16px;
display: flex;
justify-content: space-between;
}
}
}

View File

@@ -0,0 +1,28 @@
import { Divider, Popup } from "antd-mobile";
import { CloseOutline } from "antd-mobile-icons";
import "./index.less";
interface DefinedProps {
visible: boolean;
title: string;
children: React.ReactNode;
onClose: () => void;
}
function XPopup(props: DefinedProps) {
const { visible, title, children, onClose } = props;
return (
<Popup visible={visible} closeOnMaskClick={true} className="xpopup">
<div className="header">
<h3 className="title">{title}</h3>
<span className="closeIcon" onClick={onClose}>
<CloseOutline style={{ fontSize: "16px" }} />
</span>
</div>
<Divider />
<div className="content">{children}</div>
</Popup>
);
}
export default XPopup;

View File

@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

17
packages/utils/index.ts Normal file
View File

@@ -0,0 +1,17 @@
// //处理音频类
export * from "./src/voice";
// // 验证工具
// export * from './validation';
// // 存储工具
// export * from './storage';
// // 日期工具
// export * from './date';
// // 按模块导出
// export * as format from './format';
// export * as validation from './validation';
// export * as storage from './storage';
// export * as date from './date';

View File

@@ -0,0 +1,19 @@
{
"name": "@workspace/utils",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./index.ts",
"types": "./index.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src --ext .ts",
"type-check": "tsc --noEmit",
"clean": "rimraf dist"
}
}

View File

@@ -0,0 +1,3 @@
export function toYuan(num: number) {
return (num / 100).toFixed(2);
}

View File

@@ -0,0 +1,3 @@
export function toKm(m: number): number {
return m / 1000;
}

164
packages/utils/src/voice.ts Normal file
View File

@@ -0,0 +1,164 @@
export const mockTranslateAudio = async (): Promise<string> => {
return new Promise((resolve) => {
setTimeout(() => {
const mockTranslations = [
"汪汪汪!我饿了,想吃东西!🍖",
"喵喵~我想要抱抱!🤗",
"我想出去玩耍!🎾",
"我很开心!😊",
"我有点害怕...😰",
"我想睡觉了~😴",
"主人,陪我玩一会儿吧!🎮",
"我想喝水了💧",
"外面有什么声音?👂",
"我爱你,主人!❤️",
];
const randomTranslation =
mockTranslations[Math.floor(Math.random() * mockTranslations.length)];
resolve(randomTranslation);
}, 2000 + Math.random() * 2000);
});
};
// 创建发送音效 - 清脆的"叮"声
export const createSendSound = () => {
try {
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// 设置音调 - 清脆的高音
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
oscillator.frequency.exponentialRampToValueAtTime(
1200,
audioContext.currentTime + 0.1
);
// 设置音量包络
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.3
);
oscillator.type = "sine";
// 创建音频元素
const audio = new Audio();
// 重写play方法来播放合成音效
audio.play = () => {
return new Promise((resolve) => {
try {
const newOscillator = audioContext.createOscillator();
const newGainNode = audioContext.createGain();
newOscillator.connect(newGainNode);
newGainNode.connect(audioContext.destination);
newOscillator.frequency.setValueAtTime(800, audioContext.currentTime);
newOscillator.frequency.exponentialRampToValueAtTime(
1200,
audioContext.currentTime + 0.1
);
newGainNode.gain.setValueAtTime(0, audioContext.currentTime);
newGainNode.gain.linearRampToValueAtTime(
0.3,
audioContext.currentTime + 0.01
);
newGainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.3
);
newOscillator.type = "sine";
newOscillator.start(audioContext.currentTime);
newOscillator.stop(audioContext.currentTime + 0.3);
setTimeout(() => resolve(undefined), 300);
} catch (error) {
console.error("播放发送音效失败:", error);
resolve(undefined);
}
});
};
return audio;
} catch (error) {
console.error("创建发送音效失败:", error);
return new Audio(); // 返回空音频对象
}
};
export const createStartRecordSound = () => {
try {
const audio = new Audio();
audio.play = () => {
return new Promise((resolve) => {
try {
const audioContext = new (window.AudioContext ||
(window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// 设置音调 - 低沉的音
oscillator.frequency.setValueAtTime(400, audioContext.currentTime);
// 设置音量包络
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(
0.2,
audioContext.currentTime + 0.05
);
gainNode.gain.linearRampToValueAtTime(
0,
audioContext.currentTime + 0.2
);
oscillator.type = "sine";
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
setTimeout(() => resolve(undefined), 200);
} catch (error) {
console.error("播放录音音效失败:", error);
resolve(undefined);
}
});
};
return audio;
} catch (error) {
console.error("创建录音音效失败:", error);
return new Audio();
}
};
export const getAudioDuration = async (audioBlob: Blob): Promise<number> => {
return new Promise((resolve, reject) => {
const AudioContextClass =
window.AudioContext || (window as any).webkitAudioContext;
const audioContext = new AudioContextClass();
const fileReader = new FileReader();
fileReader.onload = async (e) => {
try {
const arrayBuffer = e.target?.result as ArrayBuffer;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const duration = audioBuffer.duration;
resolve(duration);
} catch (error) {
reject(error);
}
};
fileReader.readAsArrayBuffer(audioBlob);
});
};

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}