feat: init
This commit is contained in:
0
packages/hooks/index.ts
Normal file
0
packages/hooks/index.ts
Normal file
12
packages/hooks/src/i18n.ts
Normal file
12
packages/hooks/src/i18n.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
60
packages/hooks/src/location.ts
Normal file
60
packages/hooks/src/location.ts
Normal 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};
|
||||
};
|
||||
28
packages/hooks/src/session.ts
Normal file
28
packages/hooks/src/session.ts
Normal 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;
|
||||
198
packages/hooks/src/useFileUpload.ts
Normal file
198
packages/hooks/src/useFileUpload.ts
Normal 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
8
packages/shared/index.ts
Normal 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 };
|
||||
23
packages/shared/package.json
Normal file
23
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
packages/shared/src/carousel/index.less
Normal file
7
packages/shared/src/carousel/index.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.carousel {
|
||||
.carousel-item {
|
||||
.carousel-image {
|
||||
border-radius: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/shared/src/carousel/index.tsx
Normal file
49
packages/shared/src/carousel/index.tsx
Normal 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;
|
||||
42
packages/shared/src/floatingMenu/index.less
Normal file
42
packages/shared/src/floatingMenu/index.less
Normal 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;
|
||||
}
|
||||
}
|
||||
184
packages/shared/src/floatingMenu/index.tsx
Normal file
184
packages/shared/src/floatingMenu/index.tsx
Normal 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;
|
||||
88
packages/shared/src/qr-scanner/index.tsx
Normal file
88
packages/shared/src/qr-scanner/index.tsx
Normal 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;
|
||||
69
packages/shared/src/voiceIcon/index.less
Normal file
69
packages/shared/src/voiceIcon/index.less
Normal 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;
|
||||
}
|
||||
}
|
||||
22
packages/shared/src/voiceIcon/index.tsx
Normal file
22
packages/shared/src/voiceIcon/index.tsx
Normal 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);
|
||||
11
packages/shared/src/xpopup/index.less
Normal file
11
packages/shared/src/xpopup/index.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/shared/src/xpopup/index.tsx
Normal file
28
packages/shared/src/xpopup/index.tsx
Normal 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;
|
||||
10
packages/shared/tsconfig.json
Normal file
10
packages/shared/tsconfig.json
Normal 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
17
packages/utils/index.ts
Normal 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';
|
||||
19
packages/utils/package.json
Normal file
19
packages/utils/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/utils/src/amount.ts
Normal file
3
packages/utils/src/amount.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toYuan(num: number) {
|
||||
return (num / 100).toFixed(2);
|
||||
}
|
||||
3
packages/utils/src/location.ts
Normal file
3
packages/utils/src/location.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function toKm(m: number): number {
|
||||
return m / 1000;
|
||||
}
|
||||
164
packages/utils/src/voice.ts
Normal file
164
packages/utils/src/voice.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
9
packages/utils/tsconfig.json
Normal file
9
packages/utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user