style: format code in Speaking.vue
This commit is contained in:
parent
84d1bb3331
commit
bb2452a8ec
|
|
@ -1,7 +1,19 @@
|
|||
<script setup>
|
||||
import { ref, computed, nextTick, onUnmounted, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { DOUBAO_APP_ID, DOUBAO_ACCESS_TOKEN, DOUBAO_RESOURCE_ID, DOUBAO_TTS_API_PATH, DOUBAO_AUDIO_FORMAT, DOUBAO_SAMPLE_RATE, ARK_API_KEY, ARK_MODEL, ARK_API_PATH, ARK_MAX_TOKENS, ARK_HISTORY_LIMIT } from "@/config/index.js";
|
||||
import {
|
||||
DOUBAO_APP_ID,
|
||||
DOUBAO_ACCESS_TOKEN,
|
||||
DOUBAO_RESOURCE_ID,
|
||||
DOUBAO_TTS_API_PATH,
|
||||
DOUBAO_AUDIO_FORMAT,
|
||||
DOUBAO_SAMPLE_RATE,
|
||||
ARK_API_KEY,
|
||||
ARK_MODEL,
|
||||
ARK_API_PATH,
|
||||
ARK_MAX_TOKENS,
|
||||
ARK_HISTORY_LIMIT,
|
||||
} from "@/config/index.js";
|
||||
import pako from "pako";
|
||||
|
||||
const router = useRouter();
|
||||
|
|
@ -12,7 +24,8 @@ const scenes = [
|
|||
id: "school",
|
||||
name: "校园生活",
|
||||
emoji: "🏫",
|
||||
greeting: "Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
|
||||
greeting:
|
||||
"Hi there! I'm your English buddy. Let's chat about school life — classes, teachers, friends, or anything that happens at school. It's a great way to practice everyday English! So, what subject do you like most at school?",
|
||||
systemPrompt:
|
||||
"You are a friendly English tutor for middle and high school students. Chat about school life topics like classes, homework, teachers, friends, and campus activities. Use simple, clear English suitable for teenagers. Keep responses to 2-3 sentences. Gently correct grammar mistakes and be encouraging.",
|
||||
},
|
||||
|
|
@ -20,7 +33,8 @@ const scenes = [
|
|||
id: "hobby",
|
||||
name: "兴趣爱好",
|
||||
emoji: "🎮",
|
||||
greeting: "Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
|
||||
greeting:
|
||||
"Hey! I'd love to know more about you. Let's talk about your hobbies and interests — games, sports, music, drawing, reading... anything you enjoy! What do you like to do in your free time?",
|
||||
systemPrompt:
|
||||
"You are a friendly English tutor for middle and high school students. Discuss hobbies and interests like games, sports, music, art, and reading. Use casual, teen-friendly English. Keep responses to 2-3 sentences. Encourage the student to express themselves and gently fix any errors.",
|
||||
},
|
||||
|
|
@ -28,7 +42,8 @@ const scenes = [
|
|||
id: "exam",
|
||||
name: "英语考试",
|
||||
emoji: "📝",
|
||||
greeting: "Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
|
||||
greeting:
|
||||
"Hello! Let's get ready for your English exam. I can help you practice speaking topics that often appear in school exams — like describing pictures, giving opinions, or talking about your daily routine. Let's start: Can you describe what you did last weekend?",
|
||||
systemPrompt:
|
||||
"You are an English exam coach for middle and high school students. Practice common exam speaking tasks: describing pictures, expressing opinions, talking about daily life, and answering topic questions. Use language appropriate for school exams. Give brief feedback after each response. Keep your replies concise.",
|
||||
},
|
||||
|
|
@ -36,7 +51,8 @@ const scenes = [
|
|||
id: "movie",
|
||||
name: "影视讨论",
|
||||
emoji: "🎬",
|
||||
greeting: "Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
|
||||
greeting:
|
||||
"Lights, camera, English! Let's talk about movies, TV shows, or cartoons you enjoy. Discussing stories and characters is a super fun way to improve your English. Have you watched any good movies or shows recently?",
|
||||
systemPrompt:
|
||||
"You are a fun English tutor for teenagers. Discuss movies, TV shows, animations, and pop culture. Use engaging, youth-friendly language. Ask about plots, characters, and opinions. Keep responses to 2-3 sentences and gently correct any grammar mistakes.",
|
||||
},
|
||||
|
|
@ -44,7 +60,8 @@ const scenes = [
|
|||
id: "travel",
|
||||
name: "旅游英语",
|
||||
emoji: "✈️",
|
||||
greeting: "Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
|
||||
greeting:
|
||||
"Welcome, young traveler! Imagine you're on a trip abroad — you might need to ask for directions, order food, or buy souvenirs. Let's practice! Pretend you just arrived in London. What's the first thing you'd like to do?",
|
||||
systemPrompt:
|
||||
"You are a helpful travel guide for teenage students. Role-play travel scenarios suitable for young learners: asking for directions, ordering at a restaurant, buying tickets, and checking into a hotel. Use simple and practical English. Keep responses short and easy to follow.",
|
||||
},
|
||||
|
|
@ -52,7 +69,8 @@ const scenes = [
|
|||
id: "free",
|
||||
name: "自由对话",
|
||||
emoji: "💬",
|
||||
greeting: "Hey! I'm here to chat with you about anything you'd like! You can choose any topic — your dreams, daily life, thoughts, or just something fun. Feel free to express yourself. So, what would you like to talk about today?",
|
||||
greeting:
|
||||
"Hey! I'm here to chat with you about anything you'd like! You can choose any topic — your dreams, daily life, thoughts, or just something fun. Feel free to express yourself. So, what would you like to talk about today?",
|
||||
systemPrompt:
|
||||
"You are a versatile English tutor for middle and high school students. Have open conversations on any topic the student wants to discuss. Be flexible, supportive, and encouraging. Use natural, teen-friendly English. Keep responses to 2-3 sentences. Gently correct grammar mistakes and help the student improve.",
|
||||
},
|
||||
|
|
@ -62,8 +80,16 @@ const scenes = [
|
|||
const voiceOptions = [
|
||||
{ id: "en_female_dacey_uranus_bigtts", name: "Dacey (美音女)", avatar: "👩🏫" },
|
||||
{ id: "en_male_tim_uranus_bigtts", name: "Tim (美音男)", avatar: "👨🏫" },
|
||||
{ id: "en_female_stokie_uranus_bigtts", name: "Stokie (美音女)", avatar: "👩💼" },
|
||||
{ id: "zh_female_yingyujiaoxue_uranus_bigtts", name: "Tina (英音女)", avatar: "👩🎓" },
|
||||
{
|
||||
id: "en_female_stokie_uranus_bigtts",
|
||||
name: "Stokie (美音女)",
|
||||
avatar: "👩💼",
|
||||
},
|
||||
{
|
||||
id: "zh_female_yingyujiaoxue_uranus_bigtts",
|
||||
name: "Tina (英音女)",
|
||||
avatar: "👩🎓",
|
||||
},
|
||||
];
|
||||
|
||||
const currentVoiceAvatar = computed(
|
||||
|
|
@ -131,14 +157,16 @@ let asrInterimText = ref(""); // 实时识别中间结果
|
|||
|
||||
// 兼容的 UUID 生成函数
|
||||
const generateUUID = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
const currentScene = computed(() => scenes.find((s) => s.id === activeScene.value));
|
||||
const currentScene = computed(() =>
|
||||
scenes.find((s) => s.id === activeScene.value)
|
||||
);
|
||||
|
||||
// 初始化:发送默认场景欢迎语
|
||||
onMounted(() => {
|
||||
|
|
@ -153,13 +181,23 @@ onMounted(() => {
|
|||
const sendGreeting = async (scene) => {
|
||||
isSending.value = true;
|
||||
const greetingId = ++msgIdCounter;
|
||||
const greetingMsg = { id: greetingId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
|
||||
const greetingMsg = {
|
||||
id: greetingId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
audioUrl: null,
|
||||
isPlaying: false,
|
||||
isLoading: true,
|
||||
};
|
||||
messages.value.push(greetingMsg);
|
||||
await scrollToBottom();
|
||||
// 短暂延迟模拟"思考"
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
const msg = messages.value.find((m) => m.id === greetingId);
|
||||
if (msg) { msg.content = scene.greeting; msg.isLoading = false; }
|
||||
if (msg) {
|
||||
msg.content = scene.greeting;
|
||||
msg.isLoading = false;
|
||||
}
|
||||
await scrollToBottom();
|
||||
try {
|
||||
await synthesizeAndPlay(scene.greeting, greetingId);
|
||||
|
|
@ -204,7 +242,10 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
|||
req_params: {
|
||||
text,
|
||||
speaker: selectedVoice.value,
|
||||
audio_params: { format: DOUBAO_AUDIO_FORMAT, sample_rate: DOUBAO_SAMPLE_RATE },
|
||||
audio_params: {
|
||||
format: DOUBAO_AUDIO_FORMAT,
|
||||
sample_rate: DOUBAO_SAMPLE_RATE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -263,7 +304,10 @@ const synthesizeAndPlay = async (text, msgId, autoPlay = true) => {
|
|||
const totalLen = audioChunks.reduce((a, v) => a + v.length, 0);
|
||||
const allBytes = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const chunk of audioChunks) { allBytes.set(chunk, offset); offset += chunk.length; }
|
||||
for (const chunk of audioChunks) {
|
||||
allBytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const blob = new Blob([allBytes], { type: "audio/mp3" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
|
@ -307,7 +351,10 @@ const playAudio = (url, msgId) => {
|
|||
const replayMessage = (msg) => {
|
||||
if (!msg.audioUrl) return;
|
||||
if (msg.isPlaying) {
|
||||
if (currentAudioInstance) { currentAudioInstance.pause(); currentAudioInstance = null; }
|
||||
if (currentAudioInstance) {
|
||||
currentAudioInstance.pause();
|
||||
currentAudioInstance = null;
|
||||
}
|
||||
msg.isPlaying = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -320,7 +367,7 @@ const callArkAPI = async (history) => {
|
|||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${ARK_API_KEY}`,
|
||||
Authorization: `Bearer ${ARK_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: ARK_MODEL,
|
||||
|
|
@ -352,16 +399,32 @@ const sendMessage = async () => {
|
|||
|
||||
// 插入用户消息
|
||||
const userMsgId = ++msgIdCounter;
|
||||
const userMsg = { id: userMsgId, role: "user", content: text, audioUrl: null, isPlaying: false, isLoading: false };
|
||||
const userMsg = {
|
||||
id: userMsgId,
|
||||
role: "user",
|
||||
content: text,
|
||||
audioUrl: null,
|
||||
isPlaying: false,
|
||||
isLoading: false,
|
||||
};
|
||||
messages.value.push(userMsg);
|
||||
await scrollToBottom();
|
||||
|
||||
// 用户消息 TTS(不阻塞主流程,不自动播放)
|
||||
synthesizeAndPlay(text, userMsgId, false).catch((e) => console.error("User TTS error:", e));
|
||||
synthesizeAndPlay(text, userMsgId, false).catch((e) =>
|
||||
console.error("User TTS error:", e)
|
||||
);
|
||||
|
||||
// 插入 AI loading 占位
|
||||
const aiMsgId = ++msgIdCounter;
|
||||
const aiMsg = { id: aiMsgId, role: "assistant", content: "", audioUrl: null, isPlaying: false, isLoading: true };
|
||||
const aiMsg = {
|
||||
id: aiMsgId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
audioUrl: null,
|
||||
isPlaying: false,
|
||||
isLoading: true,
|
||||
};
|
||||
messages.value.push(aiMsg);
|
||||
await scrollToBottom();
|
||||
|
||||
|
|
@ -374,7 +437,9 @@ const sendMessage = async () => {
|
|||
|
||||
let replyText = "";
|
||||
if (ARK_API_KEY) {
|
||||
replyText = await callArkAPI(history.slice(0, -1).concat([{ role: "user", content: text }]));
|
||||
replyText = await callArkAPI(
|
||||
history.slice(0, -1).concat([{ role: "user", content: text }])
|
||||
);
|
||||
} else {
|
||||
// 模拟延迟
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
|
@ -383,7 +448,10 @@ const sendMessage = async () => {
|
|||
|
||||
// 更新 AI 消息内容
|
||||
const msg = messages.value.find((m) => m.id === aiMsgId);
|
||||
if (msg) { msg.content = replyText; msg.isLoading = false; }
|
||||
if (msg) {
|
||||
msg.content = replyText;
|
||||
msg.isLoading = false;
|
||||
}
|
||||
await scrollToBottom();
|
||||
|
||||
// TTS 合成
|
||||
|
|
@ -391,7 +459,10 @@ const sendMessage = async () => {
|
|||
} catch (err) {
|
||||
console.error("Speaking error:", err);
|
||||
const msg = messages.value.find((m) => m.id === aiMsgId);
|
||||
if (msg) { msg.content = "Sorry, something went wrong. Please try again."; msg.isLoading = false; }
|
||||
if (msg) {
|
||||
msg.content = "Sorry, something went wrong. Please try again.";
|
||||
msg.isLoading = false;
|
||||
}
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
await scrollToBottom();
|
||||
|
|
@ -422,7 +493,13 @@ const float32ToInt16Bytes = (float32Array) => {
|
|||
// flags: 0x00=普通包, 0x02=最后一包
|
||||
// serialization: 0x01=JSON, 0x00=Raw
|
||||
// compression: 0x01=Gzip, 0x00=无压缩
|
||||
const buildFrame = (messageType, flags, serialization, compression, payload) => {
|
||||
const buildFrame = (
|
||||
messageType,
|
||||
flags,
|
||||
serialization,
|
||||
compression,
|
||||
payload
|
||||
) => {
|
||||
const header = new Uint8Array([
|
||||
(0x01 << 4) | 0x01, // byte0: version=1, headerSize=1(×4=4字节)
|
||||
(messageType << 4) | flags, // byte1: messageType | flags
|
||||
|
|
@ -453,7 +530,9 @@ const parseServerFrame = (buffer) => {
|
|||
if (msgType === 0x0f) {
|
||||
const errCode = view.getUint32(4, false);
|
||||
const errMsgSize = view.getUint32(8, false);
|
||||
const errMsg = new TextDecoder().decode(new Uint8Array(buffer, 12, errMsgSize));
|
||||
const errMsg = new TextDecoder().decode(
|
||||
new Uint8Array(buffer, 12, errMsgSize)
|
||||
);
|
||||
return { msgType, flags, error: true, code: errCode, message: errMsg };
|
||||
}
|
||||
|
||||
|
|
@ -533,7 +612,7 @@ const startRecording = async () => {
|
|||
|
||||
try {
|
||||
// 1. 通过 Vite 代理连接(代理层自动注入鉴权 Header,绕过浏览器 WS 不支持自定义 Header 的限制)
|
||||
const wsUrl = `ws://${location.host}/asr-ws/api/v3/sauc/bigmodel`;
|
||||
const wsUrl = `wss://${location.host}/asr-ws/api/v3/sauc/bigmodel`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
asrWs = ws;
|
||||
|
|
@ -606,7 +685,9 @@ const startRecording = async () => {
|
|||
console.error("ASR WebSocket error");
|
||||
stopRecording();
|
||||
asrStatus.value = "error";
|
||||
setTimeout(() => { asrStatus.value = ""; }, 3000);
|
||||
setTimeout(() => {
|
||||
asrStatus.value = "";
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
|
|
@ -616,11 +697,17 @@ const startRecording = async () => {
|
|||
|
||||
// 4. 获取麦克风并采集 PCM
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: 16000, channelCount: 1, echoCancellation: true, noiseSuppression: true },
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
});
|
||||
asrMediaStream = stream;
|
||||
|
||||
const AudioContextClass = window.AudioContext || window["webkitAudioContext"];
|
||||
const AudioContextClass =
|
||||
window.AudioContext || window["webkitAudioContext"];
|
||||
const audioCtx = new AudioContextClass({ sampleRate: 16000 });
|
||||
asrAudioContext = audioCtx;
|
||||
|
||||
|
|
@ -630,7 +717,8 @@ const startRecording = async () => {
|
|||
asrScriptProcessor = processor;
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!isRecording.value || !asrWs || asrWs.readyState !== WebSocket.OPEN) return;
|
||||
if (!isRecording.value || !asrWs || asrWs.readyState !== WebSocket.OPEN)
|
||||
return;
|
||||
const pcmBytes = float32ToInt16Bytes(e.inputBuffer.getChannelData(0));
|
||||
// Audio Only Request(0x02), flags=0x00, Raw(0x00), Gzip(0x01)
|
||||
const compressed = pako.gzip(pcmBytes);
|
||||
|
|
@ -646,7 +734,9 @@ const startRecording = async () => {
|
|||
console.error("ASR start error:", err);
|
||||
stopRecording();
|
||||
asrStatus.value = "error";
|
||||
setTimeout(() => { asrStatus.value = ""; }, 3000);
|
||||
setTimeout(() => {
|
||||
asrStatus.value = "";
|
||||
}, 3000);
|
||||
if (err.name === "NotAllowedError") {
|
||||
alert("麦克风权限被拒绝,请在浏览器设置中允许访问麦克风");
|
||||
}
|
||||
|
|
@ -674,15 +764,27 @@ onUnmounted(() => {
|
|||
<!-- 顶部导航 -->
|
||||
<nav class="nav-bar">
|
||||
<button @click="router.push('/')" class="back-btn">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
返回首页
|
||||
</button>
|
||||
<div class="nav-title">AI 口语对话</div>
|
||||
<div class="voice-select-wrap">
|
||||
<select v-model="selectedVoice" class="voice-select">
|
||||
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">{{ v.name }}</option>
|
||||
<option v-for="v in voiceOptions" :key="v.id" :value="v.id">
|
||||
{{ v.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
@ -711,9 +813,16 @@ onUnmounted(() => {
|
|||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-for="msg in messages" :key="msg.id" class="message-row" :class="msg.role">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
class="message-row"
|
||||
:class="msg.role"
|
||||
>
|
||||
<!-- AI 头像 -->
|
||||
<div v-if="msg.role === 'assistant'" class="avatar">{{ currentVoiceAvatar }}</div>
|
||||
<div v-if="msg.role === 'assistant'" class="avatar">
|
||||
{{ currentVoiceAvatar }}
|
||||
</div>
|
||||
|
||||
<div class="bubble-wrap">
|
||||
<!-- 气泡 -->
|
||||
|
|
@ -724,16 +833,37 @@ onUnmounted(() => {
|
|||
</div>
|
||||
<span v-else>{{ msg.content }}</span>
|
||||
<!-- 消息播放按钮(AI 和用户消息均显示) -->
|
||||
<div v-if="!msg.isLoading && msg.content" class="audio-controls" :class="{ 'user-audio-controls': msg.role === 'user' }">
|
||||
<div
|
||||
v-if="!msg.isLoading && msg.content"
|
||||
class="audio-controls"
|
||||
:class="{ 'user-audio-controls': msg.role === 'user' }"
|
||||
>
|
||||
<span v-if="!msg.audioUrl" class="tts-hint">合成中...</span>
|
||||
<button v-else class="play-btn" :class="{ playing: msg.isPlaying }" @click="replayMessage(msg)" :title="msg.isPlaying ? '暂停' : '播放'">
|
||||
<button
|
||||
v-else
|
||||
class="play-btn"
|
||||
:class="{ playing: msg.isPlaying }"
|
||||
@click="replayMessage(msg)"
|
||||
:title="msg.isPlaying ? '暂停' : '播放'"
|
||||
>
|
||||
<!-- 播放中:音波动画 -->
|
||||
<div v-if="msg.isPlaying" class="wave-bars">
|
||||
<span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<!-- 未播放:播放图标 -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -750,7 +880,11 @@ onUnmounted(() => {
|
|||
<textarea
|
||||
v-model="inputText"
|
||||
class="input-box"
|
||||
:placeholder="asrInterimText ? asrInterimText : '输入英语内容,按 Enter 发送,Shift+Enter 换行...'"
|
||||
:placeholder="
|
||||
asrInterimText
|
||||
? asrInterimText
|
||||
: '输入英语内容,按 Enter 发送,Shift+Enter 换行...'
|
||||
"
|
||||
:disabled="isSending"
|
||||
@keydown="handleKeydown"
|
||||
rows="1"
|
||||
|
|
@ -758,7 +892,11 @@ onUnmounted(() => {
|
|||
<!-- 麦克风按钮 -->
|
||||
<button
|
||||
class="mic-btn"
|
||||
:class="{ recording: isRecording, error: asrStatus === 'error', connecting: asrStatus === 'connecting' }"
|
||||
:class="{
|
||||
recording: isRecording,
|
||||
error: asrStatus === 'error',
|
||||
connecting: asrStatus === 'connecting',
|
||||
}"
|
||||
@click="startRecording"
|
||||
:title="isRecording ? '停止录音' : '语音输入'"
|
||||
:disabled="isSending"
|
||||
|
|
@ -768,18 +906,46 @@ onUnmounted(() => {
|
|||
<!-- 录音中:麦克风 + 脉冲 -->
|
||||
<template v-else-if="isRecording">
|
||||
<div class="mic-pulse"></div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
<!-- 默认:麦克风图标 -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"/>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-1 14.93V20H9v2h6v-2h-2v-2.07A7.001 7.001 0 0 0 19 11h-2a5 5 0 0 1-10 0H5a7.001 7.001 0 0 0 6 6.93z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="send-btn" :disabled="!inputText.trim() || isSending" @click="sendMessage">
|
||||
<svg v-if="!isSending" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="!inputText.trim() || isSending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<svg
|
||||
v-if="!isSending"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
<div v-else class="send-spinner"></div>
|
||||
</button>
|
||||
|
|
@ -788,7 +954,9 @@ onUnmounted(() => {
|
|||
</template>
|
||||
|
||||
<style scoped>
|
||||
* { box-sizing: border-box; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
max-width: 900px;
|
||||
|
|
@ -821,8 +989,14 @@ onUnmounted(() => {
|
|||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.back-btn:hover { background: var(--card-bg); color: var(--text-primary); }
|
||||
.back-btn svg { width: 20px; height: 20px; }
|
||||
.back-btn:hover {
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.back-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.nav-title {
|
||||
flex: 1;
|
||||
|
|
@ -831,11 +1005,15 @@ onUnmounted(() => {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.voice-select-wrap { min-width: 180px; }
|
||||
.voice-select-wrap {
|
||||
min-width: 180px;
|
||||
}
|
||||
.voice-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: var(--card-bg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E") no-repeat right 0.6rem center;
|
||||
background: var(--card-bg)
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23a0a0b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")
|
||||
no-repeat right 0.6rem center;
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-primary);
|
||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||
|
|
@ -846,7 +1024,9 @@ onUnmounted(() => {
|
|||
width: 100%;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.voice-select:hover { border-color: var(--accent-1); }
|
||||
.voice-select:hover {
|
||||
border-color: var(--accent-1);
|
||||
}
|
||||
.voice-select option {
|
||||
background: #1e1e2e;
|
||||
color: var(--text-primary);
|
||||
|
|
@ -860,8 +1040,13 @@ onUnmounted(() => {
|
|||
overflow-x: auto;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
.scene-tabs::-webkit-scrollbar { height: 4px; }
|
||||
.scene-tabs::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 2px; }
|
||||
.scene-tabs::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.scene-tabs::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.scene-tab {
|
||||
display: flex;
|
||||
|
|
@ -877,13 +1062,18 @@ onUnmounted(() => {
|
|||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.scene-tab:hover { background: rgba(255,255,255,0.1); color: var(--text-primary); }
|
||||
.scene-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scene-tab.active {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
color: var(--accent-1);
|
||||
}
|
||||
.scene-emoji { font-size: 1rem; }
|
||||
.scene-emoji {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Chat Area */
|
||||
.chat-area {
|
||||
|
|
@ -895,8 +1085,13 @@ onUnmounted(() => {
|
|||
gap: 1.25rem;
|
||||
min-height: 0;
|
||||
}
|
||||
.chat-area::-webkit-scrollbar { width: 6px; }
|
||||
.chat-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
.chat-area::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.chat-area::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Welcome */
|
||||
.welcome-hint {
|
||||
|
|
@ -910,9 +1105,18 @@ onUnmounted(() => {
|
|||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
}
|
||||
.welcome-icon { font-size: 3.5rem; }
|
||||
.welcome-hint h3 { margin: 0; font-size: 1.25rem; color: var(--text-primary); }
|
||||
.welcome-hint p { margin: 0; font-size: 0.95rem; }
|
||||
.welcome-icon {
|
||||
font-size: 3.5rem;
|
||||
}
|
||||
.welcome-hint h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.welcome-hint p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Message Row */
|
||||
.message-row {
|
||||
|
|
@ -923,8 +1127,12 @@ onUnmounted(() => {
|
|||
.message-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.message-row.user .avatar { order: 2; }
|
||||
.message-row.user .bubble-wrap { order: 1; }
|
||||
.message-row.user .avatar {
|
||||
order: 2;
|
||||
}
|
||||
.message-row.user .bubble-wrap {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 38px;
|
||||
|
|
@ -939,7 +1147,10 @@ onUnmounted(() => {
|
|||
flex-shrink: 0;
|
||||
order: 0;
|
||||
}
|
||||
.user-avatar { background: rgba(139,92,246,0.15); border-color: rgba(139,92,246,0.3); }
|
||||
.user-avatar {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
|
|
@ -947,7 +1158,9 @@ onUnmounted(() => {
|
|||
max-width: 70%;
|
||||
order: 1;
|
||||
}
|
||||
.message-row.user .bubble-wrap { align-items: flex-end; }
|
||||
.message-row.user .bubble-wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 0.875rem 1.125rem;
|
||||
|
|
@ -972,8 +1185,14 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
@keyframes bubbleIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading dots */
|
||||
|
|
@ -984,16 +1203,29 @@ onUnmounted(() => {
|
|||
padding: 0.25rem 0;
|
||||
}
|
||||
.loading-dots span {
|
||||
width: 8px; height: 8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--text-secondary);
|
||||
border-radius: 50%;
|
||||
animation: dotBounce 1.2s infinite ease-in-out;
|
||||
}
|
||||
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
.loading-dots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
.loading-dots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
@keyframes dotBounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.5;
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Audio Controls */
|
||||
|
|
@ -1007,7 +1239,8 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.play-btn {
|
||||
width: 30px; height: 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
|
|
@ -1018,22 +1251,43 @@ onUnmounted(() => {
|
|||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.play-btn:hover { background: rgba(99,102,241,0.3); transform: scale(1.1); }
|
||||
.play-btn.playing { background: rgba(99,102,241,0.25); border-color: var(--accent-1); }
|
||||
.play-btn svg { width: 14px; height: 14px; }
|
||||
.play-btn:hover {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.play-btn.playing {
|
||||
background: rgba(99, 102, 241, 0.25);
|
||||
border-color: var(--accent-1);
|
||||
}
|
||||
.play-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tts-hint { font-size: 0.75rem; color: var(--text-secondary); }
|
||||
.tts-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 用户消息播放按钮右对齐 */
|
||||
.user-audio-controls { justify-content: flex-end; }
|
||||
.user-audio-controls {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.user-audio-controls .play-btn {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
color: #a78bfa;
|
||||
}
|
||||
.user-audio-controls .play-btn:hover { background: rgba(139,92,246,0.35); }
|
||||
.user-audio-controls .play-btn.playing { background: rgba(139,92,246,0.3); border-color: #a78bfa; }
|
||||
.user-audio-controls .wave-bars span { background: #a78bfa; }
|
||||
.user-audio-controls .play-btn:hover {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
.user-audio-controls .play-btn.playing {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
.user-audio-controls .wave-bars span {
|
||||
background: #a78bfa;
|
||||
}
|
||||
|
||||
/* Wave bars animation */
|
||||
.wave-bars {
|
||||
|
|
@ -1048,12 +1302,22 @@ onUnmounted(() => {
|
|||
border-radius: 2px;
|
||||
animation: waveBounce 0.6s infinite alternate;
|
||||
}
|
||||
.wave-bars span:nth-child(2) { animation-delay: 0.15s; }
|
||||
.wave-bars span:nth-child(3) { animation-delay: 0.3s; }
|
||||
.wave-bars span:nth-child(4) { animation-delay: 0.45s; }
|
||||
.wave-bars span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.wave-bars span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.wave-bars span:nth-child(4) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
@keyframes waveBounce {
|
||||
from { height: 3px; }
|
||||
to { height: 14px; }
|
||||
from {
|
||||
height: 3px;
|
||||
}
|
||||
to {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
|
|
@ -1082,11 +1346,17 @@ onUnmounted(() => {
|
|||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.input-box:focus { border-color: var(--accent-1); }
|
||||
.input-box:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.input-box:focus {
|
||||
border-color: var(--accent-1);
|
||||
}
|
||||
.input-box:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 46px; height: 46px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-1), #7c3aed);
|
||||
border: none;
|
||||
|
|
@ -1099,23 +1369,39 @@ onUnmounted(() => {
|
|||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
|
||||
}
|
||||
.send-btn:hover:not(:disabled) { transform: scale(1.08); box-shadow: 0 6px 16px rgba(99,102,241,0.5); }
|
||||
.send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.send-btn svg { width: 20px; height: 20px; }
|
||||
.send-btn:hover:not(:disabled) {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
.send-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.send-spinner {
|
||||
width: 18px; height: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mic Button */
|
||||
.mic-btn {
|
||||
position: relative;
|
||||
width: 46px; height: 46px;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid var(--card-border);
|
||||
|
|
@ -1128,9 +1414,21 @@ onUnmounted(() => {
|
|||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mic-btn svg { width: 20px; height: 20px; position: relative; z-index: 1; }
|
||||
.mic-btn:hover:not(:disabled) { background: rgba(255,255,255,0.12); color: var(--text-primary); border-color: rgba(255,255,255,0.2); }
|
||||
.mic-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.mic-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.mic-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: var(--text-primary);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
.mic-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 录音中状态 */
|
||||
.mic-btn.recording {
|
||||
|
|
@ -1138,7 +1436,9 @@ onUnmounted(() => {
|
|||
border-color: rgba(239, 68, 68, 0.5);
|
||||
color: #ef4444;
|
||||
}
|
||||
.mic-btn.recording:hover:not(:disabled) { background: rgba(239,68,68,0.25); }
|
||||
.mic-btn.recording:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
/* 连接中状态 */
|
||||
.mic-btn.connecting {
|
||||
|
|
@ -1163,14 +1463,24 @@ onUnmounted(() => {
|
|||
animation: micPulse 1.2s ease-out infinite;
|
||||
}
|
||||
@keyframes micPulse {
|
||||
0% { transform: scale(0.85); opacity: 0.8; }
|
||||
70% { transform: scale(1.15); opacity: 0; }
|
||||
100% { transform: scale(0.85); opacity: 0; }
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.8;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 连接中旋转 */
|
||||
.mic-spinner {
|
||||
width: 18px; height: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(251, 191, 36, 0.3);
|
||||
border-top-color: #fbbf24;
|
||||
border-radius: 50%;
|
||||
|
|
@ -1178,9 +1488,15 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page-container { padding: 1rem 1rem 0; }
|
||||
.bubble-wrap { max-width: 85%; }
|
||||
.nav-title { font-size: 1.2rem; }
|
||||
.page-container {
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
.bubble-wrap {
|
||||
max-width: 85%;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue