feat/初始化
This commit is contained in:
parent
d402af23f4
commit
3c84959842
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "my-vue-app",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --mode production",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@types/dat.gui": "^0.7.13",
|
||||
"axios": "^1.6.7",
|
||||
"dat.gui": "^0.7.9",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.10.2",
|
||||
"less": "^4.3.0",
|
||||
"moment": "^2.30.1",
|
||||
"mqtt": "^5.13.2",
|
||||
"sass": "^1.89.2",
|
||||
"sass-loader": "^16.0.5",
|
||||
"vue": "^3.2.25",
|
||||
"vue-router": "^4.5.1",
|
||||
"wav-encoder": "^1.3.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^2.3.4",
|
||||
"unplugin-auto-import": "^19.3.0",
|
||||
"unplugin-vue-components": "^28.7.0",
|
||||
"vite": "^2.9.15"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -0,0 +1,16 @@
|
|||
<!--
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-06-10 14:36:16
|
||||
* @Description:
|
||||
-->
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-08-22 17:04:41
|
||||
* @Description:
|
||||
*/
|
||||
const config = {
|
||||
"page": "Ecommerce Home",
|
||||
"commands": [
|
||||
{
|
||||
"command": "open_login_111",
|
||||
"description": "打开登录弹窗或页面",
|
||||
"action": "click",
|
||||
"selector": "#login-button",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"command": "open_login_222_333",
|
||||
"description": "打开登录弹窗或页面",
|
||||
"action": "click",
|
||||
"selector": "#login-button",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"command": "input_username",
|
||||
"description": "在用户名输入框中填写用户名",
|
||||
"action": "input",
|
||||
"selector": "#username-input",
|
||||
"params": [
|
||||
{
|
||||
"name": "username",
|
||||
"type": "string",
|
||||
"description": "要输入的用户名"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"command": "input_password",
|
||||
"description": "在密码输入框中填写密码",
|
||||
"action": "input",
|
||||
"selector": "#password-input",
|
||||
"params": [
|
||||
{
|
||||
"name": "password",
|
||||
"type": "string",
|
||||
"description": "要输入的密码"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"command": "submit_login",
|
||||
"description": "提交登录表单",
|
||||
"action": "click",
|
||||
"selector": "#submit-login",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"command": "navigate_to_product",
|
||||
"description": "导航到指定产品页面",
|
||||
"action": "navigate",
|
||||
"selector": "",
|
||||
"params": [
|
||||
{
|
||||
"name": "product_url",
|
||||
"type": "string",
|
||||
"description": "产品页面的URL"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"command": "add_to_cart",
|
||||
"description": "将当前产品添加到购物车",
|
||||
"action": "click",
|
||||
"selector": ".add-to-cart-btn",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"command": "search_product",
|
||||
"description": "在搜索框中输入关键词并搜索",
|
||||
"action": "input_and_submit",
|
||||
"selector": "#search-input",
|
||||
"params": [
|
||||
{
|
||||
"name": "keyword",
|
||||
"type": "string",
|
||||
"description": "搜索关键词"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-06-10 14:36:16
|
||||
* @Description:
|
||||
*/
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCN from 'element-plus/dist/locale/zh-cn.mjs' // 引入中文
|
||||
import router from './router' // 引入路由配置
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
const app = createApp(App);
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
app.use(ElementPlus, { locale: zhCN }).use(router).mount('#app')
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-08-07 10:45:45
|
||||
* @Description:
|
||||
*/
|
||||
// src/router/index.js
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
// 导入需要路由的组件
|
||||
import index from './../view/index.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'index',
|
||||
component: index
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/'),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
@ -0,0 +1,604 @@
|
|||
import mqtt from "mqtt";
|
||||
|
||||
// 实例池,用于缓存相同配置的实例(仅用于MQTT)
|
||||
const instancePool = new Map();
|
||||
|
||||
class DataMiddleware {
|
||||
/**
|
||||
* 构造函数
|
||||
* @param {Object} options - 实例配置
|
||||
*/
|
||||
constructor(options = {}) {
|
||||
// 存储实例配置
|
||||
this.options = {
|
||||
url: options.url || '',
|
||||
reconnectConfig: {
|
||||
maxRetries: 10, //最大重连次数
|
||||
initialDelay: 1000, //
|
||||
maxDelay: 30000,
|
||||
...options.reconnectConfig
|
||||
},
|
||||
mqttOptions: options.mqttOptions || {},
|
||||
httpOptions: options.httpOptions || {},
|
||||
reconnect: true
|
||||
};
|
||||
|
||||
// 存储所有请求/订阅的信息
|
||||
this.requests = {
|
||||
http: new Map(), // HTTP请求仅用于跟踪当前请求以便取消
|
||||
mqtt: new Map() // MQTT订阅缓存: key为topic
|
||||
};
|
||||
|
||||
// 客户端实例
|
||||
this.mqttClient = null;
|
||||
|
||||
// 连接状态
|
||||
this.connectionStatus = {
|
||||
mqtt: 'disconnected', // disconnected, connecting, connected, error
|
||||
http: 'idle',
|
||||
retryCount: 0
|
||||
};
|
||||
|
||||
// 用于生成唯一订阅ID
|
||||
this.subscriptionIdCounter = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取实例的唯一标识键
|
||||
*/
|
||||
getInstanceKey() {
|
||||
return this.options.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态方法:获取或创建实例
|
||||
*/
|
||||
static getInstance(options = {}) {
|
||||
const key = options.url || '';
|
||||
|
||||
if (instancePool.has(key)) {
|
||||
return instancePool.get(key);
|
||||
}
|
||||
|
||||
const instance = new DataMiddleware(options);
|
||||
instancePool.set(key, instance);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前连接状态
|
||||
*/
|
||||
getStatus() {
|
||||
return { ...this.connectionStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前MQTT客户端实例(用于发布消息)
|
||||
*/
|
||||
getMqttClient() {
|
||||
return this.mqttClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP请求方法 - 优化参数处理,支持keys可选
|
||||
* @param {string} url - 请求地址
|
||||
* @param {string[]|Function} [keys] - 需要提取的key数组,可选
|
||||
* @param {Function} [callback] - 回调函数,格式: (data, error) => {},可选
|
||||
* @returns {Promise} 返回Promise对象,同时通过callback暴露结果
|
||||
*/
|
||||
httpRequest(url, keys, callback) {
|
||||
// 处理参数:支持keys可选,自动识别callback
|
||||
let actualKeys = [];
|
||||
let actualCallback = null;
|
||||
|
||||
if (typeof keys === 'function') {
|
||||
actualCallback = keys;
|
||||
actualKeys = [];
|
||||
} else if (Array.isArray(keys) && typeof callback === 'function') {
|
||||
actualKeys = keys;
|
||||
actualCallback = callback;
|
||||
} else if (Array.isArray(keys)) {
|
||||
actualKeys = keys;
|
||||
actualCallback = null;
|
||||
} else if (arguments.length === 1) {
|
||||
actualKeys = [];
|
||||
actualCallback = null;
|
||||
}
|
||||
|
||||
// 生成唯一请求ID
|
||||
const requestId = this.generateSubscriptionId();
|
||||
|
||||
// 创建AbortController用于取消请求
|
||||
const controller = new AbortController();
|
||||
|
||||
// 存储请求信息以便后续取消
|
||||
this.requests.http.set(requestId, {
|
||||
controller,
|
||||
url
|
||||
});
|
||||
|
||||
// 将keys数组转换为逗号分隔的字符串
|
||||
const keysStr = Array.isArray(actualKeys) && actualKeys.length > 0
|
||||
? actualKeys.join(',')
|
||||
: '';
|
||||
|
||||
// 构建URL并添加keys参数
|
||||
const fullUrl = new URL(url);
|
||||
if (keysStr) {
|
||||
fullUrl.searchParams.append('keys', keysStr);
|
||||
}
|
||||
|
||||
// 构建请求选项,禁用缓存
|
||||
const fetchOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
...this.options.httpOptions.headers
|
||||
},
|
||||
signal: controller.signal,
|
||||
...this.options.httpOptions
|
||||
};
|
||||
|
||||
this.connectionStatus.http = 'loading';
|
||||
|
||||
// 返回Promise,同时支持callback
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
fetch(fullUrl.toString(), fetchOptions)
|
||||
.then(response => {
|
||||
this.connectionStatus.http = 'idle';
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// 请求完成后从跟踪表中移除
|
||||
this.requests.http.delete(requestId);
|
||||
|
||||
// 根据keys过滤数据
|
||||
// const filteredData = this._filterDataByKeys(data, actualKeys);
|
||||
|
||||
// 调用回调函数(如果提供)
|
||||
if (typeof actualCallback === 'function') {
|
||||
actualCallback(data, null);
|
||||
}
|
||||
|
||||
resolve(data);
|
||||
})
|
||||
.catch(error => {
|
||||
this.connectionStatus.http = 'idle';
|
||||
this.requests.http.delete(requestId);
|
||||
|
||||
// 忽略取消请求的错误
|
||||
if (error.name !== 'AbortError') {
|
||||
// 调用回调函数(如果提供)
|
||||
if (typeof actualCallback === 'function') {
|
||||
actualCallback(null, error);
|
||||
}
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 将请求ID附加到promise上,方便取消请求
|
||||
promise.requestId = requestId;
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消HTTP请求
|
||||
* @param {string} requestId - 请求ID
|
||||
* @returns {boolean} 是否取消成功
|
||||
*/
|
||||
cancelHttpRequest(requestId) {
|
||||
if (this.requests.http.has(requestId)) {
|
||||
const { controller } = this.requests.http.get(requestId);
|
||||
controller.abort();
|
||||
this.requests.http.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到MQTT服务器
|
||||
*/
|
||||
_connectMqtt() {
|
||||
if (!this.options.url) {
|
||||
console.error('MQTT WebSocket URL未配置');
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有连接,先断开
|
||||
if (this.mqttClient) {
|
||||
this.mqttClient.end(false, {}, () => {
|
||||
console.log('已断开现有MQTT连接');
|
||||
});
|
||||
}
|
||||
|
||||
this.connectionStatus.mqtt = 'connecting';
|
||||
this._notifyAllMqttSubscribers({
|
||||
type: 'status',
|
||||
status: 'connecting'
|
||||
});
|
||||
|
||||
// 使用mqtt.js连接
|
||||
this.mqttClient = mqtt.connect(this.options.url, {
|
||||
clientId: `vue-client-${Math.random().toString(36).substr(2, 10)}`,
|
||||
clean: true,
|
||||
reconnectPeriod: 0, // 禁用内置重连,使用自定义重连策略
|
||||
...this.options.mqttOptions
|
||||
});
|
||||
|
||||
|
||||
// 连接成功回调
|
||||
this.mqttClient.on('connect', () => {
|
||||
console.log('MQTT连接成功');
|
||||
this.connectionStatus.mqtt = 'connected';
|
||||
this.connectionStatus.retryCount = 0;
|
||||
|
||||
// 通知所有订阅者
|
||||
this._notifyAllMqttSubscribers({
|
||||
type: 'status',
|
||||
status: 'connected'
|
||||
});
|
||||
|
||||
// 重新订阅所有主题
|
||||
this.requests.mqtt.forEach((info, topic) => {
|
||||
this.mqttClient.subscribe(topic, { qos: info.qos });
|
||||
});
|
||||
});
|
||||
|
||||
// 收到消息回调
|
||||
this.mqttClient.on('message', (topic, message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
this._onMqttMessage(topic, data);
|
||||
} catch (error) {
|
||||
console.error('解析MQTT消息失败:', error);
|
||||
this._notifyMqttError(topic, new Error('消息格式错误'));
|
||||
}
|
||||
});
|
||||
|
||||
// 连接断开回调
|
||||
this.mqttClient.on('close', () => {
|
||||
if (this.connectionStatus.mqtt !== 'disconnected') {
|
||||
console.log('MQTT连接已关闭');
|
||||
this.connectionStatus.mqtt = 'disconnected';
|
||||
this._notifyAllMqttSubscribers({
|
||||
type: 'status',
|
||||
status: 'disconnected'
|
||||
});
|
||||
if(this.reconnect) {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 错误回调
|
||||
this.mqttClient.on('error', (error) => {
|
||||
console.error('MQTT错误:', error);
|
||||
this.connectionStatus.mqtt = 'error';
|
||||
this._notifyAllMqttSubscribers({
|
||||
type: 'error',
|
||||
error: new Error(`连接错误: ${error.message}`)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅MQTT主题
|
||||
*/
|
||||
|
||||
|
||||
mqttSubscribe(topics, callback, options = {}) {
|
||||
if (!this.options.url) {
|
||||
throw new Error('MQTT WebSocket URL未配置');
|
||||
}
|
||||
|
||||
// 验证输入参数
|
||||
if (!Array.isArray(topics) || topics.length === 0) {
|
||||
throw new Error('请提供有效的主题数组');
|
||||
}
|
||||
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('请提供回调函数');
|
||||
}
|
||||
|
||||
const subIds = [];
|
||||
|
||||
// 遍历主题数组,为每个主题创建订阅
|
||||
topics.forEach(topic => {
|
||||
const subId = this.generateSubscriptionId();
|
||||
const mqttKey = topic;
|
||||
|
||||
// 添加订阅者
|
||||
if (this.requests.mqtt.has(mqttKey)) {
|
||||
const mqttInfo = this.requests.mqtt.get(mqttKey);
|
||||
mqttInfo.subscribers[subId] = { callback };
|
||||
} else {
|
||||
const mqttInfo = {
|
||||
topic,
|
||||
options,
|
||||
subscribers: {
|
||||
[subId]: { callback }
|
||||
},
|
||||
qos: options.qos || 1
|
||||
};
|
||||
this.requests.mqtt.set(mqttKey, mqttInfo);
|
||||
}
|
||||
|
||||
subIds.push(subId);
|
||||
});
|
||||
|
||||
// 连接MQTT(如果尚未连接)
|
||||
if (!this.mqttClient || !this.mqttClient.connected) {
|
||||
this._connectMqtt();
|
||||
} else {
|
||||
// 已连接状态下直接订阅所有主题
|
||||
topics.forEach(topic => {
|
||||
this.mqttClient.subscribe(topic, { qos: options.qos || 0 });
|
||||
});
|
||||
}
|
||||
|
||||
// 返回所有订阅ID的数组
|
||||
return subIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理接收到的MQTT消息
|
||||
*/
|
||||
_onMqttMessage(topic, data) {
|
||||
const mqttInfo = this.requests.mqtt.get(topic);
|
||||
if (!mqttInfo) return;
|
||||
|
||||
// 通知该主题的所有订阅者
|
||||
Object.values(mqttInfo.subscribers).forEach(({ keys, callback }) => {
|
||||
try {
|
||||
const filteredData = this._filterDataByKeys(data, keys);
|
||||
callback({
|
||||
type: 'data',
|
||||
data: filteredData,
|
||||
topic,
|
||||
timestamp: Date.now()
|
||||
}, null);
|
||||
} catch (error) {
|
||||
console.error('通知MQTT订阅者失败:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布MQTT消息
|
||||
*/
|
||||
mqttPublish(topic, message, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.mqttClient || !this.mqttClient.connected) {
|
||||
reject(new Error('MQTT客户端未连接'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.mqttClient.publish(topic, JSON.stringify(message), options, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排重连
|
||||
*/
|
||||
_scheduleReconnect() {
|
||||
if (this.connectionStatus.retryCount >= this.options.reconnectConfig.maxRetries) {
|
||||
console.error('已达到最大重连次数');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectionStatus.retryCount++;
|
||||
const delay = Math.min(
|
||||
this.options.reconnectConfig.initialDelay * Math.pow(2, this.connectionStatus.retryCount - 1),
|
||||
this.options.reconnectConfig.maxDelay
|
||||
);
|
||||
|
||||
console.log(`将在 ${delay}ms 后尝试重连 (第 ${this.connectionStatus.retryCount} 次)`);
|
||||
|
||||
setTimeout(() => {
|
||||
this._connectMqtt();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算重连延迟
|
||||
*/
|
||||
_calculateReconnectDelay(attempt) {
|
||||
const delay = this.options.reconnectConfig.initialDelay * Math.pow(2, attempt);
|
||||
return Math.min(delay, this.options.reconnectConfig.maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
unsubscribe(data) {
|
||||
if(data && data.length) {
|
||||
data.forEach((d)=> {
|
||||
this.unsubscribeFun(d);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*/
|
||||
unsubscribeFun(subId) {
|
||||
// if (!['http', 'mqtt'].includes(type)) {
|
||||
// console.error('无效的订阅类型');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
let removed = false;
|
||||
this.requests['mqtt'].forEach((info, key) => {
|
||||
|
||||
if (info.subscribers && info.subscribers[subId]) {
|
||||
delete info.subscribers[subId];
|
||||
removed = true;
|
||||
console.log(info.subscribers,"----info.subscribers");
|
||||
|
||||
// 如果没有订阅者了,清理资源
|
||||
if (info.subscribers && Object.keys(info.subscribers).length === 0) {
|
||||
console.log('移除------');
|
||||
this._cleanupRequest( key, info);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理不再有订阅者的请求/订阅
|
||||
*/
|
||||
_cleanupRequest(key, info) {
|
||||
if (this.mqttClient && this.mqttClient.connected) {
|
||||
this.mqttClient.unsubscribe(info.topic);
|
||||
}
|
||||
this.requests.mqtt.delete(key);
|
||||
if(!this.requests.mqtt.size) {
|
||||
this.destroy();
|
||||
}
|
||||
// if (type === 'http') {
|
||||
// 清除HTTP请求的控制器
|
||||
// if (info.controller) {
|
||||
// info.controller.abort();
|
||||
// }
|
||||
// this.requests.http.delete(key);
|
||||
// } else if (type === 'mqtt') {
|
||||
// if (this.mqttClient && this.mqttClient.connected) {
|
||||
// this.mqttClient.unsubscribe(info.topic);
|
||||
// }
|
||||
// this.requests.mqtt.delete(key);
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一订阅ID
|
||||
*/
|
||||
generateSubscriptionId() {
|
||||
return `sub_${this.subscriptionIdCounter++}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key数组过滤数据
|
||||
*/
|
||||
_filterDataByKeys(data, keys) {
|
||||
if (!keys || keys.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => this._filterObjectByKeys(item, keys));
|
||||
}
|
||||
|
||||
return this._filterObjectByKeys(data, keys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据key数组过滤对象属性
|
||||
*/
|
||||
_filterObjectByKeys(obj, keys) {
|
||||
if (typeof obj !== 'object' || obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
return keys.reduce((result, key) => {
|
||||
const value = this._getNestedValue(obj, key);
|
||||
if (value !== undefined) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对象的嵌套属性值
|
||||
*/
|
||||
_getNestedValue(obj, key) {
|
||||
return key.split('.').reduce((current, part) => {
|
||||
if (current && typeof current === 'object' && part in current) {
|
||||
return current[part];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有MQTT订阅者
|
||||
*/
|
||||
_notifyAllMqttSubscribers(message) {
|
||||
this.requests.mqtt.forEach((mqttInfo) => {
|
||||
Object.values(mqttInfo.subscribers).forEach(({ callback }) => {
|
||||
try {
|
||||
if (message.error) {
|
||||
callback(null, message.error);
|
||||
} else {
|
||||
callback(message, null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('通知MQTT订阅者失败:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知MQTT订阅者错误
|
||||
*/
|
||||
_notifyMqttError(topic, error) {
|
||||
const mqttInfo = this.requests.mqtt.get(topic);
|
||||
if (!mqttInfo) return;
|
||||
|
||||
Object.values(mqttInfo.subscribers).forEach(({ callback }) => {
|
||||
try {
|
||||
callback(null, error);
|
||||
} catch (err) {
|
||||
console.error('通知MQTT订阅者错误失败:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁实例
|
||||
*/
|
||||
destroy() {
|
||||
// 断开MQTT连接
|
||||
if (this.mqttClient) {
|
||||
this.reconnect = false;
|
||||
this.mqttClient.end(true);
|
||||
this.mqttClient = null;
|
||||
}
|
||||
|
||||
// 取消所有HTTP请求
|
||||
// this.requests.http.forEach(info => {
|
||||
// if (info.controller) {
|
||||
// info.controller.abort();
|
||||
// }
|
||||
// });
|
||||
|
||||
// 清空请求缓存
|
||||
// this.requests.http.clear();
|
||||
this.requests.mqtt.clear();
|
||||
|
||||
// 从实例池移除
|
||||
instancePool.delete(this.getInstanceKey());
|
||||
}
|
||||
}
|
||||
|
||||
export default DataMiddleware;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
// 防抖函数实现
|
||||
export const debounce = (func, delay) => {
|
||||
let timer = null;
|
||||
|
||||
// 返回一个新函数
|
||||
return function (...args) {
|
||||
// 保存当前上下文
|
||||
const context = this;
|
||||
|
||||
// 如果定时器已存在,则清除它
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 创建新的定时器
|
||||
timer = setTimeout(() => {
|
||||
// 延迟后执行原函数,并绑定正确的上下文和参数
|
||||
func.apply(context, args);
|
||||
// 执行后清除定时器
|
||||
timer = null;
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
// 节流函数实现 - 定时器版(确保最后一次调用会被执行)
|
||||
export const throttle = (func, interval) => {
|
||||
console.log(interval);
|
||||
|
||||
let timer = null;
|
||||
let lastTime = 0;
|
||||
|
||||
return function(...args) {
|
||||
const context = this;
|
||||
const now = Date.now();
|
||||
|
||||
// 清除之前的定时器
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
// 如果达到执行间隔,立即执行
|
||||
if (now - lastTime >= interval) {
|
||||
func.apply(context, args);
|
||||
lastTime = now;
|
||||
} else {
|
||||
// 否则设置定时器,确保最后一次调用会被执行
|
||||
timer = setTimeout(() => {
|
||||
func.apply(context, args);
|
||||
lastTime = Date.now();
|
||||
timer = null;
|
||||
}, interval - (now - lastTime));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
class VoiceControlSystem {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.isListening = false;
|
||||
this.recognition = null;
|
||||
this.apiKey = 'sk-020189889aac40f3b050f7c60ca597f8'; // 替换为您的DeepSeek API密钥
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
console.log(123);
|
||||
this.setupSpeechRecognition();
|
||||
this.setupEventListeners();
|
||||
this.uploadConfigToModel();
|
||||
this.getSupportedLanguages();
|
||||
}
|
||||
|
||||
// 检测浏览器支持的语音识别语言(部分 Chromium 浏览器支持)
|
||||
async getSupportedLanguages() {
|
||||
console.log(123);
|
||||
|
||||
if (window.SpeechRecognition?.getSupportedLanguages) {
|
||||
try {
|
||||
const languages = await window.SpeechRecognition.getSupportedLanguages();
|
||||
console.log('浏览器支持的语言列表:', languages);
|
||||
return languages;
|
||||
} catch (e) {
|
||||
console.warn('无法获取支持的语言列表:', e);
|
||||
}
|
||||
}
|
||||
// 若浏览器不支持 getSupportedLanguages,返回常见兼容语言
|
||||
return ['zh-CN', 'en-US', 'zh-TW', 'en-GB'];
|
||||
}
|
||||
|
||||
setupSpeechRecognition() {
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
this.updateStatus('浏览器不支持语音识别功能', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.lang = 'zh-CN';
|
||||
this.recognition.extra = {
|
||||
'alternatives': 5,
|
||||
'language': ['zh-CN', 'zh-HK', 'zh-TW', 'en-US'] // 提供备选语言
|
||||
};
|
||||
this.recognition.continuous = false;
|
||||
this.recognition.interimResults = false;
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
this.isListening = true;
|
||||
this.updateStatus('正在聆听...', 'listening');
|
||||
document.getElementById('voice-btn').classList.add('listening');
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListening = false;
|
||||
document.getElementById('voice-btn').classList.remove('listening');
|
||||
this.updateStatus('准备就绪', 'ready');
|
||||
};
|
||||
|
||||
this.recognition.onresult = async (event) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
console.log(transcript,"-----transcript");
|
||||
|
||||
this.updateStatus(`识别结果: "${transcript}"`, 'success');
|
||||
await this.processVoiceCommand(transcript);
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
this.updateStatus(`语音识别错误: ${event.error}`, 'error');
|
||||
};
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const voiceBtn = document.getElementById('voice-btn');
|
||||
voiceBtn.addEventListener('click', () => {
|
||||
const input = document.getElementById('input-text');
|
||||
let val = input.value;
|
||||
console.log(val,"---val");
|
||||
this.toggleListening(val)
|
||||
});
|
||||
|
||||
// 添加API密钥配置界面
|
||||
this.setupApiKeyConfig();
|
||||
}
|
||||
|
||||
setupApiKeyConfig() {
|
||||
const configBtn = document.createElement('button');
|
||||
configBtn.textContent = '配置API密钥';
|
||||
configBtn.style.marginLeft = '10px';
|
||||
configBtn.addEventListener('click', () => this.showApiKeyConfig());
|
||||
document.getElementById('voice-btn').after(configBtn);
|
||||
}
|
||||
|
||||
showApiKeyConfig() {
|
||||
const apiKey = prompt('请输入DeepSeek API密钥:', this.apiKey);
|
||||
if (apiKey !== null) {
|
||||
this.apiKey = apiKey;
|
||||
this.updateStatus('API密钥已更新', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleListening(value) {
|
||||
if (!this.apiKey) {
|
||||
this.updateStatus('请先配置DeepSeek API密钥', 'error');
|
||||
this.showApiKeyConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
let transcript = value;
|
||||
await this.processVoiceCommand(transcript);
|
||||
|
||||
// if (this.isListening) {
|
||||
// this.recognition.stop();
|
||||
// } else {
|
||||
// this.recognition.start();
|
||||
// }
|
||||
}
|
||||
|
||||
async uploadConfigToModel() {
|
||||
try {
|
||||
this.updateStatus('配置文件已加载', 'info');
|
||||
console.log('DeepSeek配置已准备:', this.config);
|
||||
} catch (error) {
|
||||
this.updateStatus('配置加载失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async processVoiceCommand(voiceText) {
|
||||
try {
|
||||
this.updateStatus('正在调用DeepSeek分析指令...', 'info');
|
||||
const sequence = await this.callDeepSeekAPI(voiceText);
|
||||
this.updateStatus('DeepSeek指令序列生成成功', 'success');
|
||||
await this.executeSequence(sequence);
|
||||
} catch (error) {
|
||||
console.error('DeepSeek处理错误:', error);
|
||||
this.updateStatus(`处理失败: ${error.message}`, 'error');
|
||||
|
||||
// 尝试使用备用方案
|
||||
try {
|
||||
this.updateStatus('尝试使用备用方案...', 'info');
|
||||
const fallbackSequence = this.fallbackModelResponse(voiceText);
|
||||
await this.executeSequence(fallbackSequence);
|
||||
} catch (fallbackError) {
|
||||
this.updateStatus('备用方案也失败了', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async callDeepSeekAPI(voiceText) {
|
||||
const API_URL = 'https://api.deepseek.com/v1/chat/completions';
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error('请先配置DeepSeek API密钥');
|
||||
}
|
||||
|
||||
const prompt = this.buildDeepSeekPrompt(voiceText);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的网页语音控制助手。请严格根据提供的配置文件和用户指令,生成准确的操作序列。
|
||||
|
||||
重要规则:
|
||||
1. 只返回纯JSON格式,不要包含任何其他文本
|
||||
2. JSON结构必须包含sequence数组
|
||||
3. 每个指令必须存在于配置文件中
|
||||
4. 参数必须匹配指令定义
|
||||
5. 按逻辑顺序排列指令`
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 1000,
|
||||
response_format: { type: "json_object" }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`DeepSeek API错误: ${response.status} ${errorData.message || ''}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
// 解析JSON响应
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
// 尝试提取JSON内容
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
result = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
throw new Error('DeepSeek返回了非JSON格式的响应');
|
||||
}
|
||||
}
|
||||
console.log(result,"---result");
|
||||
|
||||
// 验证响应结构
|
||||
if (!result.sequence || !Array.isArray(result.sequence)) {
|
||||
throw new Error('DeepSeek返回了无效的指令序列格式');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('DeepSeek API调用错误:', error);
|
||||
throw new Error(`DeepSeek处理失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
buildDeepSeekPrompt(voiceText) {
|
||||
// 构建指令选择指南
|
||||
const commandSelectionGuide = this.buildCommandSelectionGuide();
|
||||
|
||||
return `网页交互配置文件:
|
||||
${JSON.stringify(this.config, null, 2)}
|
||||
|
||||
用户语音指令:"${voiceText}"
|
||||
|
||||
${commandSelectionGuide}
|
||||
|
||||
请严格遵循以下规则生成指令序列:
|
||||
1. 每个用户指令只选择最匹配的一个命令
|
||||
2. 不要重复执行相同功能的命令
|
||||
3. 如果多个命令描述相似,选择命令名称最简洁的那个
|
||||
4. 确保指令顺序合理
|
||||
5. 只返回纯JSON格式
|
||||
|
||||
要求格式:
|
||||
{
|
||||
"sequence": [
|
||||
{"command": "指令名", "params": {参数对象}}
|
||||
]
|
||||
}
|
||||
|
||||
请为指令"${voiceText}"生成正确的序列:`;
|
||||
}
|
||||
|
||||
buildCommandSelectionGuide() {
|
||||
// 分组相似命令
|
||||
const commandGroups = {};
|
||||
this.config.commands.forEach(cmd => {
|
||||
const key = cmd.description + cmd.selector;
|
||||
if (!commandGroups[key]) {
|
||||
commandGroups[key] = [];
|
||||
}
|
||||
commandGroups[key].push(cmd);
|
||||
});
|
||||
|
||||
let guide = "指令选择指南:\n";
|
||||
|
||||
Object.values(commandGroups).forEach(group => {
|
||||
if (group.length > 1) {
|
||||
guide += `\n相似命令组(选择其中一个):\n`;
|
||||
group.forEach(cmd => {
|
||||
guide += `- ${cmd.command}: ${cmd.description}\n`;
|
||||
});
|
||||
|
||||
// 推荐选择规则
|
||||
const recommended = group.reduce((prev, current) =>
|
||||
prev.command.length < current.command.length ? prev : current
|
||||
);
|
||||
guide += `推荐选择: ${recommended.command} (名称最简洁)\n`;
|
||||
}
|
||||
});
|
||||
|
||||
return guide;
|
||||
}
|
||||
|
||||
// 备用方案(当DeepSeek API不可用时)
|
||||
fallbackModelResponse(voiceText) {
|
||||
const lowerText = voiceText.toLowerCase();
|
||||
|
||||
// 简单的规则匹配
|
||||
if (lowerText.includes('登录')) {
|
||||
const username = this.extractParam(voiceText, '用户') || this.extractParam(voiceText, '账号') || 'testuser';
|
||||
const password = this.extractParam(voiceText, '密码') || '123456';
|
||||
|
||||
return {
|
||||
sequence: [
|
||||
{ command: "open_login" },
|
||||
{ command: "input_username", params: { username } },
|
||||
{ command: "input_password", params: { password } },
|
||||
{ command: "submit_login" }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerText.includes('搜索')) {
|
||||
const keyword = this.extractSearchKeyword(voiceText) || '商品';
|
||||
return {
|
||||
sequence: [
|
||||
{ command: "search_product", params: { keyword } }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (lowerText.includes('购物车') || lowerText.includes('加入')) {
|
||||
return {
|
||||
sequence: [
|
||||
{ command: "add_to_cart" }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('无法识别的指令');
|
||||
}
|
||||
|
||||
extractParam(text, paramName) {
|
||||
const regex = new RegExp(`${paramName}[::]*\\s*([^\\s]+)`, 'i');
|
||||
const match = text.match(regex);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
extractSearchKeyword(text) {
|
||||
const regex = /搜索[::]*\s*([^。,!?]+)/i;
|
||||
const match = text.match(regex);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
async executeSequence(sequence) {
|
||||
for (const [index, instruction] of sequence.sequence.entries()) {
|
||||
try {
|
||||
await this.executeInstruction(instruction);
|
||||
this.logCommand(`✓ 完成: ${instruction.command}`);
|
||||
|
||||
// 在指令之间添加延迟
|
||||
if (index < sequence.sequence.length - 1) {
|
||||
await this.delay(800);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logCommand(`✗ 错误: ${instruction.command} - ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async executeInstruction(instruction) {
|
||||
const commandConfig = this.config.commands.find(c => c.command === instruction.command);
|
||||
|
||||
if (!commandConfig) {
|
||||
throw new Error(`未知指令: ${instruction.command}`);
|
||||
}
|
||||
|
||||
this.logCommand(`开始执行: ${instruction.command}`);
|
||||
|
||||
const element = document.querySelector(commandConfig.selector);
|
||||
if (!element) {
|
||||
throw new Error(`找不到元素: ${commandConfig.selector}`);
|
||||
}
|
||||
|
||||
// 滚动到元素可见
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
switch (commandConfig.action) {
|
||||
case 'click':
|
||||
element.click();
|
||||
this.logCommand(`点击: ${commandConfig.selector}`);
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
const inputParam = commandConfig.params[0];
|
||||
if (instruction.params && instruction.params[inputParam.name]) {
|
||||
element.value = instruction.params[inputParam.name];
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
this.logCommand(`输入${inputParam.name}: ${instruction.params[inputParam.name]}`);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'navigate':
|
||||
if (instruction.params && instruction.params.product_url) {
|
||||
window.location.href = instruction.params.product_url;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'input_and_submit':
|
||||
if (instruction.params && instruction.params.keyword) {
|
||||
element.value = instruction.params.keyword;
|
||||
element.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
|
||||
// 尝试提交表单或点击相关按钮
|
||||
if (element.form) {
|
||||
element.form.submit();
|
||||
} else {
|
||||
// 查找提交按钮
|
||||
const submitBtn = document.querySelector('#search-submit, [type="submit"]');
|
||||
if (submitBtn) submitBtn.click();
|
||||
}
|
||||
this.logCommand(`搜索: ${instruction.params.keyword}`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`未知动作类型: ${commandConfig.action}`);
|
||||
}
|
||||
|
||||
// 添加视觉反馈
|
||||
this.highlightElement(element);
|
||||
}
|
||||
|
||||
highlightElement(element) {
|
||||
const originalStyle = element.style.boxShadow;
|
||||
element.style.boxShadow = '0 0 0 3px #4CAF50';
|
||||
setTimeout(() => {
|
||||
element.style.boxShadow = originalStyle;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
updateStatus(message, type = 'info') {
|
||||
const statusElement = document.getElementById('status');
|
||||
statusElement.textContent = message;
|
||||
statusElement.className = `status ${type}`;
|
||||
}
|
||||
|
||||
logCommand(message) {
|
||||
return
|
||||
const list = document.getElementById('command-list');
|
||||
const item = document.createElement('li');
|
||||
item.textContent = `${new Date().toLocaleTimeString()}: ${message}`;
|
||||
list.appendChild(item);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
delay(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化系统
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new VoiceControlSystem(config);
|
||||
});
|
||||
|
|
@ -0,0 +1,684 @@
|
|||
<!--
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-08-22 17:03:18
|
||||
* @Description:
|
||||
-->
|
||||
<template>
|
||||
<div class="container">
|
||||
<!-- 固定在左下角的语音控制组件 -->
|
||||
<div class="voice-control-container">
|
||||
<div class="status-text">点击开始语音识别</div>
|
||||
<button class="voice-btn" id="voice-btn" @click="toggleListening">
|
||||
<svg
|
||||
style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
"
|
||||
t="1756171270753"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="14442"
|
||||
width="42"
|
||||
height="70"
|
||||
>
|
||||
<path
|
||||
d="M704 192v368c0 52.8-21.6 100.8-56.4 135.6S564.8 752 512 752c-105.6 0-192-86.4-192-192V192C320 86.4 406.4 0 512 0s192 86.4 192 192z"
|
||||
p-id="14443"
|
||||
data-spm-anchor-id="a313x.search_index.0.i2.72cc3a81bxN4ca"
|
||||
class="selected"
|
||||
fill="#ffffff"
|
||||
></path>
|
||||
<path
|
||||
d="M816 496v144c0 2.8-0.4 5.6-1.1 8.4-18.5 68.2-58.9 126.1-112.3 166.9-43.5 33.2-95.6 55.2-151.6 62.2-4 0.5-7 3.9-7 7.9V944c0 8.8 7.2 16 16 16h80c35.3 0 64 28.7 64 64H320c0-35.3 28.7-64 64-64h80c8.8 0 16-7.2 16-16v-58.5c0-4-3-7.4-7-7.9-124.8-15.7-230.3-105.5-263.9-229.2-0.7-2.7-1.1-5.6-1.1-8.4V496.7c0-17.4 13.7-32.2 31.1-32.7 18.1-0.5 32.9 14 32.9 32v129.8c0 6.9 1.1 13.8 3.3 20.3C309.3 746.9 404.6 816 512 816s202.7-69.1 236.7-169.9c2.2-6.5 3.3-13.4 3.3-20.3V496.7c0-17.4 13.7-32.2 31.1-32.7 18.1-0.5 32.9 14 32.9 32z"
|
||||
p-id="14444"
|
||||
data-spm-anchor-id="a313x.search_index.0.i3.72cc3a81bxN4ca"
|
||||
class="selected"
|
||||
fill="#ffffff"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="pulse-ring"></div>
|
||||
</button>
|
||||
<div class="command-display">
|
||||
<div class="command-text"></div>
|
||||
<div class="command-action" v-for="item in action">
|
||||
{{
|
||||
"执行操作:" +
|
||||
`${item.command} ${
|
||||
item.params && item.params.keyword
|
||||
? "-" + item.params.keyword
|
||||
: ""
|
||||
}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
watch(config, (newVal, oldVal) => {
|
||||
if (voiceControl.value && voiceControl.value.isListening) {
|
||||
voiceControl.value.stopListening();
|
||||
}
|
||||
initVoiceControl();
|
||||
});
|
||||
|
||||
const config = {
|
||||
page: "Ecommerce Home",
|
||||
commands: [
|
||||
{
|
||||
command: "open_login",
|
||||
description: "打开登录弹窗或页面",
|
||||
action: "click",
|
||||
selector: "#login-button, .login-btn, [data-login]",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "input_username",
|
||||
description: "在用户名输入框中填写用户名",
|
||||
action: "input",
|
||||
selector: "#username, #username-input, input[type='text'][name*='user']",
|
||||
params: [
|
||||
{
|
||||
name: "username",
|
||||
type: "string",
|
||||
description: "要输入的用户名",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "input_password",
|
||||
description: "在密码输入框中填写密码",
|
||||
action: "input",
|
||||
selector: "#password, #password-input, input[type='password']",
|
||||
params: [
|
||||
{
|
||||
name: "password",
|
||||
type: "string",
|
||||
description: "要输入的密码",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "submit_login",
|
||||
description: "提交登录表单",
|
||||
action: "click",
|
||||
selector: "#submit-login, .login-submit, button[type='submit']",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "search_product",
|
||||
description: "在搜索框中输入关键词",
|
||||
action: "input",
|
||||
selector: "#search-input, .search-box, input[type='search']",
|
||||
params: [
|
||||
{
|
||||
name: "keyword",
|
||||
type: "string",
|
||||
description: "搜索关键词",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "perform_search",
|
||||
description: "执行搜索操作",
|
||||
action: "click",
|
||||
selector: "#search-button, .search-btn, button[type='submit']",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "add_to_cart",
|
||||
description: "将当前产品添加到购物车",
|
||||
action: "click",
|
||||
selector: ".add-to-cart, .cart-btn, [data-add-to-cart]",
|
||||
params: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
class VoiceControl {
|
||||
constructor(callback) {
|
||||
this.config = config;
|
||||
this.isListening = false;
|
||||
this.apiKey = "sk-020189889aac40f3b050f7c60ca597f8";
|
||||
this.setupSpeechRecognition();
|
||||
this.updateUI();
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
setupSpeechRecognition() {
|
||||
const SpeechRecognition =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
this.showError("您的浏览器不支持语音识别API");
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
|
||||
// 配置识别参数以提高精度
|
||||
this.recognition.continuous = true; // 长时间识别
|
||||
this.recognition.interimResults = true; // 返回临时结果
|
||||
this.recognition.maxAlternatives = 1; // 只返回最可能的结果
|
||||
this.recognition.lang = "zh-CN"; // 设置为中文识别
|
||||
|
||||
// 音频处理参数(重要)
|
||||
this.recognition.energy_threshold = 300; // 能量阈值
|
||||
this.recognition.pause_threshold = 0.3; // 停顿时间阈值(秒)
|
||||
this.recognition.phrase_threshold = 0.2; // 短语识别阈值
|
||||
|
||||
this.lastFinalTranscript = ""; // 用于跟踪最终结果
|
||||
this.isProcessing = false; // 防止并发处理
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
this.isListening = true;
|
||||
this.updateUI();
|
||||
this.showStatus("正在聆听...", "info");
|
||||
document.querySelector(".status-text").textContent = "正在聆听...";
|
||||
};
|
||||
|
||||
this.recognition.onresult = async (event) => {
|
||||
// 避免同时处理多个结果
|
||||
if (this.isProcessing) return;
|
||||
|
||||
// 获取最新结果索引
|
||||
const lastResultIndex = event.results.length - 1;
|
||||
const result = event.results[lastResultIndex];
|
||||
const transcript = result[0].transcript;
|
||||
|
||||
console.log(event, "-------event");
|
||||
this.showTranscript(transcript);
|
||||
this.showStatus("正在处理指令...", "info");
|
||||
|
||||
// 显示中间结果(灰色)和最终结果(黑色)
|
||||
const commandDisplay = document.querySelector(".command-display");
|
||||
const commandText = commandDisplay.querySelector(".command-text");
|
||||
|
||||
console.log(result.isFinal, "----result.isFinal");
|
||||
|
||||
if (!result.isFinal) {
|
||||
// 中间结果 - 可以快速显示但不处理
|
||||
commandText.textContent = `正在识别:${transcript}`;
|
||||
commandText.style.color = "#00000085";
|
||||
commandDisplay.classList.add("show");
|
||||
return; // 不处理中间结果,只显示
|
||||
}
|
||||
|
||||
// 处理最终结果
|
||||
this.isProcessing = true;
|
||||
if (transcript) {
|
||||
commandText.textContent = `正在识别:${transcript}`;
|
||||
commandText.style.color = "#00000085";
|
||||
}
|
||||
|
||||
console.log(transcript, "----");
|
||||
|
||||
try {
|
||||
// 使用setTimeout将处理放入下一个事件循环,避免阻塞UI
|
||||
setTimeout(async () => {
|
||||
if (transcript) {
|
||||
const sequence = await this.queryDeepSeek(transcript);
|
||||
this.showStatus("指令执行完成", "success");
|
||||
|
||||
if (sequence && sequence.sequence && sequence.sequence.length > 0) {
|
||||
this.callback(sequence.sequence);
|
||||
} else {
|
||||
this.callback([]);
|
||||
}
|
||||
}
|
||||
this.isProcessing = false;
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
console.error("处理过程中出错:", error);
|
||||
this.showError(`执行出错: ${error.message}`);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.log(event);
|
||||
this.isListening = false;
|
||||
this.updateUI();
|
||||
this.showError(`语音识别错误: ${event.error}`);
|
||||
document.querySelector(".status-text").textContent = "点击开始语音识别";
|
||||
this.isProcessing = false;
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
// 快速重启识别,减少等待时间
|
||||
if (this.isListening) {
|
||||
// 只有在用户希望继续聆听时才重启
|
||||
setTimeout(() => {
|
||||
this.recognition.start();
|
||||
}, 100); // 短暂延迟后重启
|
||||
} else {
|
||||
this.updateUI();
|
||||
document.querySelector(".status-text").textContent = "点击开始语音识别";
|
||||
setTimeout(() => {
|
||||
document.querySelector(".status-text").classList.remove("show");
|
||||
}, 3000);
|
||||
}
|
||||
this.isProcessing = false;
|
||||
// this.isListening = false;
|
||||
// this.updateUI();
|
||||
// document.querySelector(".status-text").textContent = "点击开始语音识别";
|
||||
|
||||
// // 3秒后隐藏状态文本
|
||||
// setTimeout(() => {
|
||||
// document.querySelector(".status-text").classList.remove("show");
|
||||
// }, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
async queryDeepSeek(userQuery) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("请先设置DeepSeek API密钥");
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
你是一个网页控制助手。请根据以下配置文件,分析用户自然语言请求,从可用指令集中筛选出与请求意图匹配的指令,将用户的自然语言指令解析成一个可执行的指令序列。
|
||||
|
||||
配置文件:
|
||||
${JSON.stringify(this.config, null, 2)}
|
||||
|
||||
用户指令:"${userQuery}"
|
||||
|
||||
请严格按照以下JSON格式输出,只包含一个名为"sequence"的数组:
|
||||
{ "sequence": [ { "command": "command_name", "params": { "param_name": "value" } }, ... ] }
|
||||
|
||||
要求:
|
||||
1. 只使用配置文件中定义的command
|
||||
2.按照符合逻辑的执行顺序对筛选出的指令进行排序(例如:登录需遵循 "打开登录→输入用户名→输入密码→提交登录" 的顺序)
|
||||
3.仅保留指令的 command 字段,形成有序数组
|
||||
4. 如果用户指令中包含参数值(如用户名、密码、关键词),请正确提取并填充到params中
|
||||
5.若请求涉及多个独立操作,需按操作逻辑拆分排序(如 "先登录再搜索商品" 需包含两部分完整指令链)
|
||||
6.严格禁止添加指令集中不存在的指令,无关指令需排除
|
||||
7.若无可匹配指令,返回空数组
|
||||
|
||||
现在请生成针对"${userQuery}"的JSON指令序列:
|
||||
`;
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `"你是一个专业的网页语音控制助手。请严格根据提供的配置文件和用户指令,生成准确的操作序列。
|
||||
重要规则:
|
||||
1. 只返回纯JSON格式,不要包含任何其他文本
|
||||
2. JSON结构必须包含sequence数组\n3. 每个指令必须存在于配置文件中
|
||||
4. 参数必须匹配指令定义
|
||||
5. 按逻辑顺序排列指令
|
||||
6. 若无可匹配指令,必须返回空数组
|
||||
7. 特别注意:诗句、诗词、问候语、闲聊内容等与网页操作无关的指令都应返回空数组"`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
stream: false,
|
||||
max_tokens: 200,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
`DeepSeek API错误: ${response.status} ${errorData.message || ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
// 解析JSON响应
|
||||
let result = JSON.parse(content);
|
||||
console.log(result, "---result");
|
||||
// 验证响应结构
|
||||
if (!result.sequence || !Array.isArray(result.sequence)) {
|
||||
throw new Error("DeepSeek返回了无效的指令序列格式");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
startListening() {
|
||||
if (!this.recognition) {
|
||||
throw new Error("语音识别未初始化");
|
||||
}
|
||||
|
||||
document.querySelector(".status-text").classList.add("show");
|
||||
this.recognition.start();
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (this.recognition && this.isListening) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// UI 更新方法
|
||||
updateUI() {
|
||||
const voiceBtn = document.getElementById("voice-btn");
|
||||
if (voiceBtn) {
|
||||
if (this.isListening) {
|
||||
voiceBtn.classList.add("listening");
|
||||
} else {
|
||||
voiceBtn.classList.remove("listening");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showStatus(message, type = "info") {
|
||||
const statusEl = document.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `状态: ${message}`;
|
||||
statusEl.className = `status ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
showTranscript(text) {
|
||||
const transcriptEl = document.getElementById("transcript");
|
||||
if (transcriptEl) {
|
||||
transcriptEl.textContent = `识别结果: ${text}`;
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showStatus(message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
const voiceControl = ref(null);
|
||||
const action = ref([]);
|
||||
|
||||
const initVoiceControl = () => {
|
||||
voiceControl.value = new VoiceControl(this.callBackFun);
|
||||
};
|
||||
|
||||
const callBackFun = (data) => {
|
||||
action.value = data;
|
||||
console.log(data);
|
||||
};
|
||||
const toggleListening = () => {
|
||||
if (!voiceControl.value) {
|
||||
alert("请先初始化语音控制系统");
|
||||
return;
|
||||
}
|
||||
|
||||
if (voiceControl.value.isListening) {
|
||||
voiceControl.value.stopListening();
|
||||
} else {
|
||||
voiceControl.value.startListening();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initVoiceControl();
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 固定在左下角的语音控制组件 */
|
||||
.voice-control-container {
|
||||
position: fixed;
|
||||
width: 144px;
|
||||
right: 30px;
|
||||
bottom: 30px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6e45e2, #88d3ce);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.voice-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.voice-btn.listening {
|
||||
background: linear-gradient(135deg, #ff5e62, #ff9966);
|
||||
animation: pulse 1.5s infinite;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.voice-btn::before {
|
||||
content: "\f130";
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.voice-btn.listening::before {
|
||||
content: "\f131";
|
||||
animation: bounce 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #00000085;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-text.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.voice-btn.listening .pulse-ring {
|
||||
animation: sonar 1.5s infinite;
|
||||
}
|
||||
|
||||
.command-display {
|
||||
position: fixed;
|
||||
right: 160px;
|
||||
bottom: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
color: #00000080;
|
||||
max-width: 300px;
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
text-align: left;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.command-display.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.command-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.command-action {
|
||||
font-size: 14px;
|
||||
color: #88d3ce;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: rgba(0, 123, 255, 0.2);
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 94, 98, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(255, 94, 98, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 94, 98, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonar {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.voice-control-container {
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.command-display {
|
||||
left: 100px;
|
||||
max-width: calc(100vw - 120px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,644 @@
|
|||
<!--
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-08-22 17:03:18
|
||||
* @Description:
|
||||
-->
|
||||
<template>
|
||||
<div class="container">
|
||||
<!-- 固定在左下角的语音控制组件 -->
|
||||
<div class="voice-control-container">
|
||||
<div class="status-text">点击开始语音识别</div>
|
||||
<button class="voice-btn" id="voice-btn" @click="toggleListening">
|
||||
<svg
|
||||
style="
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
"
|
||||
t="1756171270753"
|
||||
class="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="14442"
|
||||
width="42"
|
||||
height="70"
|
||||
>
|
||||
<path
|
||||
d="M704 192v368c0 52.8-21.6 100.8-56.4 135.6S564.8 752 512 752c-105.6 0-192-86.4-192-192V192C320 86.4 406.4 0 512 0s192 86.4 192 192z"
|
||||
p-id="14443"
|
||||
data-spm-anchor-id="a313x.search_index.0.i2.72cc3a81bxN4ca"
|
||||
class="selected"
|
||||
fill="#ffffff"
|
||||
></path>
|
||||
<path
|
||||
d="M816 496v144c0 2.8-0.4 5.6-1.1 8.4-18.5 68.2-58.9 126.1-112.3 166.9-43.5 33.2-95.6 55.2-151.6 62.2-4 0.5-7 3.9-7 7.9V944c0 8.8 7.2 16 16 16h80c35.3 0 64 28.7 64 64H320c0-35.3 28.7-64 64-64h80c8.8 0 16-7.2 16-16v-58.5c0-4-3-7.4-7-7.9-124.8-15.7-230.3-105.5-263.9-229.2-0.7-2.7-1.1-5.6-1.1-8.4V496.7c0-17.4 13.7-32.2 31.1-32.7 18.1-0.5 32.9 14 32.9 32v129.8c0 6.9 1.1 13.8 3.3 20.3C309.3 746.9 404.6 816 512 816s202.7-69.1 236.7-169.9c2.2-6.5 3.3-13.4 3.3-20.3V496.7c0-17.4 13.7-32.2 31.1-32.7 18.1-0.5 32.9 14 32.9 32z"
|
||||
p-id="14444"
|
||||
data-spm-anchor-id="a313x.search_index.0.i3.72cc3a81bxN4ca"
|
||||
class="selected"
|
||||
fill="#ffffff"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="pulse-ring"></div>
|
||||
</button>
|
||||
<div class="command-display">
|
||||
<div class="command-text">我说:打开登录</div>
|
||||
<div class="command-action" v-for="item in action">
|
||||
{{
|
||||
"执行操作:" +
|
||||
`${item.command} ${
|
||||
item.params && item.params.keyword
|
||||
? "-" + item.params.keyword
|
||||
: ""
|
||||
}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
const config = {
|
||||
page: "Ecommerce Home",
|
||||
commands: [
|
||||
{
|
||||
command: "open_login",
|
||||
description: "打开登录弹窗或页面",
|
||||
action: "click",
|
||||
selector: "#login-button, .login-btn, [data-login]",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "input_username",
|
||||
description: "在用户名输入框中填写用户名",
|
||||
action: "input",
|
||||
selector: "#username, #username-input, input[type='text'][name*='user']",
|
||||
params: [
|
||||
{
|
||||
name: "username",
|
||||
type: "string",
|
||||
description: "要输入的用户名",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "input_password",
|
||||
description: "在密码输入框中填写密码",
|
||||
action: "input",
|
||||
selector: "#password, #password-input, input[type='password']",
|
||||
params: [
|
||||
{
|
||||
name: "password",
|
||||
type: "string",
|
||||
description: "要输入的密码",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "submit_login",
|
||||
description: "提交登录表单",
|
||||
action: "click",
|
||||
selector: "#submit-login, .login-submit, button[type='submit']",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "search_product",
|
||||
description: "在搜索框中输入关键词",
|
||||
action: "input",
|
||||
selector: "#search-input, .search-box, input[type='search']",
|
||||
params: [
|
||||
{
|
||||
name: "keyword",
|
||||
type: "string",
|
||||
description: "搜索关键词",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
command: "perform_search",
|
||||
description: "执行搜索操作",
|
||||
action: "click",
|
||||
selector: "#search-button, .search-btn, button[type='submit']",
|
||||
params: [],
|
||||
},
|
||||
{
|
||||
command: "add_to_cart",
|
||||
description: "将当前产品添加到购物车",
|
||||
action: "click",
|
||||
selector: ".add-to-cart, .cart-btn, [data-add-to-cart]",
|
||||
params: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
class VoiceControl {
|
||||
constructor(callback) {
|
||||
this.config = config;
|
||||
this.isListening = false;
|
||||
this.apiKey = "sk-020189889aac40f3b050f7c60ca597f8";
|
||||
this.setupSpeechRecognition();
|
||||
this.updateUI();
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
setupSpeechRecognition() {
|
||||
const SpeechRecognition =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
this.showError("您的浏览器不支持语音识别API");
|
||||
return;
|
||||
}
|
||||
|
||||
this.recognition = new SpeechRecognition();
|
||||
this.recognition.continuous = true;
|
||||
this.recognition.interimResults = false;
|
||||
this.recognition.lang = "zh-CN";
|
||||
|
||||
// 音频处理参数(重要)
|
||||
this.recognition.energy_threshold = 300; // 能量阈值
|
||||
this.recognition.pause_threshold = 1.5; // 停顿时间阈值(秒)
|
||||
this.recognition.phrase_threshold = 0.3; // 短语识别阈值
|
||||
|
||||
this.recognition.onstart = () => {
|
||||
this.isListening = true;
|
||||
this.updateUI();
|
||||
this.showStatus("正在聆听...", "info");
|
||||
document.querySelector(".status-text").textContent = "正在聆听...";
|
||||
};
|
||||
|
||||
this.recognition.onresult = async (event) => {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
this.showTranscript(transcript);
|
||||
this.showStatus("正在处理指令...", "info");
|
||||
|
||||
// 显示识别结果
|
||||
const commandDisplay = document.querySelector(".command-display");
|
||||
commandDisplay.querySelector(
|
||||
".command-text"
|
||||
).textContent = `我说:${transcript}`;
|
||||
commandDisplay.classList.add("show");
|
||||
|
||||
console.log(transcript, "----");
|
||||
if (transcript) {
|
||||
try {
|
||||
const sequence = await this.queryDeepSeek(transcript);
|
||||
this.showStatus("指令执行完成", "success");
|
||||
console.log(sequence, this.callback, "---------sequence");
|
||||
|
||||
// 显示执行动作
|
||||
if (sequence && sequence.sequence && sequence.sequence.length > 0) {
|
||||
this.callback(sequence.sequence);
|
||||
} else {
|
||||
this.callback([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("处理过程中出错:", error);
|
||||
this.showError(`执行出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.recognition.onerror = (event) => {
|
||||
console.log(event);
|
||||
this.isListening = false;
|
||||
this.updateUI();
|
||||
this.showError(`语音识别错误: ${event.error}`);
|
||||
document.querySelector(".status-text").textContent = "点击开始语音识别";
|
||||
};
|
||||
|
||||
this.recognition.onend = () => {
|
||||
this.isListening = false;
|
||||
this.updateUI();
|
||||
document.querySelector(".status-text").textContent = "点击开始语音识别";
|
||||
|
||||
// 3秒后隐藏状态文本
|
||||
setTimeout(() => {
|
||||
document.querySelector(".status-text").classList.remove("show");
|
||||
}, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
async queryDeepSeek(userQuery) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("请先设置DeepSeek API密钥");
|
||||
}
|
||||
|
||||
const prompt = `
|
||||
你是一个网页控制助手。请根据以下配置文件,分析用户自然语言请求,从可用指令集中筛选出与请求意图匹配的指令,将用户的自然语言指令解析成一个可执行的指令序列。
|
||||
|
||||
配置文件:
|
||||
${JSON.stringify(this.config, null, 2)}
|
||||
|
||||
用户指令:"${userQuery}"
|
||||
|
||||
请严格按照以下JSON格式输出,只包含一个名为"sequence"的数组:
|
||||
{ "sequence": [ { "command": "command_name", "params": { "param_name": "value" } }, ... ] }
|
||||
|
||||
要求:
|
||||
1. 只使用配置文件中定义的command
|
||||
2.按照符合逻辑的执行顺序对筛选出的指令进行排序(例如:登录需遵循 "打开登录→输入用户名→输入密码→提交登录" 的顺序)
|
||||
3.仅保留指令的 command 字段,形成有序数组
|
||||
4. 如果用户指令中包含参数值(如用户名、密码、关键词),请正确提取并填充到params中
|
||||
5.若请求涉及多个独立操作,需按操作逻辑拆分排序(如 "先登录再搜索商品" 需包含两部分完整指令链)
|
||||
6.严格禁止添加指令集中不存在的指令,无关指令需排除
|
||||
7.若无可匹配指令,返回空数组
|
||||
|
||||
现在请生成针对"${userQuery}"的JSON指令序列:
|
||||
`;
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.deepseek.com/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `"你是一个专业的网页语音控制助手。请严格根据提供的配置文件和用户指令,生成准确的操作序列。
|
||||
重要规则:
|
||||
1. 只返回纯JSON格式,不要包含任何其他文本
|
||||
2. JSON结构必须包含sequence数组\n3. 每个指令必须存在于配置文件中
|
||||
4. 参数必须匹配指令定义
|
||||
5. 按逻辑顺序排列指令
|
||||
6. 若无可匹配指令,必须返回空数组
|
||||
7. 特别注意:诗句、诗词、问候语、闲聊内容等与网页操作无关的指令都应返回空数组"`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
stream: false,
|
||||
max_tokens: 1000,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
`DeepSeek API错误: ${response.status} ${errorData.message || ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0].message.content;
|
||||
|
||||
// 解析JSON响应
|
||||
let result = JSON.parse(content);
|
||||
console.log(result, "---result");
|
||||
// 验证响应结构
|
||||
if (!result.sequence || !Array.isArray(result.sequence)) {
|
||||
throw new Error("DeepSeek返回了无效的指令序列格式");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
startListening() {
|
||||
if (!this.recognition) {
|
||||
throw new Error("语音识别未初始化");
|
||||
}
|
||||
|
||||
document.querySelector(".status-text").classList.add("show");
|
||||
this.recognition.start();
|
||||
}
|
||||
|
||||
stopListening() {
|
||||
if (this.recognition && this.isListening) {
|
||||
this.recognition.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// UI 更新方法
|
||||
updateUI() {
|
||||
const voiceBtn = document.getElementById("voice-btn");
|
||||
if (voiceBtn) {
|
||||
if (this.isListening) {
|
||||
voiceBtn.classList.add("listening");
|
||||
} else {
|
||||
voiceBtn.classList.remove("listening");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showStatus(message, type = "info") {
|
||||
const statusEl = document.getElementById("status");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = `状态: ${message}`;
|
||||
statusEl.className = `status ${type}`;
|
||||
}
|
||||
}
|
||||
|
||||
showTranscript(text) {
|
||||
const transcriptEl = document.getElementById("transcript");
|
||||
if (transcriptEl) {
|
||||
transcriptEl.textContent = `识别结果: ${text}`;
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showStatus(message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: "speechControl",
|
||||
data() {
|
||||
return {
|
||||
voiceControl: null,
|
||||
action: [],
|
||||
};
|
||||
},
|
||||
props: {
|
||||
config: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
config: {
|
||||
handler() {
|
||||
if (this.voiceControl && this.voiceControl.isListening) {
|
||||
this.voiceControl.stopListening();
|
||||
}
|
||||
this.initVoiceControl();
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initVoiceControl();
|
||||
},
|
||||
methods: {
|
||||
initVoiceControl() {
|
||||
this.voiceControl = new VoiceControl(this.callBackFun);
|
||||
},
|
||||
callBackFun(data) {
|
||||
this.action = data;
|
||||
console.log(data);
|
||||
},
|
||||
toggleListening() {
|
||||
if (!this.voiceControl) {
|
||||
alert("请先初始化语音控制系统");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.voiceControl.isListening) {
|
||||
this.voiceControl.stopListening();
|
||||
} else {
|
||||
this.voiceControl.startListening();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 固定在左下角的语音控制组件 */
|
||||
.voice-control-container {
|
||||
position: fixed;
|
||||
width: 144px;
|
||||
right: 30px;
|
||||
bottom: 30px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6e45e2, #88d3ce);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 25px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.voice-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.voice-btn.listening {
|
||||
background: linear-gradient(135deg, #ff5e62, #ff9966);
|
||||
animation: pulse 1.5s infinite;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.voice-btn::before {
|
||||
content: "\f130";
|
||||
font-family: "Font Awesome 6 Free";
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.voice-btn.listening::before {
|
||||
content: "\f131";
|
||||
animation: bounce 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
color: #00000085;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.status-text.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.voice-btn.listening .pulse-ring {
|
||||
animation: sonar 1.5s infinite;
|
||||
}
|
||||
|
||||
.command-display {
|
||||
position: fixed;
|
||||
right: 160px;
|
||||
bottom: 30px;
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 12px;
|
||||
color: #00000080;
|
||||
max-width: 300px;
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
text-align: left;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.command-display.show {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.command-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.command-action {
|
||||
font-size: 14px;
|
||||
color: #88d3ce;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.info {
|
||||
background: rgba(0, 123, 255, 0.2);
|
||||
border-left: 4px solid #007bff;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: rgba(255, 193, 7, 0.2);
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 94, 98, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 15px rgba(255, 94, 98, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(255, 94, 98, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sonar {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.voice-control-container {
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
}
|
||||
|
||||
.command-display {
|
||||
left: 100px;
|
||||
max-width: calc(100vw - 120px);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
<template>
|
||||
<div class="gpt-base-container">
|
||||
<!-- Heading -->
|
||||
<h1 class="gap">whisper-base</h1>
|
||||
|
||||
<!-- 文件选择表单 -->
|
||||
<form
|
||||
id="myForm"
|
||||
class="gap form-container"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<label for="inputAudioFile">选择音频文件:</label>
|
||||
<input
|
||||
type="file"
|
||||
id="inputAudioFile"
|
||||
ref="fileInput"
|
||||
@change="handleFileChange"
|
||||
accept="audio/*"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submitButton"
|
||||
:disabled="!audioFile || isProcessing"
|
||||
class="submit-btn"
|
||||
>
|
||||
{{ isProcessing ? '处理中...' : '生成' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 音频播放器 -->
|
||||
<audio
|
||||
id="audioBar"
|
||||
controls
|
||||
class="gap"
|
||||
:src="audioUrl"
|
||||
ref="audioPlayer"
|
||||
></audio>
|
||||
|
||||
<!-- 输出结果 -->
|
||||
<div
|
||||
id="outputDiv"
|
||||
class="output-container gap"
|
||||
>
|
||||
{{ outputText || 'Waiting...' }}
|
||||
<span v-if="isTyping && outputText" class="typing-cursor">_</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'WhisperBase',
|
||||
|
||||
setup() {
|
||||
// 响应式数据
|
||||
const audioFile = ref(null);
|
||||
const audioUrl = ref(null);
|
||||
const outputText = ref('Waiting...');
|
||||
const isProcessing = ref(false);
|
||||
const isTyping = ref(false);
|
||||
|
||||
// DOM 引用
|
||||
const fileInput = ref(null);
|
||||
const audioPlayer = ref(null);
|
||||
|
||||
// 定时器引用
|
||||
let typingTimer = null;
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 初始化代码可以放在这里
|
||||
console.log('WhisperBase component mounted');
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理定时器
|
||||
if (typingTimer) {
|
||||
clearTimeout(typingTimer);
|
||||
}
|
||||
|
||||
// 释放URL对象
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
// 释放之前的URL
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value);
|
||||
}
|
||||
|
||||
audioFile.value = file;
|
||||
audioUrl.value = URL.createObjectURL(file);
|
||||
outputText.value = '文件已选择,点击生成按钮开始处理';
|
||||
}
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!audioFile.value) {
|
||||
alert("请选择文件!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
outputText.value = '准备生成...';
|
||||
|
||||
try {
|
||||
// 动态导入 transformers
|
||||
const { pipeline, env } = await import('/src/model-base/transformers@2.4.1');
|
||||
|
||||
// 环境设置
|
||||
env.allowRemoteModels = false;
|
||||
env.localModelPath = '/src/model-base/';
|
||||
env.backends.onnx.wasm.wasmPaths = '/src/model-base/';
|
||||
|
||||
// 创建语音识别管道
|
||||
const transcriber = await pipeline('automatic-speech-recognition', 'Xenova/whisper-base');
|
||||
|
||||
// 处理音频
|
||||
const output = await transcriber(audioUrl.value, {
|
||||
chunk_length_s: 30,
|
||||
return_timestamps: true
|
||||
});
|
||||
|
||||
// 打字机效果显示结果
|
||||
startTypingEffect(output.text);
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理失败:', error);
|
||||
outputText.value = `处理失败: ${error.message}`;
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 打字机效果
|
||||
const startTypingEffect = (text) => {
|
||||
let i = 0;
|
||||
isTyping.value = true;
|
||||
|
||||
// 清除之前的定时器
|
||||
if (typingTimer) {
|
||||
clearTimeout(typingTimer);
|
||||
}
|
||||
|
||||
const type = () => {
|
||||
if (i <= text.length) {
|
||||
outputText.value = text.slice(0, i++);
|
||||
typingTimer = setTimeout(type, 50);
|
||||
} else {
|
||||
isTyping.value = false;
|
||||
outputText.value = text; // 完成打字
|
||||
}
|
||||
};
|
||||
|
||||
type();
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
audioFile.value = null;
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value);
|
||||
audioUrl.value = null;
|
||||
}
|
||||
outputText.value = 'Waiting...';
|
||||
|
||||
if (audioPlayer.value) {
|
||||
audioPlayer.value.src = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给模板
|
||||
return {
|
||||
audioFile,
|
||||
audioUrl,
|
||||
outputText,
|
||||
isProcessing,
|
||||
isTyping,
|
||||
fileInput,
|
||||
audioPlayer,
|
||||
handleFileChange,
|
||||
handleSubmit,
|
||||
resetForm
|
||||
};
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gpt-base-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.gap {
|
||||
padding: 10px 40px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
border: cadetblue solid 5px;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form-container label {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-container input[type="file"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100px;
|
||||
font-size: 1.5em;
|
||||
color: cadetblue;
|
||||
background: white;
|
||||
border: 2px solid cadetblue;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: cadetblue;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.output-container {
|
||||
background-color: bisque;
|
||||
border-radius: 5px;
|
||||
min-height: 100px;
|
||||
padding: 20px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.typing-cursor {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.gap {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 80px;
|
||||
font-size: 1.2em;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 音频播放器样式 */
|
||||
audio {
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
/* 文件输入样式 */
|
||||
input[type="file"] {
|
||||
padding: 10px;
|
||||
border: 2px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
input[type="file"]:hover {
|
||||
border-color: cadetblue;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 左侧导航栏 -->
|
||||
<aside class="sidebar">
|
||||
<!-- 用户信息 -->
|
||||
<div class="user-section">
|
||||
<div class="avatar"></div>
|
||||
<div class="user-id">1735244688</div>
|
||||
<div class="user-label">用户ID</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="nav-menu">
|
||||
<ul>
|
||||
<li
|
||||
@click="selectMenu(item)"
|
||||
v-for="item in menu"
|
||||
:class="item.active ? 'active' : ''"
|
||||
>
|
||||
<el-icon :size="20"><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- 广告卡片 -->
|
||||
<div class="ad-card">
|
||||
<div class="ad-img"></div>
|
||||
<div class="ad-title">大幅面优化 倾斜摄影卡顿</div>
|
||||
<button class="ad-btn">前往下载</button>
|
||||
</div>
|
||||
|
||||
<!-- 底部链接 -->
|
||||
<div class="footer-links">
|
||||
<a href="#">SDK文档</a>
|
||||
<a href="#">视频教程</a>
|
||||
<a href="#">人工客服</a>
|
||||
<a href="#">建议反馈</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部横幅 -->
|
||||
<section class="header-banner">
|
||||
<div class="banner-content">
|
||||
<h1>可视化</h1>
|
||||
<p>零代码数字孪生可视化平台</p>
|
||||
<div class="tag-group">
|
||||
<span>可视化大屏</span>
|
||||
<span>三维地图</span>
|
||||
<span>GIS</span>
|
||||
<span>数字孪生</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目操作栏 -->
|
||||
<section class="project-actions">
|
||||
<div class="action-buttons">
|
||||
<button>新建</button>
|
||||
<button>新建文件夹</button>
|
||||
<button>导入项目</button>
|
||||
</div>
|
||||
<div class="tab-group">
|
||||
<span class="active">本地项目</span>
|
||||
<span>云托管项目</span>
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<input type="text" placeholder="搜索项目" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<section class="project-list">
|
||||
<div class="project-card">
|
||||
<div class="card-img"></div>
|
||||
<div class="card-title">我的驾驶大屏示例</div>
|
||||
<div class="card-actions">
|
||||
<span>编辑</span>
|
||||
<span>复制</span>
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-card">
|
||||
<div class="card-img"></div>
|
||||
<div class="card-title">我的智慧城市3D场景</div>
|
||||
<div class="card-actions">
|
||||
<span>编辑</span>
|
||||
<span>复制</span>
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup >
|
||||
import { ref } from "vue";
|
||||
const activeValue = ref(1);
|
||||
const menu = ref([
|
||||
{
|
||||
name: "我的项目",
|
||||
icon: "DataAnalysis",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
name: "报表门户",
|
||||
icon: "TrendCharts",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: "回收站",
|
||||
icon: "Delete",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: "资源中心",
|
||||
icon: "Stopwatch",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: "私有云",
|
||||
icon: "MostlyCloudy",
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
name: "设置中心",
|
||||
icon: "Setting",
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectMenu = (data) => {
|
||||
menu.value.forEach((d) => {
|
||||
if (d.name === data.name) {
|
||||
d.active = true;
|
||||
} else {
|
||||
d.active = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
/* 全局布局 */
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
font-family: "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* 左侧导航栏 */
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: #181818;
|
||||
}
|
||||
|
||||
/* 用户信息区 */
|
||||
.user-section {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #444;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
.user-id {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.user-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 导航菜单 */
|
||||
.nav-menu ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
height: 400px;
|
||||
}
|
||||
.nav-menu li {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
color: #ffffff99;
|
||||
.el-icon {
|
||||
margin-right: 6px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
.nav-menu li:hover {
|
||||
background: rgba(5, 85, 158, 0.4);
|
||||
}
|
||||
.nav-menu li.active {
|
||||
background: rgb(5, 85, 158);
|
||||
font-weight: 500;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #333;
|
||||
padding: 0 !important;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* 广告卡片 */
|
||||
.ad-card {
|
||||
background: #282828;
|
||||
background: rgb(26, 27, 29);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.ad-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
.ad-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ad-btn {
|
||||
background: #0078d4;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.ad-btn:hover {
|
||||
background: #005a9e;
|
||||
}
|
||||
|
||||
/* 底部链接 */
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.footer-links a {
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.footer-links a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 顶部横幅 */
|
||||
.header-banner {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
background-image: url("@/assets/img/banner.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
&::before {
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
.banner-content {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
}
|
||||
}
|
||||
.header-banner h1 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.header-banner p {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
width: 200px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
position: absolute;
|
||||
left: -220px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
&::after {
|
||||
content: "";
|
||||
width: 200px;
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
position: absolute;
|
||||
right: -220px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.tag-group span {
|
||||
margin: 0 6px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 项目操作栏 */
|
||||
.project-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.action-buttons button {
|
||||
background: rgb(26, 27, 29);
|
||||
border: 1px solid #444;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
margin-right: 8px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.action-buttons button:hover {
|
||||
background: #383838;
|
||||
}
|
||||
.tab-group span {
|
||||
margin: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.tab-group span:hover {
|
||||
color: #0078d4;
|
||||
}
|
||||
.tab-group span.active {
|
||||
color: #0078d4;
|
||||
font-weight: 500;
|
||||
}
|
||||
.search-box input {
|
||||
background: rgb(26, 27, 29);
|
||||
border: 1px solid #444;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.search-box input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 项目列表 */
|
||||
.project-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
}
|
||||
.project-card {
|
||||
width: 240px;
|
||||
background: rgb(26, 27, 29);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
.card-img {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
background: #444;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-actions {
|
||||
color: #999;
|
||||
}
|
||||
.card-actions span {
|
||||
margin: 0 6px;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
.card-actions span:hover {
|
||||
color: #0078d4;
|
||||
}
|
||||
.line {
|
||||
width: 160px;
|
||||
background: rgb(15, 15, 15);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* @Author: 季万俊
|
||||
* @Date: 2025-06-10 14:36:16
|
||||
* @Description:
|
||||
*/
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// https://vitejs.dev/config/
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 关键:允许通过IP访问(监听所有网络接口)
|
||||
port: 8080, // 可选:指定端口(默认5173)
|
||||
open: true, // 可选:启动时自动打开浏览器,
|
||||
proxy: {
|
||||
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue