generated from template/astro-simple-template
	update
This commit is contained in:
		@@ -7,7 +7,7 @@ import tailwindcss from '@tailwindcss/vite';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
const isDev = process.env.NODE_ENV === 'development';
 | 
					const isDev = process.env.NODE_ENV === 'development';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let target = process.env.VITE_API_URL || 'https://localhost:51015';
 | 
					let target = process.env.VITE_API_URL || 'http://localhost:4005';
 | 
				
			||||||
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
 | 
					const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
 | 
				
			||||||
let proxy = {
 | 
					let proxy = {
 | 
				
			||||||
  '/root/': {
 | 
					  '/root/': {
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										14
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								package.json
									
									
									
									
									
								
							@@ -1,14 +1,14 @@
 | 
				
			|||||||
{
 | 
					{
 | 
				
			||||||
  "name": "@kevisual/astro-simplate-template",
 | 
					  "name": "@kevisual/aura-center",
 | 
				
			||||||
  "version": "0.0.1",
 | 
					  "version": "0.0.1",
 | 
				
			||||||
  "description": "",
 | 
					  "description": "",
 | 
				
			||||||
  "main": "index.js",
 | 
					  "main": "index.js",
 | 
				
			||||||
  "basename": "/root/astro-simplate-template",
 | 
					  "basename": "/root/aura-center",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "dev": "astro dev",
 | 
					    "dev": "astro dev",
 | 
				
			||||||
    "build": "astro build",
 | 
					    "build": "astro build",
 | 
				
			||||||
    "preview": "astro preview",
 | 
					    "preview": "astro preview",
 | 
				
			||||||
    "pub": "envision deploy ./dist -k astro-simplate-template -v 0.0.1 -u",
 | 
					    "pub": "envision deploy ./dist -k aura-center -v 0.0.1 -u",
 | 
				
			||||||
    "sn": "pnpm dlx shadcn@latest add "
 | 
					    "sn": "pnpm dlx shadcn@latest add "
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "keywords": [],
 | 
					  "keywords": [],
 | 
				
			||||||
@@ -34,6 +34,7 @@
 | 
				
			|||||||
    "react-dom": "^19.2.0",
 | 
					    "react-dom": "^19.2.0",
 | 
				
			||||||
    "react-toastify": "^11.0.5",
 | 
					    "react-toastify": "^11.0.5",
 | 
				
			||||||
    "tailwind-merge": "^3.3.1",
 | 
					    "tailwind-merge": "^3.3.1",
 | 
				
			||||||
 | 
					    "wavesurfer.js": "^7.11.0",
 | 
				
			||||||
    "zustand": "^5.0.8"
 | 
					    "zustand": "^5.0.8"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "publishConfig": {
 | 
					  "publishConfig": {
 | 
				
			||||||
@@ -48,5 +49,10 @@
 | 
				
			|||||||
    "tailwindcss": "^4.1.14",
 | 
					    "tailwindcss": "^4.1.14",
 | 
				
			||||||
    "tw-animate-css": "^1.4.0"
 | 
					    "tw-animate-css": "^1.4.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "packageManager": "pnpm@10.18.3"
 | 
					  "packageManager": "pnpm@10.18.3",
 | 
				
			||||||
 | 
					  "onlyBuiltDependencies": [
 | 
				
			||||||
 | 
					    "@tailwindcss/oxide",
 | 
				
			||||||
 | 
					    "esbuild",
 | 
				
			||||||
 | 
					    "sharp"
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
							
								
								
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							@@ -62,6 +62,9 @@ importers:
 | 
				
			|||||||
      tailwind-merge:
 | 
					      tailwind-merge:
 | 
				
			||||||
        specifier: ^3.3.1
 | 
					        specifier: ^3.3.1
 | 
				
			||||||
        version: 3.3.1
 | 
					        version: 3.3.1
 | 
				
			||||||
 | 
					      wavesurfer.js:
 | 
				
			||||||
 | 
					        specifier: ^7.11.0
 | 
				
			||||||
 | 
					        version: 7.11.0
 | 
				
			||||||
      zustand:
 | 
					      zustand:
 | 
				
			||||||
        specifier: ^5.0.8
 | 
					        specifier: ^5.0.8
 | 
				
			||||||
        version: 5.0.8(@types/react@19.2.2)(react@19.2.0)
 | 
					        version: 5.0.8(@types/react@19.2.2)(react@19.2.0)
 | 
				
			||||||
@@ -2273,6 +2276,9 @@ packages:
 | 
				
			|||||||
    resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
 | 
					    resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
 | 
				
			||||||
    engines: {node: '>=0.10.0'}
 | 
					    engines: {node: '>=0.10.0'}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  wavesurfer.js@7.11.0:
 | 
				
			||||||
 | 
					    resolution: {integrity: sha512-LOGdIBIKv/roYuQYClhoqhwbIdQL1GfobLnS2vx0heoLD9lu57OUHWE2DIsCNXBvCsmmbkUvJq9W8bPLPbikGw==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  web-namespaces@2.0.1:
 | 
					  web-namespaces@2.0.1:
 | 
				
			||||||
    resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
 | 
					    resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -4918,6 +4924,8 @@ snapshots:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  void-elements@3.1.0: {}
 | 
					  void-elements@3.1.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  wavesurfer.js@7.11.0: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  web-namespaces@2.0.1: {}
 | 
					  web-namespaces@2.0.1: {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  webidl-conversions@3.0.1: {}
 | 
					  webidl-conversions@3.0.1: {}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										3
									
								
								src/apps/record/Wavesurfer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/apps/record/Wavesurfer.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					export const Wavesurfer = () => {
 | 
				
			||||||
 | 
					  return <div>Wavesurfer</div>;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										99
									
								
								src/apps/record/components/Wavesurfer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/apps/record/components/Wavesurfer.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,99 @@
 | 
				
			|||||||
 | 
					import { useEffect, useRef } from "react";
 | 
				
			||||||
 | 
					import WaveSurfer from "wavesurfer.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface WavesurferProps {
 | 
				
			||||||
 | 
					  audioUrl?: string;
 | 
				
			||||||
 | 
					  isPlaying?: boolean;
 | 
				
			||||||
 | 
					  onPlayStateChange?: (isPlaying: boolean) => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const Wavesurfer = ({ 
 | 
				
			||||||
 | 
					  audioUrl, 
 | 
				
			||||||
 | 
					  isPlaying = false, 
 | 
				
			||||||
 | 
					  onPlayStateChange
 | 
				
			||||||
 | 
					}: WavesurferProps) => {
 | 
				
			||||||
 | 
					  const waveformRef = useRef<HTMLDivElement | null>(null);
 | 
				
			||||||
 | 
					  const wavesurferRef = useRef<WaveSurfer | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 播放模式的波形
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (audioUrl && waveformRef.current && !wavesurferRef.current) {
 | 
				
			||||||
 | 
					      wavesurferRef.current = WaveSurfer.create({
 | 
				
			||||||
 | 
					        container: waveformRef.current,
 | 
				
			||||||
 | 
					        waveColor: '#10b981',
 | 
				
			||||||
 | 
					        progressColor: '#059669',
 | 
				
			||||||
 | 
					        cursorColor: '#047857',
 | 
				
			||||||
 | 
					        barWidth: 2,
 | 
				
			||||||
 | 
					        barRadius: 3,
 | 
				
			||||||
 | 
					        height: 60,
 | 
				
			||||||
 | 
					        normalize: true,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      wavesurferRef.current.load(audioUrl);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // 监听播放状态
 | 
				
			||||||
 | 
					      wavesurferRef.current.on('play', () => onPlayStateChange?.(true));
 | 
				
			||||||
 | 
					      wavesurferRef.current.on('pause', () => onPlayStateChange?.(false));
 | 
				
			||||||
 | 
					      wavesurferRef.current.on('finish', () => onPlayStateChange?.(false));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      if (wavesurferRef.current) {
 | 
				
			||||||
 | 
					        wavesurferRef.current.destroy();
 | 
				
			||||||
 | 
					        wavesurferRef.current = null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [audioUrl, onPlayStateChange]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 外部播放控制
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (wavesurferRef.current) {
 | 
				
			||||||
 | 
					      if (isPlaying) {
 | 
				
			||||||
 | 
					        wavesurferRef.current.play();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        wavesurferRef.current.pause();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [isPlaying]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 清理函数
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      if (wavesurferRef.current) {
 | 
				
			||||||
 | 
					        wavesurferRef.current.destroy();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 暴露播放控制方法
 | 
				
			||||||
 | 
					  const togglePlayback = () => {
 | 
				
			||||||
 | 
					    if (wavesurferRef.current) {
 | 
				
			||||||
 | 
					      if (wavesurferRef.current.isPlaying()) {
 | 
				
			||||||
 | 
					        wavesurferRef.current.pause();
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        wavesurferRef.current.play();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // 只在有音频URL时显示
 | 
				
			||||||
 | 
					  if (!audioUrl) {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="mb-4">
 | 
				
			||||||
 | 
					      <div className="bg-white rounded-lg p-3 border">
 | 
				
			||||||
 | 
					        <div ref={waveformRef} className="w-full"></div>
 | 
				
			||||||
 | 
					        <div className="flex items-center justify-center mt-2 text-sm text-gray-500">
 | 
				
			||||||
 | 
					          点击波形或使用播放按钮控制播放
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// 暴露控制方法的接口
 | 
				
			||||||
 | 
					export interface WavesurferRef {
 | 
				
			||||||
 | 
					  togglePlayback: () => void;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										295
									
								
								src/apps/record/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										295
									
								
								src/apps/record/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,295 @@
 | 
				
			|||||||
 | 
					import { useState, useRef } from "react";
 | 
				
			||||||
 | 
					import { getText } from "./modules/get-text";
 | 
				
			||||||
 | 
					import { Wavesurfer } from "./components/Wavesurfer";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface TranscriptionResult {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  text: string;
 | 
				
			||||||
 | 
					  timestamp: Date;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const RecordApp = () => {
 | 
				
			||||||
 | 
					  const [isRecording, setIsRecording] = useState(false);
 | 
				
			||||||
 | 
					  const [recordingTime, setRecordingTime] = useState(0);
 | 
				
			||||||
 | 
					  const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
 | 
				
			||||||
 | 
					  const [audioUrl, setAudioUrl] = useState<string>("");
 | 
				
			||||||
 | 
					  const [isPlaying, setIsPlaying] = useState(false);
 | 
				
			||||||
 | 
					  const [showWaveform, setShowWaveform] = useState(false);
 | 
				
			||||||
 | 
					  const [isTranscribing, setIsTranscribing] = useState(false);
 | 
				
			||||||
 | 
					  const [transcriptions, setTranscriptions] = useState<TranscriptionResult[]>([]);
 | 
				
			||||||
 | 
					  const [currentText, setCurrentText] = useState("");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const mediaRecorderRef = useRef<MediaRecorder | null>(null);
 | 
				
			||||||
 | 
					  const audioRef = useRef<HTMLAudioElement | null>(null);
 | 
				
			||||||
 | 
					  const intervalRef = useRef<any | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const startRecording = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 | 
				
			||||||
 | 
					      const mediaRecorder = new MediaRecorder(stream);
 | 
				
			||||||
 | 
					      mediaRecorderRef.current = mediaRecorder;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const chunks: BlobPart[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder.ondataavailable = (event) => {
 | 
				
			||||||
 | 
					        if (event.data.size > 0) {
 | 
				
			||||||
 | 
					          chunks.push(event.data);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder.onstop = () => {
 | 
				
			||||||
 | 
					        const blob = new Blob(chunks, { type: "audio/webm" });
 | 
				
			||||||
 | 
					        setAudioBlob(blob);
 | 
				
			||||||
 | 
					        setAudioUrl(URL.createObjectURL(blob));
 | 
				
			||||||
 | 
					        stream.getTracks().forEach(track => track.stop());
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      mediaRecorder.start();
 | 
				
			||||||
 | 
					      setIsRecording(true);
 | 
				
			||||||
 | 
					      setRecordingTime(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // 开始计时
 | 
				
			||||||
 | 
					      intervalRef.current = setInterval(() => {
 | 
				
			||||||
 | 
					        setRecordingTime(prev => prev + 1);
 | 
				
			||||||
 | 
					      }, 1000);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error("录音失败:", error);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const stopRecording = () => {
 | 
				
			||||||
 | 
					    if (mediaRecorderRef.current && isRecording) {
 | 
				
			||||||
 | 
					      mediaRecorderRef.current.stop();
 | 
				
			||||||
 | 
					      setIsRecording(false);
 | 
				
			||||||
 | 
					      if (intervalRef.current) {
 | 
				
			||||||
 | 
					        clearInterval(intervalRef.current);
 | 
				
			||||||
 | 
					        intervalRef.current = null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const playAudio = () => {
 | 
				
			||||||
 | 
					    if (audioRef.current && audioUrl) {
 | 
				
			||||||
 | 
					      if (isPlaying) {
 | 
				
			||||||
 | 
					        audioRef.current.pause();
 | 
				
			||||||
 | 
					        setIsPlaying(false);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        audioRef.current.play();
 | 
				
			||||||
 | 
					        setIsPlaying(true);
 | 
				
			||||||
 | 
					        setShowWaveform(true); // 开始播放时显示波形
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handlePlayStateChange = (playing: boolean) => {
 | 
				
			||||||
 | 
					    setIsPlaying(playing);
 | 
				
			||||||
 | 
					    // 如果播放结束,隐藏波形
 | 
				
			||||||
 | 
					    if (!playing) {
 | 
				
			||||||
 | 
					      setShowWaveform(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const transcribeAudio = async () => {
 | 
				
			||||||
 | 
					    if (!audioBlob) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setIsTranscribing(true);
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      // 将音频转换为base64
 | 
				
			||||||
 | 
					      const reader = new FileReader();
 | 
				
			||||||
 | 
					      reader.onloadend = async () => {
 | 
				
			||||||
 | 
					        const base64Audio = (reader.result as string).split(',')[1];
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					          const result = await getText(base64Audio);
 | 
				
			||||||
 | 
					          const text = typeof result === 'string' ? result : (result as any)?.data || '转录失败';
 | 
				
			||||||
 | 
					          setCurrentText(text);
 | 
				
			||||||
 | 
					          
 | 
				
			||||||
 | 
					          // 添加到转录历史
 | 
				
			||||||
 | 
					          const newTranscription: TranscriptionResult = {
 | 
				
			||||||
 | 
					            id: Date.now().toString(),
 | 
				
			||||||
 | 
					            text,
 | 
				
			||||||
 | 
					            timestamp: new Date()
 | 
				
			||||||
 | 
					          };
 | 
				
			||||||
 | 
					          setTranscriptions(prev => [newTranscription, ...prev]);
 | 
				
			||||||
 | 
					        } catch (error) {
 | 
				
			||||||
 | 
					          console.error("转录失败:", error);
 | 
				
			||||||
 | 
					          setCurrentText("转录失败,请重试");
 | 
				
			||||||
 | 
					        } finally {
 | 
				
			||||||
 | 
					          setIsTranscribing(false);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      };
 | 
				
			||||||
 | 
					      reader.readAsDataURL(audioBlob);
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error("处理音频失败:", error);
 | 
				
			||||||
 | 
					      setIsTranscribing(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const copyText = (text: string) => {
 | 
				
			||||||
 | 
					    navigator.clipboard.writeText(text);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const refreshTranscriptions = () => {
 | 
				
			||||||
 | 
					    setTranscriptions([]);
 | 
				
			||||||
 | 
					    setCurrentText("");
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formatTime = (seconds: number) => {
 | 
				
			||||||
 | 
					    const mins = Math.floor(seconds / 60);
 | 
				
			||||||
 | 
					    const secs = seconds % 60;
 | 
				
			||||||
 | 
					    return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-lg">
 | 
				
			||||||
 | 
					      {/* 标题 */}
 | 
				
			||||||
 | 
					      <div className="mb-6">
 | 
				
			||||||
 | 
					        <h1 className="text-2xl font-bold text-gray-800 text-center">语音录制与转文字</h1>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* 录音控制区域 */}
 | 
				
			||||||
 | 
					      <div className="mb-6 p-4 bg-gray-50 rounded-lg">
 | 
				
			||||||
 | 
					        <div className="flex items-center justify-center gap-4 mb-4">
 | 
				
			||||||
 | 
					          {/* 录音/停止按钮 */}
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            onClick={isRecording ? stopRecording : startRecording}
 | 
				
			||||||
 | 
					            className={`px-6 py-3 rounded-lg font-medium transition-colors ${
 | 
				
			||||||
 | 
					              isRecording
 | 
				
			||||||
 | 
					                ? "bg-red-500 hover:bg-red-600 text-white"
 | 
				
			||||||
 | 
					                : "bg-blue-500 hover:bg-blue-600 text-white"
 | 
				
			||||||
 | 
					            }`}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            {isRecording ? "停止录音" : "开始录音"}
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {/* 播放按钮 - 录制时隐藏 */}
 | 
				
			||||||
 | 
					          {!isRecording && (
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              onClick={playAudio}
 | 
				
			||||||
 | 
					              disabled={!audioUrl}
 | 
				
			||||||
 | 
					              className={`px-6 py-3 rounded-lg font-medium transition-colors ${
 | 
				
			||||||
 | 
					                audioUrl
 | 
				
			||||||
 | 
					                  ? "bg-green-500 hover:bg-green-600 text-white"
 | 
				
			||||||
 | 
					                  : "bg-gray-300 text-gray-500 cursor-not-allowed"
 | 
				
			||||||
 | 
					              }`}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {isPlaying ? "暂停播放" : "播放录音"}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {/* 转文字按钮 - 录制时隐藏 */}
 | 
				
			||||||
 | 
					          {!isRecording && audioUrl && (
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              onClick={transcribeAudio}
 | 
				
			||||||
 | 
					              disabled={isTranscribing}
 | 
				
			||||||
 | 
					              className={`px-6 py-3 rounded-lg font-medium transition-colors ${
 | 
				
			||||||
 | 
					                isTranscribing
 | 
				
			||||||
 | 
					                  ? "bg-gray-400 text-white cursor-not-allowed"
 | 
				
			||||||
 | 
					                  : "bg-purple-500 hover:bg-purple-600 text-white"
 | 
				
			||||||
 | 
					              }`}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {isTranscribing ? "转录中..." : "转文字"}
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* 录音状态显示 */}
 | 
				
			||||||
 | 
					        {isRecording && (
 | 
				
			||||||
 | 
					          <div className="text-center mb-4">
 | 
				
			||||||
 | 
					            <div className="flex items-center justify-center gap-2 text-red-600">
 | 
				
			||||||
 | 
					              <div className="w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
 | 
				
			||||||
 | 
					              <span className="font-medium">录音中: {formatTime(recordingTime)}</span>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* 播放波形显示 - 只在需要显示且有音频时显示 */}
 | 
				
			||||||
 | 
					        {showWaveform && audioUrl && (
 | 
				
			||||||
 | 
					          <Wavesurfer 
 | 
				
			||||||
 | 
					            audioUrl={audioUrl}
 | 
				
			||||||
 | 
					            isPlaying={isPlaying}
 | 
				
			||||||
 | 
					            onPlayStateChange={handlePlayStateChange}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* 隐藏的音频元素 */}
 | 
				
			||||||
 | 
					        {audioUrl && (
 | 
				
			||||||
 | 
					          <audio
 | 
				
			||||||
 | 
					            ref={audioRef}
 | 
				
			||||||
 | 
					            src={audioUrl}
 | 
				
			||||||
 | 
					            onEnded={() => setIsPlaying(false)}
 | 
				
			||||||
 | 
					            className="hidden"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        )}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* 转录结果区域 */}
 | 
				
			||||||
 | 
					      <div className="mb-6 p-4 bg-gray-50 rounded-lg">
 | 
				
			||||||
 | 
					        <div className="flex items-center justify-between mb-4">
 | 
				
			||||||
 | 
					          <h2 className="text-lg font-semibold text-gray-700">最近转录</h2>
 | 
				
			||||||
 | 
					          <div className="flex gap-2">
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              onClick={() => currentText && copyText(currentText)}
 | 
				
			||||||
 | 
					              disabled={!currentText}
 | 
				
			||||||
 | 
					              className={`px-3 py-1 rounded text-sm transition-colors ${
 | 
				
			||||||
 | 
					                currentText
 | 
				
			||||||
 | 
					                  ? "bg-blue-100 hover:bg-blue-200 text-blue-700"
 | 
				
			||||||
 | 
					                  : "bg-gray-200 text-gray-400 cursor-not-allowed"
 | 
				
			||||||
 | 
					              }`}
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              复制
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              onClick={refreshTranscriptions}
 | 
				
			||||||
 | 
					              className="px-3 py-1 rounded text-sm bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              刷新
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {/* 当前转录文本 */}
 | 
				
			||||||
 | 
					        <div className="min-h-[100px] p-4 bg-white border rounded-lg">
 | 
				
			||||||
 | 
					          {isTranscribing ? (
 | 
				
			||||||
 | 
					            <div className="flex items-center justify-center text-gray-500">
 | 
				
			||||||
 | 
					              <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-500 mr-2"></div>
 | 
				
			||||||
 | 
					              正在转录音频...
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          ) : currentText ? (
 | 
				
			||||||
 | 
					            <p className="text-gray-800 leading-relaxed">{currentText}</p>
 | 
				
			||||||
 | 
					          ) : (
 | 
				
			||||||
 | 
					            <p className="text-gray-400 text-center">转录结果将显示在这里</p>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {/* 历史转录记录 */}
 | 
				
			||||||
 | 
					      {transcriptions.length > 0 && (
 | 
				
			||||||
 | 
					        <div className="p-4 bg-gray-50 rounded-lg">
 | 
				
			||||||
 | 
					          <h3 className="text-md font-semibold text-gray-700 mb-3">历史记录</h3>
 | 
				
			||||||
 | 
					          <div className="space-y-3 max-h-60 overflow-y-auto">
 | 
				
			||||||
 | 
					            {transcriptions.map((item) => (
 | 
				
			||||||
 | 
					              <div
 | 
				
			||||||
 | 
					                key={item.id}
 | 
				
			||||||
 | 
					                className="p-3 bg-white border rounded-lg flex justify-between items-start"
 | 
				
			||||||
 | 
					              >
 | 
				
			||||||
 | 
					                <div className="flex-1">
 | 
				
			||||||
 | 
					                  <p className="text-gray-800 text-sm mb-1">{item.text}</p>
 | 
				
			||||||
 | 
					                  <p className="text-gray-400 text-xs">
 | 
				
			||||||
 | 
					                    {item.timestamp.toLocaleString()}
 | 
				
			||||||
 | 
					                  </p>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                <button
 | 
				
			||||||
 | 
					                  onClick={() => copyText(item.text)}
 | 
				
			||||||
 | 
					                  className="ml-3 px-2 py-1 text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 rounded transition-colors"
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  复制
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					              </div>
 | 
				
			||||||
 | 
					            ))}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										38
									
								
								src/apps/record/modules/get-text.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/apps/record/modules/get-text.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,38 @@
 | 
				
			|||||||
 | 
					import { query } from '../../../modules/query'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export type AsrResponse = {
 | 
				
			||||||
 | 
					  audio_info: {
 | 
				
			||||||
 | 
					    /**
 | 
				
			||||||
 | 
					     * 音频时长,单位为 ms
 | 
				
			||||||
 | 
					     */
 | 
				
			||||||
 | 
					    duration: number;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					  result: {
 | 
				
			||||||
 | 
					    additions: {
 | 
				
			||||||
 | 
					      duration: string;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    text: string;
 | 
				
			||||||
 | 
					    utterances: Array<{
 | 
				
			||||||
 | 
					      end_time: number;
 | 
				
			||||||
 | 
					      start_time: number;
 | 
				
			||||||
 | 
					      text: string;
 | 
				
			||||||
 | 
					      words: Array<{
 | 
				
			||||||
 | 
					        confidence: number;
 | 
				
			||||||
 | 
					        end_time: number;
 | 
				
			||||||
 | 
					        start_time: number;
 | 
				
			||||||
 | 
					        text: string;
 | 
				
			||||||
 | 
					      }>;
 | 
				
			||||||
 | 
					    }>;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					export const getText = async (base64Audio: string) => {
 | 
				
			||||||
 | 
					  const res = await query.post<AsrResponse>({
 | 
				
			||||||
 | 
					    path: 'asr',
 | 
				
			||||||
 | 
					    key: 'text',
 | 
				
			||||||
 | 
					    base64Audio
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					  if (res.code !== 200) {
 | 
				
			||||||
 | 
					    return '转录失败'
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  return res.data?.result?.text || '转录失败'
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										3
									
								
								src/modules/query.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/modules/query.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					import { Query } from '@kevisual/query';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const query = new Query();
 | 
				
			||||||
							
								
								
									
										17
									
								
								src/pages/record.astro
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/pages/record.astro
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					---
 | 
				
			||||||
 | 
					import { RecordApp } from '../apps/record/index';
 | 
				
			||||||
 | 
					import '../styles/global.css';
 | 
				
			||||||
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<html>
 | 
				
			||||||
 | 
					  <head>
 | 
				
			||||||
 | 
					    <meta charset="utf-8" />
 | 
				
			||||||
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
				
			||||||
 | 
					    <title>语音录制与转文字</title>
 | 
				
			||||||
 | 
					  </head>
 | 
				
			||||||
 | 
					  <body>
 | 
				
			||||||
 | 
					    <div class="min-h-screen bg-gray-100 py-8">
 | 
				
			||||||
 | 
					      <RecordApp client:load />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
		Reference in New Issue
	
	Block a user