diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..bd4006f --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,42 @@ +module.exports = { + root: true, + env: { + browser: true, + es2020: true, + node: true, + }, + extends: [ + "eslint:recommended", + "@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.js", "node_modules"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: ["react-refresh"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + }, + overrides: [ + { + files: ["packages/**/*.ts", "packages/**/*.tsx"], + rules: { + "no-console": "warn", + }, + }, + { + files: ["projects/**/*.ts", "projects/**/*.tsx"], + rules: { + "no-console": "off", + }, + }, + ], +}; diff --git a/.gitignore b/.gitignore index 3c3629e..6360dee 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,96 @@ +# Dependencies node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local Netlify folder +.netlify \ No newline at end of file diff --git a/package.json b/package.json index 8bf8314..8e3566e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { - "name": "ec-web-h5", + "name": "tashow-h5", "version": "0.0.0", "type": "module", "scripts": { + "dev:translate-h5": "pnpm --filter translate-h5 dev", + "build:translate-h5": "pnpm --filter translate-h5 build", "dev": "vite", "dev:https": "vite --https", "build": "tsc && vite build", diff --git a/packages/hooks/index.ts b/packages/hooks/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/hooks/src/i18n.ts b/packages/hooks/src/i18n.ts new file mode 100644 index 0000000..ac3cd8c --- /dev/null +++ b/packages/hooks/src/i18n.ts @@ -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; + } +} diff --git a/packages/hooks/src/location.ts b/packages/hooks/src/location.ts new file mode 100644 index 0000000..f549fc2 --- /dev/null +++ b/packages/hooks/src/location.ts @@ -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({ + loaded: false, + coordinates: null, + }); + + // 用于存储任何错误消息的状态 + const [error, setError] = useState(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}; +}; diff --git a/packages/hooks/src/session.ts b/packages/hooks/src/session.ts new file mode 100644 index 0000000..5595df5 --- /dev/null +++ b/packages/hooks/src/session.ts @@ -0,0 +1,28 @@ +import {useState, useEffect} from 'react'; +import isEqual from 'lodash.isequal'; + +function useSessionStorage(key: string, initialValue: T): [T, (value: T) => void] { + // 初始化状态 + const [storedValue, setStoredValue] = useState(() => { + 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; diff --git a/packages/hooks/src/useFileUpload.ts b/packages/hooks/src/useFileUpload.ts new file mode 100644 index 0000000..a2f61f8 --- /dev/null +++ b/packages/hooks/src/useFileUpload.ts @@ -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; + resetUpload: () => void; +} + +export const useFileUpload = (): UseFileUploadReturn => { + const [uploadStatus, setUploadStatus] = useState({ + status: "idle", + }); + + // 检测文件类型并转换文件名 + const getFileExtension = (mimeType: string): string => { + const mimeToExt: Record = { + "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 => { + // 检查文件大小 + 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, + }; +}; diff --git a/packages/shared/index.ts b/packages/shared/index.ts new file mode 100644 index 0000000..116ac5b --- /dev/null +++ b/packages/shared/index.ts @@ -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 }; diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..7dc2c1b --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/carousel/index.less b/packages/shared/src/carousel/index.less new file mode 100644 index 0000000..f1685d9 --- /dev/null +++ b/packages/shared/src/carousel/index.less @@ -0,0 +1,7 @@ +.carousel { + .carousel-item { + .carousel-image { + border-radius: 15px; + } + } +} \ No newline at end of file diff --git a/packages/shared/src/carousel/index.tsx b/packages/shared/src/carousel/index.tsx new file mode 100644 index 0000000..932a50e --- /dev/null +++ b/packages/shared/src/carousel/index.tsx @@ -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 = ({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 ( + + {images.map((image, index) => ( +
+ {`Slide +
+ ))} +
+ ); +}; + +export default Carousel; \ No newline at end of file diff --git a/packages/shared/src/floatingMenu/index.less b/packages/shared/src/floatingMenu/index.less new file mode 100644 index 0000000..be92e5f --- /dev/null +++ b/packages/shared/src/floatingMenu/index.less @@ -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; + } +} diff --git a/packages/shared/src/floatingMenu/index.tsx b/packages/shared/src/floatingMenu/index.tsx new file mode 100644 index 0000000..36dc1ee --- /dev/null +++ b/packages/shared/src/floatingMenu/index.tsx @@ -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(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 = () => ( + <> + {/* 背景遮罩 */} +
setVisible(false)} + /> + + {/* 菜单项 */} + {menuItems.map((item, index) => { + const position = getMenuItemPosition(index); + return ( +
handleItemClick(item)} + // title={item.label} + > + {item.icon} +
+ ); + })} + + ); + + return ( + <> + {/* 主按钮 */} +
+ +
+ {props.value?.icon} +
+ {/* {!visible && } */} + 切换语言 +
+
+
+
+ + {/* 菜单 - 只在有位置信息时渲染 */} + {visible && + menuPosition.x > 0 && + menuPosition.y > 0 && + createPortal(, document.body)} + + ); +}; + +export default FloatingFanMenu; diff --git a/packages/shared/src/qr-scanner/index.tsx b/packages/shared/src/qr-scanner/index.tsx new file mode 100644 index 0000000..3aebcde --- /dev/null +++ b/packages/shared/src/qr-scanner/index.tsx @@ -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 = ({width = 300, height = 300, onScan}) => { + const videoRef = useRef(null); + const canvasRef = useRef(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 ( +
+ + + {scanSuccess &&
识别成功!
} {/* 显示识别成功的蒙层 */} +
+ ); +} + +export default QRScanner; diff --git a/packages/shared/src/voiceIcon/index.less b/packages/shared/src/voiceIcon/index.less new file mode 100644 index 0000000..3e40484 --- /dev/null +++ b/packages/shared/src/voiceIcon/index.less @@ -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; + } +} diff --git a/packages/shared/src/voiceIcon/index.tsx b/packages/shared/src/voiceIcon/index.tsx new file mode 100644 index 0000000..09fb7a4 --- /dev/null +++ b/packages/shared/src/voiceIcon/index.tsx @@ -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 ( +
+
+
+
+
+
+ ); +}; + +export default React.memo(VoiceIcon); diff --git a/packages/shared/src/xpopup/index.less b/packages/shared/src/xpopup/index.less new file mode 100644 index 0000000..b5b92ec --- /dev/null +++ b/packages/shared/src/xpopup/index.less @@ -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; + } + } +} diff --git a/packages/shared/src/xpopup/index.tsx b/packages/shared/src/xpopup/index.tsx new file mode 100644 index 0000000..723cc11 --- /dev/null +++ b/packages/shared/src/xpopup/index.tsx @@ -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 ( + +
+

{title}

+ + + +
+ +
{children}
+
+ ); +} + +export default XPopup; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..aaef027 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/utils/index.ts b/packages/utils/index.ts new file mode 100644 index 0000000..6c10e10 --- /dev/null +++ b/packages/utils/index.ts @@ -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'; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..9ca5b8d --- /dev/null +++ b/packages/utils/package.json @@ -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" + } +} diff --git a/packages/utils/src/amount.ts b/packages/utils/src/amount.ts new file mode 100644 index 0000000..6db3cae --- /dev/null +++ b/packages/utils/src/amount.ts @@ -0,0 +1,3 @@ +export function toYuan(num: number) { + return (num / 100).toFixed(2); +} \ No newline at end of file diff --git a/packages/utils/src/location.ts b/packages/utils/src/location.ts new file mode 100644 index 0000000..5a997c7 --- /dev/null +++ b/packages/utils/src/location.ts @@ -0,0 +1,3 @@ +export function toKm(m: number): number { + return m / 1000; +} \ No newline at end of file diff --git a/packages/utils/src/voice.ts b/packages/utils/src/voice.ts new file mode 100644 index 0000000..b4d8cf1 --- /dev/null +++ b/packages/utils/src/voice.ts @@ -0,0 +1,164 @@ +export const mockTranslateAudio = async (): Promise => { + 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 => { + 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); + }); +}; diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..5188ba6 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a8419a..86c70e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,129 @@ importers: specifier: ^5.0.0 version: 5.4.19(@types/node@20.19.11)(less@4.4.1) + packages/shared: + dependencies: + antd-mobile: + specifier: ^5.34.0 + version: 5.39.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/utils: {} + + projects/translate-h5: + dependencies: + '@icon-park/react': + specifier: ^1.4.2 + version: 1.4.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/node': + specifier: ^20.10.0 + version: 20.19.11 + '@types/react-router-dom': + specifier: ^5.3.3 + version: 5.3.3 + '@uidotdev/usehooks': + specifier: ^2.4.1 + version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@vitejs/plugin-basic-ssl': + specifier: ^2.1.0 + version: 2.1.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1)) + '@workspace/shared': + specifier: workspace:* + version: link:../../packages/shared + '@workspace/utils': + specifier: workspace:* + version: link:../../packages/utils + antd-mobile: + specifier: ^5.33.0 + version: 5.39.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + antd-mobile-icons: + specifier: ^0.3.0 + version: 0.3.0 + axios: + specifier: ^1.6.2 + version: 1.11.0 + axios-hooks: + specifier: ^5.0.2 + version: 5.1.1(axios@1.11.0)(react@18.3.1) + js-audio-recorder: + specifier: ^1.0.7 + version: 1.0.7 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 + less: + specifier: ^4.2.0 + version: 4.4.1 + query-string: + specifier: ^8.1.0 + version: 8.2.0 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-audio-voice-recorder: + specifier: ^2.2.0 + version: 2.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + react-router-dom: + specifier: ^6.20.0 + version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-slick: + specifier: ^0.29.0 + version: 0.29.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + slick-carousel: + specifier: ^1.8.1 + version: 1.8.1(jquery@3.7.1) + weixin-js-sdk: + specifier: ^1.6.5 + version: 1.6.5 + zustand: + specifier: ^4.4.6 + version: 4.5.7(@types/react@18.3.24)(react@18.3.1) + devDependencies: + '@types/lodash.isequal': + specifier: ^4.5.8 + version: 4.5.8 + '@types/react': + specifier: ^18.3.24 + version: 18.3.24 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.24) + '@types/react-slick': + specifier: ^0.23.12 + version: 0.23.13 + '@typescript-eslint/eslint-plugin': + specifier: ^6.10.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^6.10.0 + version: 6.21.0(eslint@9.34.0)(typescript@5.9.2) + '@vitejs/plugin-react-swc': + specifier: ^3.5.0 + version: 3.11.0(vite@5.4.19(@types/node@20.19.11)(less@4.4.1)) + eslint: + specifier: ^9.34.0 + version: 9.34.0 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.34.0) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.20(eslint@9.34.0) + postcss-pxtorem: + specifier: ^6.0.0 + version: 6.1.0(postcss@8.5.6) + typescript: + specifier: ^5.2.2 + version: 5.9.2 + vite: + specifier: ^5.0.0 + version: 5.4.19(@types/node@20.19.11)(less@4.4.1) + packages: '@babel/runtime@7.26.10': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7fe61d5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "projects/*" + - "packages/*" diff --git a/projects/translate-h5/index.html b/projects/translate-h5/index.html new file mode 100644 index 0000000..57d34a6 --- /dev/null +++ b/projects/translate-h5/index.html @@ -0,0 +1,39 @@ + + + + + + + tashow-h5 + + + +
+ + + diff --git a/projects/translate-h5/package.json b/projects/translate-h5/package.json new file mode 100644 index 0000000..a6d37ad --- /dev/null +++ b/projects/translate-h5/package.json @@ -0,0 +1,52 @@ +{ + "name": "translate-h5", + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:https": "vite --https", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@icon-park/react": "^1.4.2", + "@types/node": "^20.10.0", + "@types/react-router-dom": "^5.3.3", + "@uidotdev/usehooks": "^2.4.1", + "@vitejs/plugin-basic-ssl": "^2.1.0", + "antd-mobile": "^5.33.0", + "antd-mobile-icons": "^0.3.0", + "axios": "^1.6.2", + "axios-hooks": "^5.0.2", + "js-audio-recorder": "^1.0.7", + "jsqr": "^1.4.0", + "less": "^4.2.0", + "query-string": "^8.1.0", + "react": "^18.2.0", + "react-audio-voice-recorder": "^2.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "react-slick": "^0.29.0", + "slick-carousel": "^1.8.1", + "weixin-js-sdk": "^1.6.5", + "zustand": "^4.4.6", + "@workspace/shared": "workspace:*", + "@workspace/utils": "workspace:*" + }, + "devDependencies": { + "@types/lodash.isequal": "^4.5.8", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@types/react-slick": "^0.23.12", + "@typescript-eslint/eslint-plugin": "^6.10.0", + "@typescript-eslint/parser": "^6.10.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "eslint": "^9.34.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "postcss-pxtorem": "^6.0.0", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} diff --git a/projects/translate-h5/src/api/mock.ts b/projects/translate-h5/src/api/mock.ts new file mode 100644 index 0000000..0e65ac0 --- /dev/null +++ b/projects/translate-h5/src/api/mock.ts @@ -0,0 +1,37 @@ +import useAxios from 'axios-hooks'; +import {Page, Result} from "@/types/http"; + +export interface MockResult { + id: number; +} + +export interface MockPage { + id: number; +} + +/** + * fetch the data + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchXXX = () => { + // set the url + const url = `/xxx/xxx`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} + + +/** + * fetch the data with page + * 详细使用可以查看 useAxios 的文档 + */ +export const useFetchPageXXX = (page: number, size: number) => { + // set the url + const url = `/xxx/xxx?page=${page}&size=${size}`; + // fetch the data + const [{data, loading, error}, refetch] = useAxios>(url); + // to do something + return {data, loading, error, refetch}; +} \ No newline at end of file diff --git a/projects/translate-h5/src/assets/translate/cat.svg b/projects/translate-h5/src/assets/translate/cat.svg new file mode 100644 index 0000000..3457da1 --- /dev/null +++ b/projects/translate-h5/src/assets/translate/cat.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/translate-h5/src/assets/translate/dog.svg b/projects/translate-h5/src/assets/translate/dog.svg new file mode 100644 index 0000000..aa4b8b8 --- /dev/null +++ b/projects/translate-h5/src/assets/translate/dog.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/translate-h5/src/assets/translate/microphone.svg b/projects/translate-h5/src/assets/translate/microphone.svg new file mode 100644 index 0000000..6499bd9 --- /dev/null +++ b/projects/translate-h5/src/assets/translate/microphone.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/projects/translate-h5/src/assets/translate/microphoneDisabledSvg.svg b/projects/translate-h5/src/assets/translate/microphoneDisabledSvg.svg new file mode 100644 index 0000000..463ff15 --- /dev/null +++ b/projects/translate-h5/src/assets/translate/microphoneDisabledSvg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/projects/translate-h5/src/assets/translate/pig.svg b/projects/translate-h5/src/assets/translate/pig.svg new file mode 100644 index 0000000..b1ece1b --- /dev/null +++ b/projects/translate-h5/src/assets/translate/pig.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/translate-h5/src/assets/translate/playing.svg b/projects/translate-h5/src/assets/translate/playing.svg new file mode 100644 index 0000000..191166a --- /dev/null +++ b/projects/translate-h5/src/assets/translate/playing.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/translate-h5/src/composables/authorization.ts b/projects/translate-h5/src/composables/authorization.ts new file mode 100644 index 0000000..b622afa --- /dev/null +++ b/projects/translate-h5/src/composables/authorization.ts @@ -0,0 +1,42 @@ +import {useState, useEffect} from 'react'; + +export const STORAGE_AUTHORIZE_KEY = 'token'; + +export const useAuthorization = () => { + const [currentToken, setCurrentToken] = useState(localStorage.getItem(STORAGE_AUTHORIZE_KEY)); + + // 同步 localStorage 变更 + useEffect(() => { + const handleStorageChange = (event: StorageEvent) => { + if (event.key === STORAGE_AUTHORIZE_KEY) { + setCurrentToken(localStorage.getItem(STORAGE_AUTHORIZE_KEY)); + } + }; + + window.addEventListener('storage', handleStorageChange); + return () => { + window.removeEventListener('storage', handleStorageChange); + }; + }, []); + + const login = (token: string) => { + localStorage.setItem(STORAGE_AUTHORIZE_KEY, token); + setCurrentToken(token); + }; + + const logout = () => { + localStorage.removeItem(STORAGE_AUTHORIZE_KEY); + setCurrentToken(null); + }; + + const isLogin = () => { + return !!currentToken; + }; + + return { + token: currentToken, + login, + logout, + isLogin + }; +}; diff --git a/projects/translate-h5/src/composables/language.ts b/projects/translate-h5/src/composables/language.ts new file mode 100644 index 0000000..9c808aa --- /dev/null +++ b/projects/translate-h5/src/composables/language.ts @@ -0,0 +1,21 @@ +import {setDefaultConfig} from 'antd-mobile'; +import enUS from 'antd-mobile/es/locales/en-US'; +import zhCN from 'antd-mobile/es/locales/zh-CN'; + +function changeLanguage(language: string) { + let locale; + switch (language) { + case 'zh_CN': + locale = enUS; + break; + case 'en_US': + locale = zhCN; + break; + default: + locale = enUS; // 或者是你的默认语言 + } + + setDefaultConfig({locale: locale}); +} + +export default changeLanguage; diff --git a/projects/translate-h5/src/enum/http-enum.ts b/projects/translate-h5/src/enum/http-enum.ts new file mode 100644 index 0000000..03938d1 --- /dev/null +++ b/projects/translate-h5/src/enum/http-enum.ts @@ -0,0 +1,21 @@ +/** + * @description: request method + */ +export enum RequestEnum { + GET = 'GET', + POST = 'POST', + PUT = 'PUT', + DELETE = 'DELETE', +} + +/** + * @description: contentType + */ +export enum ContentTypeEnum { + // json + JSON = 'application/json;charset=UTF-8', + // form-data qs + FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8', + // form-data upload + FORM_DATA = 'multipart/form-data;charset=UTF-8', +} diff --git a/projects/translate-h5/src/http/axios-instance.ts b/projects/translate-h5/src/http/axios-instance.ts new file mode 100644 index 0000000..2bef525 --- /dev/null +++ b/projects/translate-h5/src/http/axios-instance.ts @@ -0,0 +1,49 @@ +import Axios, { + AxiosError, + AxiosInstance as AxiosType, + AxiosResponse, + InternalAxiosRequestConfig +} from 'axios'; +import {STORAGE_AUTHORIZE_KEY} from "@/composables/authorization.ts"; + +export interface ResponseBody { + code: number; + data?: T; + msg: string; +} + +async function requestHandler(config: InternalAxiosRequestConfig): Promise { + const token = localStorage.getItem(STORAGE_AUTHORIZE_KEY); + if (token) { + config.headers[STORAGE_AUTHORIZE_KEY] = token; + } + return config; +} + +function responseHandler(response: AxiosResponse): AxiosResponse { + // 响应拦截器逻辑... + return response; +} + +function errorHandler(error: AxiosError): Promise> { + // 错误处理逻辑... + return Promise.reject(error); +} + +class AxiosInstance { + private readonly instance: AxiosType; + + constructor(baseURL: string) { + this.instance = Axios.create({baseURL}); + + this.instance.interceptors.request.use(requestHandler, errorHandler); + this.instance.interceptors.response.use(responseHandler, errorHandler); + } + + public getInstance(): AxiosType { + return this.instance; + } +} + +const baseURL = import.meta.env.VITE_BASE_URL || 'http://127.0.0.1:8080'; +export const axiosInstance = new AxiosInstance(baseURL).getInstance(); diff --git a/projects/translate-h5/src/index.css b/projects/translate-h5/src/index.css new file mode 100644 index 0000000..06b9df1 --- /dev/null +++ b/projects/translate-h5/src/index.css @@ -0,0 +1,103 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Roboto", sans-serif; +} + +a, +button, +input, +textarea { + -webkit-tap-highlight-color: transparent; +} + +html { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; + font-size: 100%; +} + +/* 小屏幕设备(如手机)*/ +@media (max-width: 600px) { + html { + font-size: 90%; /* 字体稍微小一点 */ + } +} + +/* 中等屏幕设备(如平板)*/ +@media (min-width: 601px) and (max-width: 1024px) { + html { + font-size: 100%; /* 标准大小 */ + } +} + +/* 大屏幕设备(如桌面)*/ +@media (min-width: 1025px) { + html { + font-size: 110%; /* 字体稍微大一点 */ + } +} + +* { + -webkit-backface-visibility: hidden; + -moz-backface-visibility: hidden; + -ms-backface-visibility: hidden; +} + +a { + text-decoration: none; + color: inherit; +} + +img { + max-width: 100%; + height: auto; +} + +.ec-navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background-color: #fff; + border-bottom: 1px solid #e5e5e5; + -webkit-transition: all 0.3s; + transition: all 0.3s; +} + +:root { + --primary-color: #ffc300; +} + +:root:root { + /* --adm-color-primary: #FFC300; + --adm-color-success: #00b578; + --adm-color-warning: #ff8f1f; + --adm-color-danger: #ff3141; + + --adm-color-white: #ffffff; + --adm-color-text: #333333; + --adm-color-text-secondary: #666666; + --adm-color-weak: #999999; + --adm-color-light: #cccccc; + --adm-color-border: #eeeeee; + --adm-color-box: #f5f5f5; + --adm-color-background: #ffffff; + + --adm-font-size-main: var(--adm-font-size-5); */ + + --adm-font-family: -apple-system, blinkmacsystemfont, "Helvetica Neue", + helvetica, segoe ui, arial, roboto, "PingFang SC", "miui", + "Hiragino Sans GB", "Microsoft Yahei", sans-serif; +} +.i-icon { + height: 100%; +} +svg { + height: 100%; +} diff --git a/projects/translate-h5/src/layout/main/index.less b/projects/translate-h5/src/layout/main/index.less new file mode 100644 index 0000000..fe18e79 --- /dev/null +++ b/projects/translate-h5/src/layout/main/index.less @@ -0,0 +1,20 @@ +.main-layout { + height: 100%; + .layout-content { + height: 100%; + overflow-y: auto; + // padding-bottom: 150px; + padding-bottom: constant(safe-area-inset-bottom); /*兼容 IOS<11.2*/ + padding-bottom: env(safe-area-inset-bottom); /*兼容 IOS>11.2*/ + } + + .layout-tab { + flex: 0 0 auto; + position: fixed; + bottom: 0; + width: 100%; + z-index: 1000; + background-color: #fff; + box-shadow: 0 0 3px rgba(0, 0, 0, 0.1); + } +} diff --git a/projects/translate-h5/src/layout/main/mainLayout.tsx b/projects/translate-h5/src/layout/main/mainLayout.tsx new file mode 100644 index 0000000..51282c5 --- /dev/null +++ b/projects/translate-h5/src/layout/main/mainLayout.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { NavBar, SafeArea, TabBar, Toast } from "antd-mobile"; +import { useNavigate, useLocation } from "react-router-dom"; +import { User, CattleZodiac } from "@icon-park/react"; +import "./index.less"; + +interface MainLayoutProps { + children: React.ReactNode; + isShowNavBar?: boolean; + title?: string; + onLink?: () => void; +} + +const MainLayout: React.FC = ({ + isShowNavBar, + children, + onLink, + title, +}) => { + const navigate = useNavigate(); + const location = useLocation(); + const { pathname } = location; + const [activeKey, setActiveKey] = React.useState(pathname); + + const setRouteActive = (value: string) => { + if (value !== "/") { + Toast.show("待开发"); + } + }; + + const tabs = [ + { + key: "/", + title: "宠物翻译", + icon: , + }, + { + key: "/set", + title: "待办", + icon: , + }, + { + key: "/message", + title: "消息", + icon: , + }, + { + key: "/me", + title: "我的", + + icon: , + }, + ]; + + const goBack = () => { + if (onLink) { + onLink?.(); + } else { + navigate(-1); + } + }; + return ( +
+ + {isShowNavBar ? {title} : ""} +
{children}
+ +
+ {/* setRouteActive(value)} + safeArea={true} + > + {tabs.map((item) => ( + + ))} + */} +
+
+ ); +}; + +export default MainLayout; diff --git a/projects/translate-h5/src/locales/en_US.json b/projects/translate-h5/src/locales/en_US.json new file mode 100644 index 0000000..373a607 --- /dev/null +++ b/projects/translate-h5/src/locales/en_US.json @@ -0,0 +1,3 @@ +{ + "index.title": "Hello World!" +} diff --git a/projects/translate-h5/src/locales/zh_CN.json b/projects/translate-h5/src/locales/zh_CN.json new file mode 100644 index 0000000..fb48ba5 --- /dev/null +++ b/projects/translate-h5/src/locales/zh_CN.json @@ -0,0 +1,3 @@ +{ + "index.title": "你好,世界!" +} \ No newline at end of file diff --git a/projects/translate-h5/src/main.tsx b/projects/translate-h5/src/main.tsx new file mode 100644 index 0000000..638931a --- /dev/null +++ b/projects/translate-h5/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "@/view/app/App.tsx"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + // + + + + // +); diff --git a/projects/translate-h5/src/route/auth.tsx b/projects/translate-h5/src/route/auth.tsx new file mode 100644 index 0000000..1406e73 --- /dev/null +++ b/projects/translate-h5/src/route/auth.tsx @@ -0,0 +1,29 @@ +import React, {useEffect} from 'react'; +import {useNavigate} from 'react-router-dom'; + +interface AuthRouteProps { + children: React.ReactNode; + auth?: boolean; +} + +/** + * 认证路由 + * @param children 子组件 + * @param auth 是否需要认证 + * @constructor 认证路由组件 + */ +const AuthRoute: React.FC = ({children, auth}) => { + const navigate = useNavigate(); + const token = localStorage.getItem('token'); // 或者其他认证令牌的获取方式 + const isAuthenticated = Boolean(token); // 认证逻辑 + + useEffect(() => { + if (auth && !isAuthenticated) { + navigate('/login'); // 如果未认证且路由需要认证,则重定向到登录 + } + }, [auth, isAuthenticated, navigate]); + + return <>{children}; +}; + +export default AuthRoute; diff --git a/projects/translate-h5/src/route/render-routes.tsx b/projects/translate-h5/src/route/render-routes.tsx new file mode 100644 index 0000000..12d6537 --- /dev/null +++ b/projects/translate-h5/src/route/render-routes.tsx @@ -0,0 +1,27 @@ +import {Route, Routes} from 'react-router-dom'; +import {routes, AppRoute} from './routes'; +import AuthRoute from './auth.tsx'; + +/** + * 渲染路由 + * @constructor RenderRoutes + */ +export const RenderRoutes = () => { + const renderRoutes = (routes: AppRoute[]) => { + return routes.map(route => ( + + {route.element} + + } + > + {route.children && renderRoutes(route.children)} + + )); + }; + + return {renderRoutes(routes)}; +}; diff --git a/projects/translate-h5/src/route/routes.tsx b/projects/translate-h5/src/route/routes.tsx new file mode 100644 index 0000000..44b59ee --- /dev/null +++ b/projects/translate-h5/src/route/routes.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import Home from "@/view/home"; +import TranslateDetail from "@/view/home/detail"; +import Setting from "@/view/setting"; +import Page404 from "@/view/error/page404"; +export interface AppRoute { + path: string; + element: React.ReactNode; + auth?: boolean; + children?: AppRoute[]; +} + +export const routes: AppRoute[] = [ + { path: "/", element: , auth: false }, + { path: "/set", element: , auth: false }, + { path: "/detail", element: , auth: false }, + { path: "/mood", element: , auth: false }, + { path: "*", element: , auth: false }, +]; diff --git a/projects/translate-h5/src/store/i18n.ts b/projects/translate-h5/src/store/i18n.ts new file mode 100644 index 0000000..c7b7aee --- /dev/null +++ b/projects/translate-h5/src/store/i18n.ts @@ -0,0 +1,21 @@ +import {create} from 'zustand' +import {persist, createJSONStorage} from 'zustand/middleware' + +export const LANG_KEY = 'lang' +export const useI18nStore = create()( + persist( + (set, get) => ({ + lang: 'en_US', + changeLanguage: (lang) => set({lang}), + toggleLanguage: () => set(() => { + return { + lang: get().lang.includes('zh') ? 'en_US' : 'zh_CN' + } + }) + }), + { + name: 'i18n-storage', + storage: createJSONStorage(() => localStorage) + }, + ), +) \ No newline at end of file diff --git a/projects/translate-h5/src/store/user.ts b/projects/translate-h5/src/store/user.ts new file mode 100644 index 0000000..f87b10e --- /dev/null +++ b/projects/translate-h5/src/store/user.ts @@ -0,0 +1,22 @@ +import {create} from 'zustand' +import {persist, createJSONStorage} from 'zustand/middleware' + +export const useUserStore = create()( + persist( + (set) => ({ + user: { + name: '', + avatar: '', + email: '' + }, + setUser: (user: User) => set({user}), + clearUser: () => set({ + user: null + }), + }), + { + name: 'user-storage', + storage: createJSONStorage(() => localStorage) + }, + ), +) \ No newline at end of file diff --git a/projects/translate-h5/src/types/chat.ts b/projects/translate-h5/src/types/chat.ts new file mode 100644 index 0000000..22ec78a --- /dev/null +++ b/projects/translate-h5/src/types/chat.ts @@ -0,0 +1,41 @@ +// types/chat.ts +// types/chat.ts (更新) +export interface VoiceMessage { + id: string; + type: "voice"; + content: { + duration: number; + url?: string; + localId?: string; + blob?: Blob; + waveform?: number[]; + // 新增上传相关字段 + fileId?: string; + fileName?: string; + serverUrl?: string; + uploadStatus?: "uploading" | "success" | "error"; + uploadProgress?: number; + }; + sender: "user" | "pet"; + timestamp: number; + isPlaying?: boolean; + translation?: string; + translating?: boolean; +} + +export interface TextMessage { + id: string; + type: "text"; + content: string; + sender: "user" | "pet"; + timestamp: number; +} + +export type ChatMessage = VoiceMessage | TextMessage; + +export interface PetProfile { + name: string; + avatar: string; + species: "dog" | "cat" | "bird" | "other"; + personality: string; +} diff --git a/projects/translate-h5/src/types/http.d.ts b/projects/translate-h5/src/types/http.d.ts new file mode 100644 index 0000000..019239c --- /dev/null +++ b/projects/translate-h5/src/types/http.d.ts @@ -0,0 +1,14 @@ +interface Page { + total: number; + size: number; + current: number; + pages: number; + records: T[]; +} + +export interface Result { + success: boolean; + code: number; + message: string; + data: T; +} \ No newline at end of file diff --git a/projects/translate-h5/src/types/store.d.ts b/projects/translate-h5/src/types/store.d.ts new file mode 100644 index 0000000..c238023 --- /dev/null +++ b/projects/translate-h5/src/types/store.d.ts @@ -0,0 +1,19 @@ +interface User { + name: string; + avatar: string; + email: string; +} + +interface UserState { + user: User | null; + setUser: (user: User) => void; + clearUser: () => void; +} + +type lang = 'zh_CN' | 'en_US' + +interface LangStore { + lang: lang; + changeLanguage: (lang: lang) => void + toggleLanguage: () => void +} \ No newline at end of file diff --git a/projects/translate-h5/src/types/upload.ts b/projects/translate-h5/src/types/upload.ts new file mode 100644 index 0000000..64e0400 --- /dev/null +++ b/projects/translate-h5/src/types/upload.ts @@ -0,0 +1,34 @@ +// types/upload.ts +export interface UploadConfig { + url: string; + method?: "POST" | "PUT"; + headers?: Record; + fieldName?: string; + maxFileSize?: number; + allowedTypes?: string[]; +} + +export interface UploadProgress { + loaded: number; + total: number; + percentage: number; +} + +export interface UploadResponse { + success: boolean; + data?: { + fileId: string; + fileName: string; + fileUrl: string; + duration: number; + size: number; + }; + error?: string; +} + +export interface VoiceUploadStatus { + status: "idle" | "uploading" | "success" | "error"; + progress?: UploadProgress; + response?: UploadResponse; + error?: string; +} diff --git a/projects/translate-h5/src/utils/amount.ts b/projects/translate-h5/src/utils/amount.ts new file mode 100644 index 0000000..6db3cae --- /dev/null +++ b/projects/translate-h5/src/utils/amount.ts @@ -0,0 +1,3 @@ +export function toYuan(num: number) { + return (num / 100).toFixed(2); +} \ No newline at end of file diff --git a/projects/translate-h5/src/utils/index.ts b/projects/translate-h5/src/utils/index.ts new file mode 100644 index 0000000..5a21bf8 --- /dev/null +++ b/projects/translate-h5/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './amount'; +export * from './location'; \ No newline at end of file diff --git a/projects/translate-h5/src/utils/js-audio-recorder.d.ts b/projects/translate-h5/src/utils/js-audio-recorder.d.ts new file mode 100644 index 0000000..a8b6c3c --- /dev/null +++ b/projects/translate-h5/src/utils/js-audio-recorder.d.ts @@ -0,0 +1,22 @@ +declare module "js-audio-recorder" { + export interface RecorderOptions { + sampleBits?: number; + sampleRate?: number; + numChannels?: number; + compiling?: boolean; + } + + export default class Recorder { + constructor(options?: RecorderOptions); + start(): Promise; + pause(): void; + resume(): void; + stop(): void; + getBlob(): Blob; + getWAVBlob(): Blob; + destroy(): void; + duration: number; + fileSize: number; + isrecording: boolean; + } +} diff --git a/projects/translate-h5/src/utils/location.ts b/projects/translate-h5/src/utils/location.ts new file mode 100644 index 0000000..5a997c7 --- /dev/null +++ b/projects/translate-h5/src/utils/location.ts @@ -0,0 +1,3 @@ +export function toKm(m: number): number { + return m / 1000; +} \ No newline at end of file diff --git a/projects/translate-h5/src/utils/voice.ts b/projects/translate-h5/src/utils/voice.ts new file mode 100644 index 0000000..b4d8cf1 --- /dev/null +++ b/projects/translate-h5/src/utils/voice.ts @@ -0,0 +1,164 @@ +export const mockTranslateAudio = async (): Promise => { + 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 => { + 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); + }); +}; diff --git a/projects/translate-h5/src/view/app/App.tsx b/projects/translate-h5/src/view/app/App.tsx new file mode 100644 index 0000000..c21aabd --- /dev/null +++ b/projects/translate-h5/src/view/app/App.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { RenderRoutes } from "@/route/render-routes.tsx"; +import { axiosInstance } from "@/http/axios-instance.ts"; +import { configure } from "axios-hooks"; +import enUS from "antd-mobile/es/locales/en-US"; +import { ConfigProvider } from "antd-mobile"; +import { useI18nStore } from "@/store/i18n.ts"; +import zhCN from "antd-mobile/es/locales/zh-CN"; + +function App() { + configure({ + axios: axiosInstance, + }); + const i18nStore = useI18nStore(); + return ( + <> + + + + + ); +} + +export default App; diff --git a/projects/translate-h5/src/view/archives/index.less b/projects/translate-h5/src/view/archives/index.less new file mode 100644 index 0000000..e69de29 diff --git a/projects/translate-h5/src/view/archives/index.tsx b/projects/translate-h5/src/view/archives/index.tsx new file mode 100644 index 0000000..efe1ada --- /dev/null +++ b/projects/translate-h5/src/view/archives/index.tsx @@ -0,0 +1,13 @@ +// 档案 +import MainLayout from "@/layout/main/mainLayout"; +import "./index.less"; + +function Index() { + return ( + +
+
+ ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/error/page404.tsx b/projects/translate-h5/src/view/error/page404.tsx new file mode 100644 index 0000000..c9032f8 --- /dev/null +++ b/projects/translate-h5/src/view/error/page404.tsx @@ -0,0 +1,18 @@ +import MainLayout from "@/layout/main/mainLayout"; +import { Button } from "antd-mobile"; +import { useNavigate } from "react-router-dom"; +function Index() { + const navigate = useNavigate(); + const goHome = () => { + navigate("/"); + }; + return ( + + + + ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/component/index.less b/projects/translate-h5/src/view/home/component/index.less new file mode 100644 index 0000000..e69de29 diff --git a/projects/translate-h5/src/view/home/component/message/index.less b/projects/translate-h5/src/view/home/component/message/index.less new file mode 100644 index 0000000..3d6e700 --- /dev/null +++ b/projects/translate-h5/src/view/home/component/message/index.less @@ -0,0 +1,46 @@ +.message { + flex: 1 auto; + overflow-y: auto; + padding: 12px; + .item { + color: rgba(0, 0, 0, 0.45); + font-size: 12px; + display: flex; + gap: 12px; + margin-bottom: 20px; + } + .avatar { + width: 40px; + height: 40px; + border-radius: 40px; + background: #eee; + } + .voice-container { + display: flex; + align-items: center; + justify-content: space-between; + background: rgba(0, 0, 0, 0.02); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 8px; + margin-top: 8px; + padding-right: 12px; + .time { + color: rgba(0, 0, 0, 0.88); + } + .tips { + // align-self: stretch; + // display: grid; + // align-items: center; + color: rgba(0, 0, 0, 0.45); + } + } + .translate { + display: flex; + gap: 12px; + padding: 12px; + align-items: center; + background: rgba(0, 0, 0, 0.02); + border-radius: 8px; + margin-top: 12px; + } +} diff --git a/projects/translate-h5/src/view/home/component/message/index.tsx b/projects/translate-h5/src/view/home/component/message/index.tsx new file mode 100644 index 0000000..9128203 --- /dev/null +++ b/projects/translate-h5/src/view/home/component/message/index.tsx @@ -0,0 +1,176 @@ +import { useEffect, useRef, useState } from "react"; +import { Divider, Image, SpinLoading, Toast } from "antd-mobile"; +import { VoiceIcon } from "@workspace/shared"; +import dogSvg from "@/assets/translate/dog.svg"; +import catSvg from "@/assets/translate/cat.svg"; +import pigSvg from "@/assets/translate/pig.svg"; +import { Message } from "../../types"; +import "./index.less"; + +interface DefinedProps { + data: Message[]; + isRecording: boolean; +} + +function Index(props: DefinedProps) { + const { data, isRecording } = props; + const audioRefs = useRef<{ [key: string]: HTMLAudioElement }>({}); + const [isPlaying, setIsPlating] = useState(false); + const [currentPlayingId, setCurrentPlayingId] = useState(); + + useEffect(() => { + if (isRecording) { + stopAllAudio(); + } + }, [isRecording]); + const onVoiceChange = () => { + setIsPlating(!isPlaying); + }; + const playAudio = (messageId: number, audioUrl: string) => { + if (isRecording) { + Toast.show("录音中,无法播放音频"); + return; + } + + if (currentPlayingId === messageId) { + if (audioRefs.current[messageId]) { + audioRefs.current[messageId].pause(); + audioRefs.current[messageId].currentTime = 0; + } + setCurrentPlayingId(undefined); + setIsPlating(false); + return; + } + + stopAllAudio(); + if (!audioRefs.current[messageId]) { + audioRefs.current[messageId] = new Audio(audioUrl); + } + + const audio = audioRefs.current[messageId]; + audio.currentTime = 0; + + audio.onended = () => { + setCurrentPlayingId(undefined); + setIsPlating(false); + }; + + audio.onerror = (error) => { + console.error("音频播放错误:", error); + Toast.show("音频播放失败"); + setIsPlating(false); + }; + + audio + .play() + .then(() => { + setCurrentPlayingId(messageId); + setIsPlating(true); + }) + .catch((error) => { + console.error("音频播放失败:", error); + Toast.show("音频播放失败"); + }); + }; + const stopAllAudio = () => { + if (currentPlayingId && audioRefs.current[currentPlayingId]) { + audioRefs.current[currentPlayingId].pause(); + audioRefs.current[currentPlayingId].currentTime = 0; + setIsPlating(false); + setCurrentPlayingId(undefined); + } + + Object.values(audioRefs.current).forEach((audio) => { + if (!audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + }); + }; + const renderAvatar = (type?: "pig" | "cat" | "dog") => { + if (type === "pig") { + ; + } + if (type === "cat") { + return ( + + ); + } + return ( + + ); + }; + + return ( +
+ {data.map((item, index) => ( +
playAudio(item.id, item.audioUrl)} + > + {renderAvatar(item.type)} +
+
+ {item.name} + + {item.timestamp} +
+
+ +
{item.duration}''
+
+ {item.isTranslating ? ( +
+ + 翻译中... +
+ ) : ( +
{item.translatedText}
+ )} +
+
+ ))} + +
+
+
+
+ 生无可恋喵 + + 15:00 +
+
+ +
+ {isRecording ? "录制中..." : "轻点麦克风录制"} +
+
+
+
+
+ ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/component/search/index.less b/projects/translate-h5/src/view/home/component/search/index.less new file mode 100644 index 0000000..7289ace --- /dev/null +++ b/projects/translate-h5/src/view/home/component/search/index.less @@ -0,0 +1,22 @@ +.search { + display: flex; + gap: 12px; + width: 100%; + .adm-search-bar-input-box-icon { + display: flex; + align-items: center; + } + .adm-search-bar { + flex: 1 auto; + } + .all { + height: 34px !important; + background: var(--adm-color-fill-content); + border-radius: 6px; + padding: 0px 12px; + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.88); + gap: 6px; + } +} diff --git a/projects/translate-h5/src/view/home/component/search/index.tsx b/projects/translate-h5/src/view/home/component/search/index.tsx new file mode 100644 index 0000000..d371724 --- /dev/null +++ b/projects/translate-h5/src/view/home/component/search/index.tsx @@ -0,0 +1,87 @@ +import { DownOne } from "@icon-park/react"; +import { + ActionSheet, + Dropdown, + type DropdownRef, + Popup, + Radio, + SearchBar, + Space, +} from "antd-mobile"; +import { RadioValue } from "antd-mobile/es/components/radio"; +import { useRef, useState } from "react"; + +interface PropsConfig { + handleAllAni: () => void; +} +const allAni = ["全部宠物", "丑丑", "胖胖", "可可"]; +function Index(props: PropsConfig) { + const [aniName, setAniName] = useState("全部宠物"); + const animenuRef = useRef(null); + const handleAniSelect = (val: RadioValue) => { + setAniName(allAni[val as number]); + animenuRef.current?.close(); + }; + + return ( +
+ + + +
+ + + + 全部宠物 + + + 丑丑 + + + 胖胖 + + + 可可 + + + +
+
+
+ {/*
+ 全部宠物 + +
+ setVisible(false)} + /> */} + {/* { + setVisible(false); + }} + > +
+ 宠物 +
+ 11111 +
*/} +
+ ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/component/voice/index.less b/projects/translate-h5/src/view/home/component/voice/index.less new file mode 100644 index 0000000..2cfcb68 --- /dev/null +++ b/projects/translate-h5/src/view/home/component/voice/index.less @@ -0,0 +1,54 @@ +.voice-record { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding: 12px 0px; + box-shadow: 1px 2px 4px 3px #eee; + .adm-progress-circle-info { + height: 32px; + } + .isRecording { + .adm-progress-circle { + border-radius: 50%; + animation: recordingButtonPulse 1s infinite; + } + } + .tips { + color: #9f9f9f; + } + .circle { + display: inline-block; + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(22, 119, 255, 1); + } + + .cancleBtn { + position: absolute; + border: 2px solid rgba(230, 244, 255, 1); + width: 72px; + height: 72px; + right: 60px; + border-radius: 50%; + top: 50%; + transform: translateY(-50%); + } + + @keyframes recordingButtonPulse { + 0% { + box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5); + } + 30% { + box-shadow: 0 0 0 14px rgba(255, 77, 79, 0); + } + 70% { + box-shadow: 0 0 0 14px rgba(255, 77, 79, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(22, 119, 255, 0.5); + } + } +} diff --git a/projects/translate-h5/src/view/home/component/voice/index.tsx b/projects/translate-h5/src/view/home/component/voice/index.tsx new file mode 100644 index 0000000..196ec00 --- /dev/null +++ b/projects/translate-h5/src/view/home/component/voice/index.tsx @@ -0,0 +1,277 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AudioRecorder, useAudioRecorder } from "react-audio-voice-recorder"; +import { Button, Dialog, Image, ProgressCircle, Toast } from "antd-mobile"; +import microphoneSvg from "@/assets/translate/microphone.svg"; +import microphoneDisabledSvg from "@/assets/translate/microphoneDisabledSvg.svg"; +import { createStartRecordSound, createSendSound } from "@/utils/voice"; +import "./index.less"; +import { Message } from "../../types"; +import { CloseCircleOutline } from "antd-mobile-icons"; + +interface DefinedProps { + onRecordingComplete: (url: string, finalDuration: number) => void; + isRecording: boolean; + onSetIsRecording: (flag: boolean) => void; +} +function Index(props: DefinedProps) { + const { isRecording } = props; + const [hasPermission, setHasPermission] = useState(false); //是否有权限 + const [isPermissioning, setIsPermissioning] = useState(true); //获取权限中 + const [recordingDuration, setRecordingDuration] = useState(0); //录音时长进度 + const [isModal, setIsModal] = useState(false); + const recordingTimerRef = useRef(); + const isCancelledRef = useRef(false); + const recordingStartTimeRef = useRef(0); //录音时长 + // 音效相关 + const sendSoundRef = useRef(null); + const startRecordSoundRef = useRef(null); + useEffect(() => { + initializeSounds(); + checkMicrophonePermission(); + }, [hasPermission]); + + useEffect(() => { + if (isRecording) { + recorderControls.startRecording(); + } else { + } + }, [isRecording]); + + //重置状态 + const onResetRecordingState = () => { + props.onSetIsRecording(false); + setRecordingDuration(0); + recordingStartTimeRef.current = 0; + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + }; + + const initializeSounds = () => { + try { + // 发送音效 - 使用Web Audio API生成 + sendSoundRef.current = createSendSound(); + + // 开始录音音效 + startRecordSoundRef.current = createStartRecordSound(); + + console.log("音效初始化完成"); + } catch (error) { + console.error("音效初始化失败:", error); + } + }; + + const renderBtn = useCallback(() => { + if (!hasPermission) { + //没有权限 + return ( + <> + + {isPermissioning ? ( +
获取麦克风权限中...
+ ) : ( +
麦克风没有开启权限
+ )} + + ); + } + if (isRecording) { + //正在录音中 + return ( +
+ +
+ +
+
+ +
+ ); + } else { + //麦克风状态 + return ( + + ); + } + }, [hasPermission, isRecording, recordingDuration]); + const checkMicrophonePermission = useCallback(async () => { + try { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + setHasPermission(false); + setIsPermissioning(false); + Toast.show("浏览器不支持录音功能"); + return false; + } + + if (navigator.permissions && navigator.permissions.query) { + try { + const permissionStatus = await navigator.permissions.query({ + name: "microphone" as PermissionName, + }); + + if (permissionStatus.state === "denied") { + setHasPermission(false); + setIsPermissioning(false); + setIsModal(true); + + return false; + } + if (permissionStatus.state === "granted") { + setHasPermission(true); + setIsModal(false); + return true; + } + } catch (permError) { + console.log("权限查询不支持,继续使用getUserMedia检查"); + } + } + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + stream.getTracks().forEach((track) => track.stop()); + setHasPermission(true); + return true; + } catch (error: any) { + if (error.message.includes("user denied permission")) { + setIsModal(true); + } + + setHasPermission(false); + return false; + } + }, []); + + useEffect(() => { + if (isModal) { + Dialog.confirm({ + content: "重新获取麦克风权限", + onConfirm: async () => { + setIsModal(true); + await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + setHasPermission(true); + }, + }); + } + }, [isModal]); + + const recorderControls = useAudioRecorder( + { + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + }, + (err) => { + console.error("录音错误:", err); + Toast.show("录音失败,请重试"); + onResetRecordingState(); + } + ); + + // 播放音效 + const playSound = async (soundRef: React.RefObject) => { + try { + if (soundRef.current) { + await soundRef.current.play(); + } + } catch (error: any) { + Toast.show(`播放音效失败:${error.message}`); + } + }; + + //开始录音 + const onStartRecording = () => { + isCancelledRef.current = false; + if (recordingTimerRef.current) { + clearInterval(recordingTimerRef.current); + recordingTimerRef.current = undefined; + } + props.onSetIsRecording(true); + // recorderControls.startRecording(); + recordingStartTimeRef.current = Date.now(); + // 立即开始计时 + recordingTimerRef.current = setInterval(() => { + setRecordingDuration((prev) => prev + 1); + }, 1000); + }; + const onStopRecording = useCallback(() => { + recorderControls.stopRecording(); + onResetRecordingState(); + }, [recorderControls, recordingDuration]); + //录音完成 + // 在发送时检查录音时长 + const onRecordingComplete = useCallback( + (blob: Blob) => { + if (isCancelledRef.current) { + Toast.show("已取消"); + return; + } + // 检查blob有效性 + if (!blob || blob.size === 0) { + Toast.show("录音数据无效,请重新录音"); + + return; + } + const audioUrl = URL.createObjectURL(blob); + const audio = new Audio(); + audio.src = audioUrl; + // 计算实际录音时长 + + audio.addEventListener("loadedmetadata", () => { + if (audio.duration < 1) { + Toast.show("录音时间太短,请重新录音"); + return; + } + alert(audio.duration); + playSound(sendSoundRef); + props.onRecordingComplete?.(audioUrl, Math.floor(audio.duration)); + }); + }, + [isCancelledRef, isRecording, sendSoundRef] + ); + + const cancelRecording = useCallback(() => { + isCancelledRef.current = true; + recorderControls.stopRecording(); + onResetRecordingState(); + }, []); + + return ( + <> + +
{renderBtn()}
+ + ); +} + +export default React.memo(Index); diff --git a/projects/translate-h5/src/view/home/detail.tsx b/projects/translate-h5/src/view/home/detail.tsx new file mode 100644 index 0000000..2717d85 --- /dev/null +++ b/projects/translate-h5/src/view/home/detail.tsx @@ -0,0 +1,5 @@ +function Index() { + return
; +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/index.less b/projects/translate-h5/src/view/home/index.less new file mode 100644 index 0000000..e32b8cc --- /dev/null +++ b/projects/translate-h5/src/view/home/index.less @@ -0,0 +1,39 @@ +.home { + height: 100%; + overflow: hidden; + .adm-tabs { + display: flex; + flex-direction: column; + height: 100%; + } + .adm-tabs-content { + flex: 1; + overflow: hidden; + padding: 0px; + } + .adm-tabs-header { + border: 0 none; + position: sticky; + top: 0px; + background: #fff; + z-index: 99; + } + + .adm-tabs-tab { + font-size: 20px; + color: rgba(0, 0, 0, 0.25); + font-weight: 600; + &.adm-tabs-tab-active { + color: #000; + } + } + .adm-tabs-tab-line { + height: 0px; + } + + .translate-container { + display: flex; + flex-direction: column; + height: 100%; + } +} diff --git a/projects/translate-h5/src/view/home/index.tsx b/projects/translate-h5/src/view/home/index.tsx new file mode 100644 index 0000000..41f03b8 --- /dev/null +++ b/projects/translate-h5/src/view/home/index.tsx @@ -0,0 +1,31 @@ +import MainLayout from "@/layout/main/mainLayout"; +import { Button, Tabs } from "antd-mobile"; +import Translate from "./translate"; +import "./index.less"; + +function Index() { + const handleRecordComplete = (audioData: AudioData): void => { + console.log("录音完成:", audioData); + }; + + const handleError = (error: Error): void => { + console.error("录音错误:", error); + }; + + return ( + +
+ + + + + + 2 + + +
+
+ ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/translate.tsx b/projects/translate-h5/src/view/home/translate.tsx new file mode 100644 index 0000000..0b9701e --- /dev/null +++ b/projects/translate-h5/src/view/home/translate.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useState } from "react"; +import { Image, Toast } from "antd-mobile"; +import MessageCom from "./component/message"; +import VoiceRecord from "./component/voice"; +import { + XPopup, + FloatingMenu, + type FloatMenuItemConfig, +} from "@workspace/shared"; +import type { Message } from "./types"; + +import { mockTranslateAudio } from "@/utils/voice"; +import dogSvg from "@/assets/translate/dog.svg"; +import catSvg from "@/assets/translate/cat.svg"; +import pigSvg from "@/assets/translate/pig.svg"; +import { MoreTwo } from "@icon-park/react"; +interface DefinedProps {} +const menuItems: FloatMenuItemConfig[] = [ + { icon: , type: "dog" }, + { icon: , type: "cat" }, + { icon: , type: "pig" }, + { + icon: ( + + ), + type: "add", + }, +]; +function Index(props: DefinedProps) { + // const data: Message[] = [ + // { + // id: 1, + // audioUrl: "", + // duration: 0, + // translatedText: "", + // isTranslating: true, + // isPlaying: false, + // timestamp: 0, + // }, + // ]; + const [currentPlayingId, setCurrentPlayingId] = useState(null); //当前播放id + const [messages, setMessages] = useState([]); + const [isRecording, setIsRecording] = useState(false); //是否录音中 + const [currentLanguage, setCurrentLanguage] = useState(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + setCurrentLanguage(menuItems[0]); + }, []); + + //完成录音 + const onRecordingComplete = useCallback( + (audioUrl: string, actualDuration: number) => { + const newMessage: Message = { + id: Date.now(), + type: "dog", + audioUrl, + name: "生无可恋喵", + duration: actualDuration, + timestamp: Date.now(), + isTranslating: true, + }; + + setMessages((prev) => [...prev, newMessage]); + setTimeout(() => { + onTranslateAudio(newMessage.id); + }, 1000); + + Toast.show("语音已发送"); + }, + [messages] + ); + + //翻译 + const onTranslateAudio = useCallback( + async (messageId: number) => { + try { + const translatedText = await mockTranslateAudio(); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { ...msg, translatedText, isTranslating: false } + : msg + ) + ); + } catch (error) { + console.error("翻译失败:", error); + Toast.show("翻译失败,请重试"); + + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + isTranslating: false, + translatedText: "翻译失败,请重试", + } + : msg + ) + ); + } + }, + [messages] + ); + + const onSetIsRecording = (flag: boolean) => { + setIsRecording(flag); + }; + + const onLanguage = (item: FloatMenuItemConfig) => { + if (item.type === "add") { + setVisible(true); + } else { + setCurrentLanguage(item); + } + }; + + return ( +
+ + + + { + setVisible(false); + }} + > +
+ 快捷选项 +
+
+
+ 宠物语种 +
+
+
+ ); +} + +export default Index; diff --git a/projects/translate-h5/src/view/home/types.ts b/projects/translate-h5/src/view/home/types.ts new file mode 100644 index 0000000..9e0d223 --- /dev/null +++ b/projects/translate-h5/src/view/home/types.ts @@ -0,0 +1,12 @@ +export interface Message { + id: number; + type?: "dog" | "cat" | "pig"; + audioUrl: string; + name: string; //名字 + duration: number; //时长 + timestamp: number; //时间 + translatedText?: string; + isTranslating?: boolean; + avatar?: string; + isPlaying?: boolean; +} diff --git a/projects/translate-h5/src/view/setting/index.tsx b/projects/translate-h5/src/view/setting/index.tsx new file mode 100644 index 0000000..b7cbbd5 --- /dev/null +++ b/projects/translate-h5/src/view/setting/index.tsx @@ -0,0 +1,13 @@ +import useI18n from "@/hooks/i18n.ts"; +import MainLayout from "@/layout/main/mainLayout"; +import { Button } from "antd-mobile"; +import { useI18nStore } from "@/store/i18n.ts"; + +function Index() { + const t = useI18n(); + const i18nStore = useI18nStore(); + + return qq; +} + +export default Index; diff --git a/projects/translate-h5/src/vite-env.d.ts b/projects/translate-h5/src/vite-env.d.ts new file mode 100644 index 0000000..62e4f5d --- /dev/null +++ b/projects/translate-h5/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file diff --git a/projects/translate-h5/tsconfig.json b/projects/translate-h5/tsconfig.json new file mode 100644 index 0000000..f32e379 --- /dev/null +++ b/projects/translate-h5/tsconfig.json @@ -0,0 +1,30 @@ +{ + // "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"], + "@shared/*": ["packages/shared/src/*"], + "@utils/*": ["packages/utils/src/*"], + "@hooks/*": ["packages/hooks/src/*"] + } + }, + "include": ["src", "../../packages/shared/src", "../../packages/utils/src"] +} diff --git a/projects/translate-h5/tsconfig.node.json b/projects/translate-h5/tsconfig.node.json new file mode 100644 index 0000000..8784fbe --- /dev/null +++ b/projects/translate-h5/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"], + "exclude": ["node_modules"] +} diff --git a/projects/translate-h5/vite.config.ts b/projects/translate-h5/vite.config.ts new file mode 100644 index 0000000..9be4cc1 --- /dev/null +++ b/projects/translate-h5/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "path"; +import basicSsl from "@vitejs/plugin-basic-ssl"; +export default defineConfig({ + // plugins: [react()], + plugins: [react(), basicSsl()], + server: { + https: {}, + port: 3000, + host: "0.0.0.0", + open: true, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "@shared": path.resolve(__dirname, "../../packages/shared/src"), + "@hooks": path.resolve(__dirname, "../../packages/hooks/src"), + "@utils": path.resolve(__dirname, "../../packages/utils/src"), + }, + }, + optimizeDeps: { + include: ["@shared", "@hooks", "@utils"], + }, +}); diff --git a/src/component/floatingMenu/index.tsx b/src/component/floatingMenu/index.tsx index 9fdb2b9..a13588c 100644 --- a/src/component/floatingMenu/index.tsx +++ b/src/component/floatingMenu/index.tsx @@ -9,9 +9,7 @@ import { CheckOutline, } from "antd-mobile-icons"; import { createPortal } from "react-dom"; -import dogSvg from "@/assets/translate/dog.svg"; -import catSvg from "@/assets/translate/cat.svg"; -import pigSvg from "@/assets/translate/pig.svg"; + import "./index.less"; import { MoreTwo } from "@icon-park/react"; diff --git a/src/hooks/useAudioControl.ts b/src/hooks/useAudioControl.ts deleted file mode 100644 index 15245dd..0000000 --- a/src/hooks/useAudioControl.ts +++ /dev/null @@ -1,55 +0,0 @@ -// hooks/useAudioControl.ts -import { useEffect, useState } from "react"; -import AudioManager from "../utils/audioManager"; - -interface UseAudioControlReturn { - currentPlayingId: string | null; - stopAllAudio: () => void; - pauseAllAudio: () => void; - getAudioStates: () => Record< - string, - { - isPlaying: boolean; - duration: number; - currentTime: number; - } - >; -} - -export const useAudioControl = (): UseAudioControlReturn => { - const [currentPlayingId, setCurrentPlayingId] = useState(null); - const audioManager = AudioManager.getInstance(); - - useEffect(() => { - // 定期检查当前播放状态 - const interval = setInterval(() => { - const currentId = audioManager.getCurrentAudioId(); - setCurrentPlayingId(currentId); - }, 500); - - return () => { - clearInterval(interval); - }; - }, [audioManager]); - - const stopAllAudio = () => { - audioManager.stopCurrent(); - setCurrentPlayingId(null); - }; - - const pauseAllAudio = () => { - audioManager.pauseCurrent(); - setCurrentPlayingId(null); - }; - - const getAudioStates = () => { - return audioManager.getAudioStates(); - }; - - return { - currentPlayingId, - stopAllAudio, - pauseAllAudio, - getAudioStates, - }; -}; diff --git a/src/hooks/usePetTranslator.ts b/src/hooks/usePetTranslator.ts deleted file mode 100644 index 71438f1..0000000 --- a/src/hooks/usePetTranslator.ts +++ /dev/null @@ -1,153 +0,0 @@ -// hooks/usePetTranslator.ts -import { useState, useCallback } from "react"; -import { VoiceMessage, ChatMessage, PetProfile } from "../types/chat"; - -interface UsePetTranslatorReturn { - messages: ChatMessage[]; - currentPet: PetProfile; - translateVoice: (voiceMessage: VoiceMessage) => Promise; - addMessage: (message: ChatMessage) => void; - updateMessage: (messageId: string, updatedMessage: ChatMessage) => void; // 新增 - clearMessages: () => void; - setPet: (pet: PetProfile) => void; -} - -export const usePetTranslator = (): UsePetTranslatorReturn => { - const [messages, setMessages] = useState([]); - const [currentPet, setCurrentPet] = useState({ - name: "小汪", - avatar: "🐕", - species: "dog", - personality: "活泼可爱", - }); - - // 模拟翻译API调用 - const translateVoice = useCallback( - async (voiceMessage: VoiceMessage): Promise => { - // 更新消息状态为翻译中 - setMessages((prev) => - prev.map((msg) => - msg.id === voiceMessage.id ? { ...msg, translating: true } : msg - ) - ); - - try { - // 模拟API调用延迟 - await new Promise((resolve) => setTimeout(resolve, 2000)); - - // 模拟翻译结果 - const translations = { - dog: [ - "主人,我饿了!给我点好吃的吧!🍖", - "我想出去玩耍!带我去公园吧!🏃‍♂️", - "我爱你,主人!你是最好的!❤️", - "我想睡觉了,陪我一起休息吧!😴", - "有陌生人!我要保护你!🛡️", - "今天天气真好,我们去散步吧!🌞", - "我想和其他狗狗玩耍!🐕‍🦺", - "主人回来了!我好开心!🎉", - ], - cat: [ - "铲屎官,快来伺候本喵!😼", - "我要晒太阳,别打扰我!☀️", - "给我准备小鱼干!现在就要!🐟", - "我心情不好,离我远点!😾", - "勉强让你摸摸我的头吧!😸", - "这个位置是我的,你不能坐!🛋️", - "我饿了,但我不会告诉你的!🙄", - "今天我心情好,可以陪你玩一会!😺", - ], - bird: [ - "早上好!新的一天开始了!🌅", - "我想唱歌给你听!🎵", - "给我一些种子吧!我饿了!🌱", - "外面的世界真美好!🌳", - "我想和你一起飞翔!🕊️", - "今天的阳光真温暖!☀️", - "我学会了新的歌曲!🎶", - "陪我聊聊天吧!💬", - ], - other: [ - "我想和你交流!👋", - "照顾好我哦!💕", - "我很开心!😊", - "陪我玩一会儿吧!🎾", - "我需要你的关爱!🤗", - "今天过得真愉快!😄", - "我想要你的注意!👀", - "我们是最好的朋友!🤝", - ], - }; - - const speciesTranslations = - translations[currentPet.species] || translations.other; - const randomTranslation = - speciesTranslations[ - Math.floor(Math.random() * speciesTranslations.length) - ]; - - // 更新翻译结果 - setMessages((prev) => - prev.map((msg) => - msg.id === voiceMessage.id - ? { ...msg, translation: randomTranslation, translating: false } - : msg - ) - ); - - // 添加宠物回复 - const petReply: ChatMessage = { - id: `pet_${Date.now()}`, - type: "text", - content: `${currentPet.name}说:${randomTranslation}`, - sender: "pet", - timestamp: Date.now(), - }; - - setMessages((prev) => [...prev, petReply]); - } catch (error) { - console.error("翻译失败:", error); - setMessages((prev) => - prev.map((msg) => - msg.id === voiceMessage.id - ? { ...msg, translation: "翻译失败,请重试", translating: false } - : msg - ) - ); - } - }, - [currentPet] - ); - - const addMessage = useCallback((message: ChatMessage): void => { - setMessages((prev) => [...prev, message]); - }, []); - - // 新增:更新消息方法 - const updateMessage = useCallback( - (messageId: string, updatedMessage: ChatMessage): void => { - setMessages((prev) => - prev.map((msg) => (msg.id === messageId ? updatedMessage : msg)) - ); - }, - [] - ); - - const clearMessages = useCallback((): void => { - setMessages([]); - }, []); - - const setPet = useCallback((pet: PetProfile): void => { - setCurrentPet(pet); - }, []); - - return { - messages, - currentPet, - translateVoice, - addMessage, - updateMessage, // 导出新方法 - clearMessages, - setPet, - }; -}; diff --git a/src/hooks/useVoiceRecorder.ts b/src/hooks/useVoiceRecorder.ts deleted file mode 100644 index 400df19..0000000 --- a/src/hooks/useVoiceRecorder.ts +++ /dev/null @@ -1,125 +0,0 @@ -// hooks/useVoiceRecorder.ts (使用新的录音工具类) -import { useState, useRef, useCallback } from "react"; -import { UniversalAudioRecorder } from "../utils/audioRecorder"; - -interface UseVoiceRecorderReturn { - isRecording: boolean; - recordingTime: number; - isPaused: boolean; - startRecording: () => Promise; - stopRecording: () => Promise; - pauseRecording: () => void; - resumeRecording: () => void; - cancelRecording: () => void; -} - -export const useVoiceRecorder = (): UseVoiceRecorderReturn => { - const [isRecording, setIsRecording] = useState(false); - const [isPaused, setIsPaused] = useState(false); - const [recordingTime, setRecordingTime] = useState(0); - - const recorderRef = useRef(null); - const timerRef = useRef(null); - - const startTimer = useCallback(() => { - timerRef.current = setInterval(() => { - if (recorderRef.current) { - setRecordingTime(recorderRef.current.getDuration()); - } - }, 100); - }, []); - - const stopTimer = useCallback(() => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } - }, []); - - const startRecording = useCallback(async (): Promise => { - try { - const recorder = new UniversalAudioRecorder({ - sampleRate: 16000, - channels: 1, - bitDepth: 16, - }); - - await recorder.start(); - recorderRef.current = recorder; - - setIsRecording(true); - setIsPaused(false); - setRecordingTime(0); - startTimer(); - } catch (error) { - console.error("录音启动失败:", error); - throw error; - } - }, [startTimer]); - - const stopRecording = useCallback(async (): Promise => { - if (!recorderRef.current || !isRecording) return null; - - try { - const audioBlob = await recorderRef.current.stop(); - - setIsRecording(false); - setIsPaused(false); - setRecordingTime(0); - stopTimer(); - - recorderRef.current = null; - return audioBlob; - } catch (error) { - console.error("录音停止失败:", error); - return null; - } - }, [isRecording, stopTimer]); - - const pauseRecording = useCallback((): void => { - if (!recorderRef.current || !isRecording || isPaused) return; - - try { - recorderRef.current.pause(); - setIsPaused(true); - stopTimer(); - } catch (error) { - console.error("录音暂停失败:", error); - } - }, [isRecording, isPaused, stopTimer]); - - const resumeRecording = useCallback((): void => { - if (!recorderRef.current || !isRecording || !isPaused) return; - - try { - recorderRef.current.resume(); - setIsPaused(false); - startTimer(); - } catch (error) { - console.error("录音恢复失败:", error); - } - }, [isRecording, isPaused, startTimer]); - - const cancelRecording = useCallback((): void => { - if (recorderRef.current) { - recorderRef.current.cancel(); - recorderRef.current = null; - } - - setIsRecording(false); - setIsPaused(false); - setRecordingTime(0); - stopTimer(); - }, [stopTimer]); - - return { - isRecording, - recordingTime, - isPaused, - startRecording, - stopRecording, - pauseRecording, - resumeRecording, - cancelRecording, - }; -}; diff --git a/src/layout/main/mainLayout.tsx b/src/layout/main/mainLayout.tsx index 9c18d94..6529c38 100644 --- a/src/layout/main/mainLayout.tsx +++ b/src/layout/main/mainLayout.tsx @@ -7,7 +7,6 @@ import { User, CattleZodiac, } from "@icon-park/react"; -import useI18n from "@/hooks/i18n.ts"; import "./index.less"; interface MainLayoutProps { diff --git a/tsconfig.json b/tsconfig.json index 4a6e1df..b3b7819 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,29 @@ { "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, - - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - // "jsx": "react", - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - - "baseUrl": "./", + "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@workspace/shared": ["packages/shared/src/index.ts"], + "@workspace/shared/*": ["packages/shared/src/*"], + "@workspace/utils": ["packages/utils/src/index.ts"], + "@workspace/utils/*": ["packages/utils/src/*"] } - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + } } diff --git a/tsconfig.node.json b/tsconfig.node.json index 42872c5..4f625df 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -6,5 +6,6 @@ "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts"], + "exclude": ["dist", "node_modules"] }