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