mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-30 05:13:59 +08:00
Merge pull request #2046 from xinnan-tech/xiaozhi-sercer-testPage
修复测试页面 打断对话多次可能导致无法发出声音的bug
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
// DOM元素
|
||||||
|
const connectButton = document.getElementById('connectButton');
|
||||||
|
const serverUrlInput = document.getElementById('serverUrl');
|
||||||
|
const connectionStatus = document.getElementById('connectionStatus');
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
const sendTextButton = document.getElementById('sendTextButton');
|
||||||
|
const recordButton = document.getElementById('recordButton');
|
||||||
|
const stopButton = document.getElementById('stopButton');
|
||||||
|
// 会话记录
|
||||||
|
const conversationDiv = document.getElementById('conversation');
|
||||||
|
const logContainer = document.getElementById('logContainer');
|
||||||
|
let visualizerCanvas = document.getElementById('audioVisualizer');
|
||||||
|
|
||||||
|
// ota 是否连接成功,修改成对应的样式
|
||||||
|
export function otaStatusStyle (flan) {
|
||||||
|
if(flan){
|
||||||
|
document.getElementById('otaStatus').textContent = 'ota已连接';
|
||||||
|
document.getElementById('otaStatus').style.color = 'green';
|
||||||
|
}else{
|
||||||
|
document.getElementById('otaStatus').textContent = 'ota未连接';
|
||||||
|
document.getElementById('otaStatus').style.color = 'red';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ota 是否连接成功,修改成对应的样式
|
||||||
|
export function getLogContainer (flan) {
|
||||||
|
return logContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新Opus库状态显示
|
||||||
|
export function updateScriptStatus(message, type) {
|
||||||
|
const statusElement = document.getElementById('scriptStatus');
|
||||||
|
if (statusElement) {
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.className = `script-status ${type}`;
|
||||||
|
statusElement.style.display = 'block';
|
||||||
|
statusElement.style.width = 'auto';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加消息到会话记录
|
||||||
|
export function addMessage(text, isUser = false) {
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `message ${isUser ? 'user' : 'server'}`;
|
||||||
|
messageDiv.textContent = text;
|
||||||
|
conversationDiv.appendChild(messageDiv);
|
||||||
|
conversationDiv.scrollTop = conversationDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { log } from './utils/logger.js';
|
||||||
|
import { updateScriptStatus } from './document.js'
|
||||||
|
|
||||||
|
|
||||||
|
// 检查Opus库是否已加载
|
||||||
|
export function checkOpusLoaded() {
|
||||||
|
try {
|
||||||
|
// 检查Module是否存在(本地库导出的全局变量)
|
||||||
|
if (typeof Module === 'undefined') {
|
||||||
|
throw new Error('Opus库未加载,Module对象不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试先使用Module.instance(libopus.js最后一行导出方式)
|
||||||
|
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
||||||
|
// 使用Module.instance对象替换全局Module对象
|
||||||
|
window.ModuleInstance = Module.instance;
|
||||||
|
log('Opus库加载成功(使用Module.instance)', 'success');
|
||||||
|
updateScriptStatus('Opus库加载成功', 'success');
|
||||||
|
|
||||||
|
// 3秒后隐藏状态
|
||||||
|
const statusElement = document.getElementById('scriptStatus');
|
||||||
|
if (statusElement) statusElement.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有Module.instance,检查全局Module函数
|
||||||
|
if (typeof Module._opus_decoder_get_size === 'function') {
|
||||||
|
window.ModuleInstance = Module;
|
||||||
|
log('Opus库加载成功(使用全局Module)', 'success');
|
||||||
|
updateScriptStatus('Opus库加载成功', 'success');
|
||||||
|
|
||||||
|
// 3秒后隐藏状态
|
||||||
|
const statusElement = document.getElementById('scriptStatus');
|
||||||
|
if (statusElement) statusElement.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
||||||
|
} catch (err) {
|
||||||
|
log(`Opus库加载失败,请检查libopus.js文件是否存在且正确: ${err.message}`, 'error');
|
||||||
|
updateScriptStatus('Opus库加载失败,请检查libopus.js文件是否存在且正确', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 创建一个Opus编码器
|
||||||
|
let opusEncoder = null;
|
||||||
|
export function initOpusEncoder() {
|
||||||
|
try {
|
||||||
|
if (opusEncoder) {
|
||||||
|
return opusEncoder; // 已经初始化过
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.ModuleInstance) {
|
||||||
|
log('无法创建Opus编码器:ModuleInstance不可用', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化一个Opus编码器
|
||||||
|
const mod = window.ModuleInstance;
|
||||||
|
const sampleRate = 16000; // 16kHz采样率
|
||||||
|
const channels = 1; // 单声道
|
||||||
|
const application = 2048; // OPUS_APPLICATION_VOIP = 2048
|
||||||
|
|
||||||
|
// 创建编码器
|
||||||
|
opusEncoder = {
|
||||||
|
channels: channels,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
frameSize: 960, // 60ms @ 16kHz = 60 * 16 = 960 samples
|
||||||
|
maxPacketSize: 4000, // 最大包大小
|
||||||
|
module: mod,
|
||||||
|
|
||||||
|
// 初始化编码器
|
||||||
|
init: function () {
|
||||||
|
try {
|
||||||
|
// 获取编码器大小
|
||||||
|
const encoderSize = mod._opus_encoder_get_size(this.channels);
|
||||||
|
log(`Opus编码器大小: ${encoderSize}字节`, 'info');
|
||||||
|
|
||||||
|
// 分配内存
|
||||||
|
this.encoderPtr = mod._malloc(encoderSize);
|
||||||
|
if (!this.encoderPtr) {
|
||||||
|
throw new Error("无法分配编码器内存");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化编码器
|
||||||
|
const err = mod._opus_encoder_init(
|
||||||
|
this.encoderPtr,
|
||||||
|
this.sampleRate,
|
||||||
|
this.channels,
|
||||||
|
application
|
||||||
|
);
|
||||||
|
|
||||||
|
if (err < 0) {
|
||||||
|
throw new Error(`Opus编码器初始化失败: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置位率 (16kbps)
|
||||||
|
mod._opus_encoder_ctl(this.encoderPtr, 4002, 16000); // OPUS_SET_BITRATE
|
||||||
|
|
||||||
|
// 设置复杂度 (0-10, 越高质量越好但CPU使用越多)
|
||||||
|
mod._opus_encoder_ctl(this.encoderPtr, 4010, 5); // OPUS_SET_COMPLEXITY
|
||||||
|
|
||||||
|
// 设置使用DTX (不传输静音帧)
|
||||||
|
mod._opus_encoder_ctl(this.encoderPtr, 4016, 1); // OPUS_SET_DTX
|
||||||
|
|
||||||
|
log("Opus编码器初始化成功", 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.encoderPtr) {
|
||||||
|
mod._free(this.encoderPtr);
|
||||||
|
this.encoderPtr = null;
|
||||||
|
}
|
||||||
|
log(`Opus编码器初始化失败: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 编码PCM数据为Opus
|
||||||
|
encode: function (pcmData) {
|
||||||
|
if (!this.encoderPtr) {
|
||||||
|
if (!this.init()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = this.module;
|
||||||
|
|
||||||
|
// 为PCM数据分配内存
|
||||||
|
const pcmPtr = mod._malloc(pcmData.length * 2); // 2字节/int16
|
||||||
|
|
||||||
|
// 将PCM数据复制到HEAP
|
||||||
|
for (let i = 0; i < pcmData.length; i++) {
|
||||||
|
mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为输出分配内存
|
||||||
|
const outPtr = mod._malloc(this.maxPacketSize);
|
||||||
|
|
||||||
|
// 进行编码
|
||||||
|
const encodedLen = mod._opus_encode(
|
||||||
|
this.encoderPtr,
|
||||||
|
pcmPtr,
|
||||||
|
this.frameSize,
|
||||||
|
outPtr,
|
||||||
|
this.maxPacketSize
|
||||||
|
);
|
||||||
|
|
||||||
|
if (encodedLen < 0) {
|
||||||
|
throw new Error(`Opus编码失败: ${encodedLen}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制编码后的数据
|
||||||
|
const opusData = new Uint8Array(encodedLen);
|
||||||
|
for (let i = 0; i < encodedLen; i++) {
|
||||||
|
opusData[i] = mod.HEAPU8[outPtr + i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 释放内存
|
||||||
|
mod._free(pcmPtr);
|
||||||
|
mod._free(outPtr);
|
||||||
|
|
||||||
|
return opusData;
|
||||||
|
} catch (error) {
|
||||||
|
log(`Opus编码出错: ${error.message}`, 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 销毁编码器
|
||||||
|
destroy: function () {
|
||||||
|
if (this.encoderPtr) {
|
||||||
|
this.module._free(this.encoderPtr);
|
||||||
|
this.encoderPtr = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
opusEncoder.init();
|
||||||
|
return opusEncoder;
|
||||||
|
} catch (error) {
|
||||||
|
log(`创建Opus编码器失败: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export default class BlockingQueue {
|
||||||
|
#items = [];
|
||||||
|
#waiters = []; // {resolve, reject, min, timer, onTimeout}
|
||||||
|
|
||||||
|
/* 空队列一次性闸门 */
|
||||||
|
#emptyPromise = null;
|
||||||
|
#emptyResolve = null;
|
||||||
|
|
||||||
|
/* 生产者:把数据塞进去 */
|
||||||
|
enqueue(item, ...restItems) {
|
||||||
|
if (restItems.length === 0) {
|
||||||
|
this.#items.push(item);
|
||||||
|
}
|
||||||
|
// 如果有额外参数,批量处理所有项
|
||||||
|
else {
|
||||||
|
const items = [item, ...restItems].filter(i => i);
|
||||||
|
if (items.length === 0) return;
|
||||||
|
this.#items.push(...items);
|
||||||
|
}
|
||||||
|
// 若有空队列闸门,一次性放行所有等待者
|
||||||
|
if (this.#emptyResolve) {
|
||||||
|
this.#emptyResolve();
|
||||||
|
this.#emptyResolve = null;
|
||||||
|
this.#emptyPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 唤醒所有正在等的 waiter
|
||||||
|
this.#wakeWaiters();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 消费者:min 条或 timeout ms 先到谁 */
|
||||||
|
async dequeue(min = 1, timeout = Infinity, onTimeout = null) {
|
||||||
|
// 1. 若空,等第一次数据到达(所有调用共享同一个 promise)
|
||||||
|
if (this.#items.length === 0) {
|
||||||
|
await this.#waitForFirstItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即满足
|
||||||
|
if (this.#items.length >= min) {
|
||||||
|
return this.#flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要等待
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timer = null;
|
||||||
|
const waiter = { resolve, reject, min, onTimeout, timer };
|
||||||
|
|
||||||
|
// 超时逻辑
|
||||||
|
if (Number.isFinite(timeout)) {
|
||||||
|
waiter.timer = setTimeout(() => {
|
||||||
|
this.#removeWaiter(waiter);
|
||||||
|
if (onTimeout) onTimeout(this.#items.length);
|
||||||
|
resolve(this.#flush());
|
||||||
|
}, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#waiters.push(waiter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空队列闸门生成器 */
|
||||||
|
#waitForFirstItem() {
|
||||||
|
if (!this.#emptyPromise) {
|
||||||
|
this.#emptyPromise = new Promise(r => (this.#emptyResolve = r));
|
||||||
|
}
|
||||||
|
return this.#emptyPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内部:每次数据变动后,检查哪些 waiter 已满足 */
|
||||||
|
#wakeWaiters() {
|
||||||
|
for (let i = this.#waiters.length - 1; i >= 0; i--) {
|
||||||
|
const w = this.#waiters[i];
|
||||||
|
if (this.#items.length >= w.min) {
|
||||||
|
this.#removeWaiter(w);
|
||||||
|
w.resolve(this.#flush());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#removeWaiter(waiter) {
|
||||||
|
const idx = this.#waiters.indexOf(waiter);
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.#waiters.splice(idx, 1);
|
||||||
|
if (waiter.timer) clearTimeout(waiter.timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#flush() {
|
||||||
|
const snapshot = [...this.#items];
|
||||||
|
this.#items.length = 0;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 当前缓存长度(不含等待者) */
|
||||||
|
get length() {
|
||||||
|
return this.#items.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getLogContainer } from '../document.js'
|
||||||
|
|
||||||
|
const logContainer = getLogContainer();
|
||||||
|
// 日志记录函数
|
||||||
|
export function log(message, type = 'info') {
|
||||||
|
// 将消息按换行符分割成多行
|
||||||
|
const lines = message.split('\n');
|
||||||
|
const now = new Date();
|
||||||
|
// const timestamp = `[${now.toLocaleTimeString()}] `;
|
||||||
|
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
|
||||||
|
// 为每一行创建日志条目
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
const logEntry = document.createElement('div');
|
||||||
|
logEntry.className = `log-entry log-${type}`;
|
||||||
|
// 如果是第一条日志,显示时间戳
|
||||||
|
const prefix = index === 0 ? timestamp : ' '.repeat(timestamp.length);
|
||||||
|
logEntry.textContent = `${prefix}${line}`;
|
||||||
|
// logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||||||
|
// logEntry.style 保留起始的空格
|
||||||
|
logEntry.style.whiteSpace = 'pre';
|
||||||
|
if (type === 'error') {
|
||||||
|
logEntry.style.color = 'red';
|
||||||
|
} else if (type === 'debug') {
|
||||||
|
logEntry.style.color = 'gray';
|
||||||
|
return;
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
logEntry.style.color = 'orange';
|
||||||
|
} else if (type === 'success') {
|
||||||
|
logEntry.style.color = 'green';
|
||||||
|
} else {
|
||||||
|
logEntry.style.color = 'black';
|
||||||
|
}
|
||||||
|
logContainer.appendChild(logEntry);
|
||||||
|
});
|
||||||
|
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { log } from './utils/logger.js';
|
||||||
|
import { otaStatusStyle } from './document.js'
|
||||||
|
|
||||||
|
// WebSocket 连接
|
||||||
|
export async function webSocketConnect(otaUrl,wsUrl,config){
|
||||||
|
if (!validateWsUrl(wsUrl)) {
|
||||||
|
return; // 直接返回,不再往下执行
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateConfig(config)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ok = await sendOTA(otaUrl, config);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
// 使用自定义WebSocket实现以添加认证头信息
|
||||||
|
let connUrl = new URL(wsUrl);
|
||||||
|
// 添加认证参数
|
||||||
|
connUrl.searchParams.append('device-id', config.deviceId);
|
||||||
|
connUrl.searchParams.append('client-id', config.clientId);
|
||||||
|
log(`正在连接: ${connUrl.toString()}`, 'info');
|
||||||
|
|
||||||
|
return new WebSocket(connUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证配置
|
||||||
|
function validateConfig(config) {
|
||||||
|
if (!config.deviceMac) {
|
||||||
|
log('设备MAC地址不能为空', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!config.clientId) {
|
||||||
|
log('客户端ID不能为空', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断wsUrl路径是否存在错误
|
||||||
|
function validateWsUrl(wsUrl){
|
||||||
|
if (wsUrl === '') return false;
|
||||||
|
// 检查URL格式
|
||||||
|
if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
|
||||||
|
log('URL格式错误,必须以ws://或wss://开头', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// OTA发送请求,验证状态
|
||||||
|
async function sendOTA(otaUrl, config) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(otaUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Device-Id': config.deviceId,
|
||||||
|
'Client-Id': config.clientId
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
version: 0,
|
||||||
|
uuid: '',
|
||||||
|
application: {
|
||||||
|
name: 'xiaozhi-web-test',
|
||||||
|
version: '1.0.0',
|
||||||
|
compile_time: '2025-04-16 10:00:00',
|
||||||
|
idf_version: '4.4.3',
|
||||||
|
elf_sha256: '1234567890abcdef1234567890abcdef1234567890abcdef'
|
||||||
|
},
|
||||||
|
ota: { label: 'xiaozhi-web-test' },
|
||||||
|
board: {
|
||||||
|
type: 'xiaozhi-web-test',
|
||||||
|
ssid: 'xiaozhi-web-test',
|
||||||
|
rssi: 0,
|
||||||
|
channel: 0,
|
||||||
|
ip: '192.168.1.1',
|
||||||
|
mac: config.deviceMac
|
||||||
|
},
|
||||||
|
flash_size: 0,
|
||||||
|
minimum_free_heap_size: 0,
|
||||||
|
mac_address: config.deviceMac,
|
||||||
|
chip_model_name: '',
|
||||||
|
chip_info: { model: 0, cores: 0, revision: 0, features: 0 },
|
||||||
|
partition_table: [{ label: '', type: 0, subtype: 0, address: 0, size: 0 }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
otaStatusStyle(true)
|
||||||
|
return true; // 成功
|
||||||
|
} catch (err) {
|
||||||
|
otaStatusStyle(false)
|
||||||
|
return false; // 失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 10px 20px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #444;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h2 .toggle-button {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-left: 20px;
|
||||||
|
padding: 0 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info span {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info strong {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel {
|
||||||
|
display: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin-top: 5px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-panel.expanded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #4285f4;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#serverUrl,
|
||||||
|
#otaUrl {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#messageInput {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#nfcCardId {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: white;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
background-color: #e2f2ff;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 10px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-visualizer {
|
||||||
|
height: 60px;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button {
|
||||||
|
background-color: #db4437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button:hover {
|
||||||
|
background-color: #c53929;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-button.recording {
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
background-color: #db4437;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
background-color: #ff6659;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-color: #db4437;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#logContainer {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin: 5px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-info {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-error {
|
||||||
|
color: #db4437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-success {
|
||||||
|
color: #0f9d58;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-status {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-loaded {
|
||||||
|
background-color: #0f9d58;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-loading {
|
||||||
|
background-color: #f4b400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-error {
|
||||||
|
background-color: #db4437;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scriptStatus.success {
|
||||||
|
background-color: #e6f4ea;
|
||||||
|
color: #0f9d58;
|
||||||
|
border-left: 4px solid #0f9d58;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scriptStatus.error {
|
||||||
|
background-color: #fce8e6;
|
||||||
|
color: #db4437;
|
||||||
|
border-left: 4px solid #db4437;
|
||||||
|
}
|
||||||
|
|
||||||
|
#scriptStatus.warning {
|
||||||
|
background-color: #fef7e0;
|
||||||
|
color: #f4b400;
|
||||||
|
border-left: 4px solid #f4b400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #4285f4;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item label {
|
||||||
|
width: 100px;
|
||||||
|
text-align: right;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-controls input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-controls button {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 8px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-left: 20px;
|
||||||
|
padding: 0 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status span {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status .status {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
+1466
-2204
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user