mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-28 01:53:53 +08:00
Merge branch 'main' into test-server-pr
This commit is contained in:
+1
-3
@@ -18,6 +18,4 @@ xiaozhi-esp32-server
|
|||||||
|
|
||||||
# manager-web 、manager-api接口协议
|
# manager-web 、manager-api接口协议
|
||||||
|
|
||||||
[manager前后端接口协议](https://app.apifox.com/invite/project?token=eXg2_tUv85q-gc3ZRowmn)
|
https://2662r3426b.vicp.fun/xiaozhi-esp32-api/api/v1/doc.html
|
||||||
|
|
||||||
[前端页面设计图](https://codesign.qq.com/app/s/526108506410828)
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ manager-api 该项目基于SpringBoot框架开发。
|
|||||||
|
|
||||||
开发使用代码编辑器,导入项目时,选择`manager-api`文件夹作为项目目录
|
开发使用代码编辑器,导入项目时,选择`manager-api`文件夹作为项目目录
|
||||||
|
|
||||||
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
|
|
||||||
|
|
||||||
# 开发环境
|
# 开发环境
|
||||||
JDK 21
|
JDK 21
|
||||||
Maven 3.8+
|
Maven 3.8+
|
||||||
|
|||||||
+3
-3
@@ -75,7 +75,7 @@ public class TimbreController {
|
|||||||
@Operation(summary = "音色修改")
|
@Operation(summary = "音色修改")
|
||||||
@RequiresPermissions("sys:role:superAdmin")
|
@RequiresPermissions("sys:role:superAdmin")
|
||||||
public Result<Void> update(
|
public Result<Void> update(
|
||||||
@PathVariable Long id,
|
@PathVariable String id,
|
||||||
@RequestBody TimbreDataDTO dto) {
|
@RequestBody TimbreDataDTO dto) {
|
||||||
ValidatorUtils.validateEntity(dto);
|
ValidatorUtils.validateEntity(dto);
|
||||||
timbreService.update(id, dto);
|
timbreService.update(id, dto);
|
||||||
@@ -85,8 +85,8 @@ public class TimbreController {
|
|||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{id}")
|
||||||
@Operation(summary = "音色删除")
|
@Operation(summary = "音色删除")
|
||||||
@RequiresPermissions("sys:role:superAdmin")
|
@RequiresPermissions("sys:role:superAdmin")
|
||||||
public Result<Void> delete(@PathVariable Long id) {
|
public Result<Void> delete(@PathVariable String id) {
|
||||||
timbreService.delete(new Long[] { id });
|
timbreService.delete(new String[] { id });
|
||||||
return new Result<>();
|
return new Result<>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,14 +45,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
|
|||||||
* @param timbreId 需要修改的id
|
* @param timbreId 需要修改的id
|
||||||
* @param dto 需要修改的数据
|
* @param dto 需要修改的数据
|
||||||
*/
|
*/
|
||||||
void update(Long timbreId, TimbreDataDTO dto);
|
void update(String timbreId, TimbreDataDTO dto);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量删除音色
|
* 批量删除音色
|
||||||
*
|
*
|
||||||
* @param ids 需要被删除的音色id列表
|
* @param ids 需要被删除的音色id列表
|
||||||
*/
|
*/
|
||||||
void delete(Long[] ids);
|
void delete(String[] ids);
|
||||||
|
|
||||||
List<String> getVoiceNames(String ttsModelId, String voiceName);
|
List<String> getVoiceNames(String ttsModelId, String voiceName);
|
||||||
}
|
}
|
||||||
+2
-2
@@ -70,7 +70,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void update(Long timbreId, TimbreDataDTO dto) {
|
public void update(String timbreId, TimbreDataDTO dto) {
|
||||||
isTtsModelId(dto.getTtsModelId());
|
isTtsModelId(dto.getTtsModelId());
|
||||||
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
|
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
|
||||||
timbreEntity.setId(timbreId);
|
timbreEntity.setId(timbreId);
|
||||||
@@ -79,7 +79,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
public void delete(Long[] ids) {
|
public void delete(String[] ids) {
|
||||||
baseDao.deleteBatchIds(Arrays.asList(ids));
|
baseDao.deleteBatchIds(Arrays.asList(ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<appender name="LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
<appender name="LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
<!--日志文件输出的文件名-->
|
<!--日志文件输出的文件名-->
|
||||||
<FileNamePattern>/system/logs/admin.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
|
<FileNamePattern>/home/system/logs/admin.%d{yyyy-MM-dd}.%i.log</FileNamePattern>
|
||||||
<!--日志大小-->
|
<!--日志大小-->
|
||||||
<maxFileSize>1MB</maxFileSize>
|
<maxFileSize>1MB</maxFileSize>
|
||||||
<!--日志文件保留天数-->
|
<!--日志文件保留天数-->
|
||||||
|
|||||||
Generated
+42
@@ -11,6 +11,8 @@
|
|||||||
"element-ui": "^2.15.14",
|
"element-ui": "^2.15.14",
|
||||||
"flyio": "^0.6.14",
|
"flyio": "^0.6.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
"opus-decoder": "^0.7.7",
|
||||||
|
"opus-recorder": "^8.0.5",
|
||||||
"vue": "^2.6.14",
|
"vue": "^2.6.14",
|
||||||
"vue-axios": "^3.5.2",
|
"vue-axios": "^3.5.2",
|
||||||
"vue-router": "^3.6.5",
|
"vue-router": "^3.6.5",
|
||||||
@@ -149,6 +151,11 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eshaz/web-worker": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@eshaz/web-worker/-/web-worker-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-WxXiHFmD9u/owrzempiDlBB1ZYqiLnm9s6aPc8AlFQalq2tKmqdmMr9GXOupDgzXtqnBipj8Un0gkIm7Sjf8mw=="
|
||||||
|
},
|
||||||
"node_modules/@hapi/hoek": {
|
"node_modules/@hapi/hoek": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz",
|
||||||
@@ -1004,6 +1011,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@wasm-audio-decoders/common": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wasm-audio-decoders/common/-/common-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-b9JNh9sPAvn8PVIizNh9D60WkfQong/u9ea873H47u7zvVDLctxYIp2aZw9CQqXaQdk7JB3MoU5UHiseO40swg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@eshaz/web-worker": "1.2.2",
|
||||||
|
"simple-yenc": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||||
@@ -5731,6 +5747,23 @@
|
|||||||
"opener": "bin/opener-bin.js"
|
"opener": "bin/opener-bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opus-decoder": {
|
||||||
|
"version": "0.7.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/opus-decoder/-/opus-decoder-0.7.7.tgz",
|
||||||
|
"integrity": "sha512-KWDyCi/9aXnNN+jrjs+aaVdwiwzDdac81S9ul0iv1CTs4+5K4VDZKuJjIImrYOBA2oSNHDjVq4xzn6BE+XbI1A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@wasm-audio-decoders/common": "9.0.5"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/eshaz"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/opus-recorder": {
|
||||||
|
"version": "8.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/opus-recorder/-/opus-recorder-8.0.5.tgz",
|
||||||
|
"integrity": "sha512-tBRXc9Btds7i3bVfA7d5rekAlyOcfsivt5vSIXHxRV1Oa+s6iXFW8omZ0Lm3ABWotVcEyKt96iIIUcgbV07YOw=="
|
||||||
|
},
|
||||||
"node_modules/ora": {
|
"node_modules/ora": {
|
||||||
"version": "5.4.1",
|
"version": "5.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz",
|
||||||
@@ -7619,6 +7652,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-yenc": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-yenc/-/simple-yenc-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-5gvxpSd79e9a3V4QDYUqnqxeD4HGlhCakVpb6gMnDD7lexJggSBJRBO5h52y/iJrdXRilX9UCuDaIJhSWm5OWw==",
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/eshaz"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/sirv/-/sirv-2.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/sirv/-/sirv-2.0.4.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"element-ui": "^2.15.14",
|
"element-ui": "^2.15.14",
|
||||||
"flyio": "^0.6.14",
|
"flyio": "^0.6.14",
|
||||||
"normalize.css": "^8.0.1",
|
"normalize.css": "^8.0.1",
|
||||||
|
"opus-decoder": "^0.7.7",
|
||||||
|
"opus-recorder": "^8.0.5",
|
||||||
"vue": "^2.6.14",
|
"vue": "^2.6.14",
|
||||||
"vue-axios": "^3.5.2",
|
"vue-axios": "^3.5.2",
|
||||||
"vue-router": "^3.6.5",
|
"vue-router": "^3.6.5",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -7,7 +7,7 @@ import admin from './module/admin.js'
|
|||||||
* 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
|
* 如果你想调用8002端口,就用'/xiaozhi-esp32-api/api/v1',请与vue.config.js的devServer配置相结合,方便跨域请求
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const DEV_API_SERVICE = 'https://apifoxmock.com/m1/5931378-5618560-default'
|
const DEV_API_SERVICE = 'https://2662r3426b.vicp.fun/xiaozhi-esp32-api'
|
||||||
// 8002开发完成完成后使用这个
|
// 8002开发完成完成后使用这个
|
||||||
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
|
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import {getServiceUrl} from '../api'
|
|||||||
export default {
|
export default {
|
||||||
// 用户列表
|
// 用户列表
|
||||||
getUserList(callback) {
|
getUserList(callback) {
|
||||||
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/admin/users`)
|
RequestService.sendRequest()
|
||||||
|
.url(`${getServiceUrl()}/api/v1/admin/users`)
|
||||||
.method('GET')
|
.method('GET')
|
||||||
.success((res) => {
|
.success((res) => {
|
||||||
RequestService.clearRequestTime()
|
RequestService.clearRequestTime()
|
||||||
callback(res)
|
callback(res)
|
||||||
})
|
})
|
||||||
.fail(() => {
|
.fail((err) => {
|
||||||
|
console.error('请求失败:', err)
|
||||||
RequestService.reAjaxFun(() => {
|
RequestService.reAjaxFun(() => {
|
||||||
this.getList()
|
this.getUserList(callback)
|
||||||
})
|
})
|
||||||
}).send()
|
})
|
||||||
},
|
.send()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -43,7 +43,7 @@
|
|||||||
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-menu slot="dropdown">
|
||||||
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
|
<el-dropdown-item icon="el-icon-plus" @click.native="">个人中心</el-dropdown-item>
|
||||||
<el-dropdown-item icon="el-icon-circle-plus" @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
|
<el-dropdown-item icon="el-icon-circle-plus" @click.native="showChangePasswordDialog">修改密码</el-dropdown-item>
|
||||||
<el-dropdown-item icon="el-icon-circle-plus-outline" @click.native="">退出登录</el-dropdown-item>
|
<el-dropdown-item icon="el-icon-circle-plus-outline" @click.native="handleLogout">退出登录</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +57,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import userApi from '@/apis/module/user';
|
import userApi from '@/apis/module/user';
|
||||||
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
|
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
|
||||||
|
import { mapActions } from 'vuex'; // 导入 mapActions
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HeaderBar',
|
name: 'HeaderBar',
|
||||||
@@ -118,7 +120,24 @@ export default {
|
|||||||
// 显示修改密码弹窗
|
// 显示修改密码弹窗
|
||||||
showChangePasswordDialog() {
|
showChangePasswordDialog() {
|
||||||
this.isChangePasswordDialogVisible = true;
|
this.isChangePasswordDialogVisible = true;
|
||||||
}
|
},
|
||||||
|
// 退出登录
|
||||||
|
async handleLogout() {
|
||||||
|
try {
|
||||||
|
// 调用 Vuex 的 logout action
|
||||||
|
await this.logout();
|
||||||
|
|
||||||
|
this.$message.success('退出登录成功');
|
||||||
|
|
||||||
|
this.$router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('退出登录失败:', error);
|
||||||
|
this.$message.error('退出登录失败,请重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 使用 mapActions 引入 Vuex 的 logout action
|
||||||
|
...mapActions(['logout'])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -62,6 +62,13 @@ const routes = [
|
|||||||
return import('../views/ModelConfig.vue')
|
return import('../views/ModelConfig.vue')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/test',
|
||||||
|
name: 'TestServer',
|
||||||
|
component: function () {
|
||||||
|
return import('../views/test.vue')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Vue.use(Vuex)
|
|||||||
|
|
||||||
export default new Vuex.Store({
|
export default new Vuex.Store({
|
||||||
state: {
|
state: {
|
||||||
token: ''
|
token: '',
|
||||||
|
userInfo: {} // 添加用户信息存储
|
||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
getToken(state) {
|
getToken(state) {
|
||||||
@@ -14,15 +15,33 @@ export default new Vuex.Store({
|
|||||||
state.token = localStorage.getItem('token')
|
state.token = localStorage.getItem('token')
|
||||||
}
|
}
|
||||||
return state.token
|
return state.token
|
||||||
|
},
|
||||||
|
getUserInfo(state) {
|
||||||
|
return state.userInfo
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mutations: {
|
mutations: {
|
||||||
setToken(state, token) {
|
setToken(state, token) {
|
||||||
state.token = token
|
state.token = token
|
||||||
localStorage.token = token
|
localStorage.setItem('token', token)
|
||||||
|
},
|
||||||
|
setUserInfo(state, userInfo) {
|
||||||
|
state.userInfo = userInfo
|
||||||
|
},
|
||||||
|
clearAuth(state) {
|
||||||
|
state.token = ''
|
||||||
|
state.userInfo = {}
|
||||||
|
localStorage.removeItem('token')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
// 添加 logout action
|
||||||
|
logout({ commit }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
commit('clearAuth')
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
<HeaderBar />
|
<HeaderBar />
|
||||||
|
|
||||||
<div class="operation-bar">
|
<div class="operation-bar">
|
||||||
|
<h2 class="page-title">模型配置</h2>
|
||||||
<div class="right-operations">
|
<div class="right-operations">
|
||||||
<el-button v-if="activeTab === 'tts'" type="primary" plain size="small" @click="ttsDialogVisible = true">
|
<el-button v-if="activeTab === 'tts'" type="primary" plain size="small" @click="ttsDialogVisible = true" style="">
|
||||||
语音设置
|
语音设置
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" plain size="small" @click="handleImport">
|
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
|
||||||
|
<img alt="" src="@/assets/model/inner_conf.png">
|
||||||
导入配置
|
导入配置
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="success" plain size="small" @click="handleExport">
|
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
|
||||||
|
<img alt="" src="@/assets/model/output_conf.png">
|
||||||
导出配置
|
导出配置
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主体内容 -->
|
<!-- 主体内容 -->
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
<div class="content-panel">
|
<div class="content-panel">
|
||||||
@@ -61,12 +64,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table :header-cell-style="{background: 'transparent'}" :data="modelList" border class="data-table" header-row-class-name="table-header" >
|
<el-table ref="modelTable" style="width: 100%" :header-cell-style="{background: 'transparent'}" :data="modelList" class="data-table" header-row-class-name="table-header" :header-cell-class-name="headerCellClassName" @selection-change="handleSelectionChange">
|
||||||
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
<el-table-column type="selection" width="55" align="center"></el-table-column>
|
||||||
<el-table-column label="模型名称" prop="candidateName" align="center"></el-table-column>
|
<el-table-column label="模型名称" prop="candidateName" align="center"></el-table-column>
|
||||||
<el-table-column label="模型编码" prop="code" align="center"></el-table-column>
|
<el-table-column label="模型编码" prop="code" align="center"></el-table-column>
|
||||||
<el-table-column label="提供商" prop="supplier" align="center"></el-table-column>
|
<el-table-column label="提供商" prop="supplier" align="center"></el-table-column>
|
||||||
<el-table-column label="是否启用" align="center" width="120">
|
<el-table-column label="是否启用" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-switch v-model="scope.row.isApplied" class="custom-switch" :active-color="null" :inactive-color="null"/>
|
<el-switch v-model="scope.row.isApplied" class="custom-switch" :active-color="null" :inactive-color="null"/>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
|
|
||||||
<div class="table-footer">
|
<div class="table-footer">
|
||||||
<div class="batch-actions">
|
<div class="batch-actions">
|
||||||
<el-button size="mini" @click="selectAll">全选</el-button>
|
<el-button size="mini" @click="selectAll" style="width: 75px; background: #606ff3">{{ isAllSelected ? '取消全选' : '全选' }}</el-button>
|
||||||
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
|
<el-button size="mini" type="danger" icon="el-icon-delete" @click="batchDelete">
|
||||||
删除
|
删除
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -131,10 +134,18 @@ export default {
|
|||||||
],
|
],
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 4,
|
pageSize: 4,
|
||||||
total: 20
|
total: 20,
|
||||||
|
selectedModels: [],
|
||||||
|
isAllSelected: false
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
headerCellClassName({ column, columnIndex }) {
|
||||||
|
if (columnIndex === 0) {
|
||||||
|
return 'custom-selection-header';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
handleMenuSelect(index) {
|
handleMenuSelect(index) {
|
||||||
this.activeTab = index;
|
this.activeTab = index;
|
||||||
},
|
},
|
||||||
@@ -142,8 +153,24 @@ export default {
|
|||||||
console.log('查询:', this.search);
|
console.log('查询:', this.search);
|
||||||
},
|
},
|
||||||
batchDelete() {
|
batchDelete() {
|
||||||
console.log('批量删除');
|
if (this.selectedModels.length === 0) {
|
||||||
},
|
this.$message.warning('请先选择要删除的模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$confirm('确定要删除选中的模型吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
const selectedIds = this.selectedModels.map(model => model.code);
|
||||||
|
this.modelList = this.modelList.filter(model => !selectedIds.includes(model.code));
|
||||||
|
this.$message.success('删除成功');
|
||||||
|
this.selectedModels = [];
|
||||||
|
this.isAllSelected = false;
|
||||||
|
}).catch(() => {
|
||||||
|
this.$message.info('已取消删除');
|
||||||
|
});
|
||||||
|
},
|
||||||
addModel() {
|
addModel() {
|
||||||
this.addDialogVisible = true;
|
this.addDialogVisible = true;
|
||||||
},
|
},
|
||||||
@@ -156,10 +183,20 @@ export default {
|
|||||||
this.editDialogVisible = true;
|
this.editDialogVisible = true;
|
||||||
},
|
},
|
||||||
deleteModel(model) {
|
deleteModel(model) {
|
||||||
console.log('删除:', model);
|
this.$confirm(`确定要删除模型 ${model.candidateName} 吗?`, '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
this.modelList = this.modelList.filter(item => item.code !== model.code);
|
||||||
|
this.$message.success('删除成功');
|
||||||
|
}).catch(() => {
|
||||||
|
this.$message.info('已取消删除');
|
||||||
|
});
|
||||||
},
|
},
|
||||||
handleCurrentChange(page) {
|
handleCurrentChange(page) {
|
||||||
this.currentPage = page;
|
this.currentPage = page;
|
||||||
|
this.$refs.modelTable.clearSelection();
|
||||||
console.log('当前页码:', page);
|
console.log('当前页码:', page);
|
||||||
},
|
},
|
||||||
handleImport() {
|
handleImport() {
|
||||||
@@ -172,7 +209,18 @@ export default {
|
|||||||
console.log('保存的模型数据:', formData);
|
console.log('保存的模型数据:', formData);
|
||||||
},
|
},
|
||||||
selectAll() {
|
selectAll() {
|
||||||
console.log('全选');
|
if (this.isAllSelected) {
|
||||||
|
this.$refs.modelTable.clearSelection();
|
||||||
|
} else {
|
||||||
|
this.$refs.modelTable.toggleAllSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleSelectionChange(val) {
|
||||||
|
this.selectedModels = val;
|
||||||
|
this.isAllSelected = val.length === this.modelList.length;
|
||||||
|
if (val.length === 0) {
|
||||||
|
this.isAllSelected = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handleAddConfirm(newModel) {
|
handleAddConfirm(newModel) {
|
||||||
console.log('新增模型数据:', newModel);
|
console.log('新增模型数据:', newModel);
|
||||||
@@ -185,6 +233,7 @@ export default {
|
|||||||
::v-deep .el-table tr{
|
::v-deep .el-table tr{
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
.welcome {
|
||||||
min-width: 900px;
|
min-width: 900px;
|
||||||
min-height: 506px;
|
min-height: 506px;
|
||||||
@@ -200,21 +249,32 @@ export default {
|
|||||||
|
|
||||||
.main-wrapper {
|
.main-wrapper {
|
||||||
margin: 5px 60px;
|
margin: 5px 60px;
|
||||||
background-image: url("@/assets/home/background.png");
|
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: rgba(237,242,255,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.operation-bar {
|
.operation-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
border-bottom: 1px solid #ebeef5;
|
border-bottom: 1px solid #ebeef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-operations {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.content-panel {
|
.content-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -225,12 +285,17 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-panel {
|
.nav-panel {
|
||||||
width: 18%;
|
min-width: 242px;
|
||||||
min-width: 200px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-right: 1px solid #ebeef5;
|
border-right: 1px solid #ebeef5;
|
||||||
background-size: cover;
|
background:
|
||||||
background: url("../assets/model/model.png") no-repeat center;
|
linear-gradient(
|
||||||
|
120deg,
|
||||||
|
rgba(107, 140, 255, 0.3) 0%,
|
||||||
|
rgba(169, 102, 255, 0.3) 25%,
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
url("../assets/model/model.png") no-repeat center / cover;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -238,22 +303,36 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-panel .el-menu-item {
|
.nav-panel .el-menu-item {
|
||||||
height: 48px;
|
height: 50px;
|
||||||
line-height: 40px;
|
line-height: 50px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding-right: 12px !important;
|
padding-right: 12px !important;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
margin: 8px 12px 8px auto;
|
margin: 8px 0px 8px auto;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-panel .el-menu-item.is-active {
|
.nav-panel .el-menu-item.is-active {
|
||||||
background: #ecf5ff;
|
background: #e9f0ff;
|
||||||
color: #409EFF;
|
color: #0ba6f4 !important;
|
||||||
border-right: 3px solid #409EFF;
|
position: relative;
|
||||||
|
padding-left: 40px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-panel .el-menu-item.is-active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 15px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
background: #409EFF;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 4px rgba(64, 158, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-text {
|
.menu-text {
|
||||||
@@ -264,14 +343,12 @@ export default {
|
|||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 600px;
|
min-width: 600px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-bar {
|
.title-bar {
|
||||||
@@ -303,6 +380,15 @@ export default {
|
|||||||
width: 240px;
|
width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::v-deep .search-input .el-input__inner::placeholder {
|
||||||
|
color: black;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .search-input .el-input__inner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.search-btn {
|
.search-btn {
|
||||||
background: linear-gradient(135deg, #6B8CFF, #A966FF);
|
background: linear-gradient(135deg, #6B8CFF, #A966FF);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -313,7 +399,6 @@ export default {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.data-table /deep/ .el-table__row {
|
.data-table /deep/ .el-table__row {
|
||||||
@@ -355,24 +440,27 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-btn {
|
.edit-btn {
|
||||||
color: #409EFF !important;
|
color: #7079aa !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
color: #F56C6C !important;
|
color: #7079aa !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-btn {
|
.add-btn {
|
||||||
background: #87CEFA;
|
background: #cce5f9;
|
||||||
|
width: 75px;
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: black;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-wrapper {
|
.title-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-actions .el-button:first-child {
|
.batch-actions .el-button:first-child {
|
||||||
background: linear-gradient(135deg, #409EFF, #6B8CFF);
|
background: linear-gradient(135deg, #409EFF, #6B8CFF);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -396,6 +484,64 @@ export default {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::v-deep .el-table .custom-selection-header .cell .el-checkbox__inner {
|
||||||
|
display: none !important; /* 使表头复选框不可见 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-table .custom-selection-header .cell::before {
|
||||||
|
content: '选择';
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 0;
|
||||||
|
color: black;
|
||||||
|
margin-top: 23px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-table__body .el-checkbox__inner {
|
||||||
|
display: inline-block !important;
|
||||||
|
background: #e6edfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-table thead th:not(:first-child) .cell {
|
||||||
|
color: #303133 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .nav-panel .el-menu-item.is-active .menu-text {
|
||||||
|
color: #409EFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .data-table {
|
||||||
|
&.el-table::before,
|
||||||
|
&.el-table::after,
|
||||||
|
&.el-table__inner-wrapper::before {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .data-table .el-table__header-wrapper {
|
||||||
|
border-bottom: 1px solid rgb(224,227,237);
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .data-table .el-table__body td {
|
||||||
|
border-bottom: 1px solid rgb(224,227,237) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button img{
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding-right: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-checkbox__inner {
|
||||||
|
border-color: #cfcfcf !important;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .el-checkbox__input.is-checked .el-checkbox__inner {
|
||||||
|
background-color: #409EFF !important;
|
||||||
|
border-color: #409EFF !important;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,27 @@
|
|||||||
<div class="top-area">
|
<div class="top-area">
|
||||||
<div class="page-title">用户管理</div>
|
<div class="page-title">用户管理</div>
|
||||||
<div class="page-search">
|
<div class="page-search">
|
||||||
<el-input placeholder="请输入手机号码查询" v-model="searchPhone" class="search-input" />
|
<el-input placeholder="请输入手机号码查询" v-model="searchPhone" class="search-input" @keyup.enter.native="handleSearch"/>
|
||||||
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
<el-button class="btn-search" @click="handleSearch">搜索</el-button>
|
||||||
<!-- <el-button type="danger" @click="batchDelete">批量删除</el-button>
|
|
||||||
<el-button type="danger" @click="batchDisable">批量禁用</el-button> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card class="user-card" shadow="never">
|
<el-card class="user-card" shadow="never">
|
||||||
<!-- <div class="user-search-operate" style="display: flex; align-items: center; margin-bottom: 20px;">
|
|
||||||
<el-input placeholder="请输入手机号码查询" v-model="searchPhone" style="width: 300px; margin-right: 10px" />
|
|
||||||
<el-button @click="handleSearch">查询</el-button>
|
|
||||||
<el-button type="danger" @click="batchDelete">批量删除</el-button>
|
|
||||||
<el-button type="danger" @click="batchDisable">批量禁用</el-button>
|
|
||||||
</div> -->
|
|
||||||
<el-table :data="userList" class="transparent-table" :header-cell-class-name="headerCellClassName">
|
<el-table :data="userList" class="transparent-table" :header-cell-class-name="headerCellClassName">
|
||||||
<el-table-column label="选择" type="selection" width="55"></el-table-column>
|
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
|
||||||
<el-table-column label="用户Id" prop="user_id"></el-table-column>
|
<el-table-column label="用户Id" prop="user_id" align="center"></el-table-column>
|
||||||
<el-table-column label="手机号码" prop="mobile"></el-table-column>
|
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
|
||||||
<el-table-column label="设备数量" prop="device_count"></el-table-column>
|
<el-table-column label="设备数量" prop="device_count" align="center"></el-table-column>
|
||||||
<el-table-column label="状态" prop="status"></el-table-column>
|
<el-table-column label="状态" prop="status" align="center"></el-table-column>
|
||||||
<el-table-column label="操作">
|
<el-table-column label="操作" align="center">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-button size="mini" type="text" @click="resetPassword(scope.row)" style="color: #989fdd">重置密码</el-button>
|
<el-button size="mini" type="text" @click="resetPassword(scope.row)" style="color: #989fdd">重置密码</el-button>
|
||||||
<el-button size="mini" type="text"
|
<el-button size="mini" type="text"
|
||||||
v-if="scope.row.status === '正常'"
|
v-if="scope.row.status === '正常'"
|
||||||
@click="disableUser(scope.row)">禁用</el-button>
|
@click="disableUser(scope.row)">禁用账户</el-button>
|
||||||
<el-button size="mini" type="text"
|
<el-button size="mini" type="text"
|
||||||
v-if="scope.row.status === '禁用'"
|
v-if="scope.row.status === '禁用'"
|
||||||
@click="restoreUser(scope.row)">恢复</el-button>
|
@click="restoreUser(scope.row)">恢复账号</el-button>
|
||||||
<el-button size="mini" type="text" @click="deleteUser(scope.row)" style="color: #989fdd">删除用户</el-button>
|
<el-button size="mini" type="text" @click="deleteUser(scope.row)" style="color: #989fdd">删除用户</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -46,16 +38,22 @@
|
|||||||
<el-button size="mini" type="warning" style="color: black; background: #f6d075"><i class="el-icon-remove-outline rotated-icon"></i>禁用</el-button>
|
<el-button size="mini" type="warning" style="color: black; background: #f6d075"><i class="el-icon-remove-outline rotated-icon"></i>禁用</el-button>
|
||||||
<el-button size="mini" type="danger" icon="el-icon-delete" style="background: #fd5b63">删除</el-button>
|
<el-button size="mini" type="danger" icon="el-icon-delete" style="background: #fd5b63">删除</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination-container">
|
<div class="custom-pagination">
|
||||||
<el-pagination
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
|
||||||
background
|
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
|
||||||
@current-change="handleCurrentChange"
|
|
||||||
:current-page="currentPage"
|
<button
|
||||||
:page-sizes="[5, 10, 15]"
|
v-for="page in visiblePages"
|
||||||
:page-size="pageSize"
|
:key="page"
|
||||||
layout="prev, pager, next"
|
class="pagination-btn"
|
||||||
:total="total"
|
:class="{ active: page === currentPage }"
|
||||||
/>
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="pagination-btn" :disabled="currentPage === pageCount" @click="goNext">下一页</button>
|
||||||
|
<span class="total-text">共{{ total }}条记录</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -69,7 +67,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import HeaderBar from "@/components/HeaderBar.vue";
|
import HeaderBar from "@/components/HeaderBar.vue";
|
||||||
import Api from '@/apis/api';
|
|
||||||
import adminApi from '@/apis/module/admin';
|
import adminApi from '@/apis/module/admin';
|
||||||
|
|
||||||
|
|
||||||
@@ -78,29 +75,67 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
searchPhone: '',
|
searchPhone: '',
|
||||||
userList: [
|
userList: [],
|
||||||
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 10 },
|
originalUserList: [], // 原始数据
|
||||||
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 9 },
|
|
||||||
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 7 },
|
|
||||||
{ userId: '123456', phone: '13800138000', status: '禁用', deviceCount: 7 }
|
|
||||||
],
|
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 4,
|
pageSize: 5,
|
||||||
total: 20
|
total: 20
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
adminApi.getUserList(({data}) => {
|
this.fetchUsers();
|
||||||
//mock偶尔会返回-1导致出错,又会返回两个list,所以这里只取第一个
|
},
|
||||||
this.userList = data.data[0].list;
|
computed: {
|
||||||
console.log('用户列表:', this.userList);
|
pageCount() {
|
||||||
})
|
return Math.ceil(this.total / this.pageSize);
|
||||||
|
},
|
||||||
|
visiblePages() {
|
||||||
|
const pages = [];
|
||||||
|
const maxVisible = 3;
|
||||||
|
let start = Math.max(1, this.currentPage - 1);
|
||||||
|
let end = Math.min(this.pageCount, start + maxVisible - 1);
|
||||||
|
|
||||||
|
if (end - start + 1 < maxVisible) {
|
||||||
|
start = Math.max(1, end - maxVisible + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i <= end; i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
handleSearch() {
|
// 获取用户列表
|
||||||
// 模拟搜索逻辑
|
fetchUsers() {
|
||||||
console.log('执行查询,搜索号码:', this.searchPhone);
|
adminApi.getUserList(({data}) => {
|
||||||
|
if (data.code === 0) {
|
||||||
|
const responseData = data.data[0] || data.data;
|
||||||
|
this.originalUserList = responseData.list.map(user => ({
|
||||||
|
...user,
|
||||||
|
status: user.status === '1' ? '正常' : '禁用'
|
||||||
|
}));
|
||||||
|
this.userList = [...this.originalUserList];
|
||||||
|
this.total = responseData.totalCount || 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 分页变化
|
||||||
|
handleCurrentChange(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.fetchUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
handleSearch() {
|
||||||
|
if (!this.searchPhone) {
|
||||||
|
this.userList = [...this.originalUserList];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.userList = this.originalUserList.filter(user =>
|
||||||
|
user.mobile.includes(this.searchPhone)
|
||||||
|
)},
|
||||||
batchDelete() {
|
batchDelete() {
|
||||||
console.log('执行批量删除操作');
|
console.log('执行批量删除操作');
|
||||||
},
|
},
|
||||||
@@ -121,15 +156,35 @@ export default {
|
|||||||
deleteUser(row) {
|
deleteUser(row) {
|
||||||
console.log('删除用户:', row);
|
console.log('删除用户:', row);
|
||||||
},
|
},
|
||||||
handleCurrentChange(page) {
|
handleSizeChange(val) {
|
||||||
this.currentPage = page;
|
this.pageSize = val;
|
||||||
console.log('当前页码:', page);
|
console.log('每页条数:', val);
|
||||||
},
|
},
|
||||||
headerCellClassName({ column, columnIndex }) {
|
headerCellClassName({column, columnIndex}) {
|
||||||
if (columnIndex === 0) {
|
if (columnIndex === 0) {
|
||||||
return 'custom-selection-header'
|
return 'custom-selection-header'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
|
},
|
||||||
|
goFirst() {
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.handleCurrentChange(1);
|
||||||
|
},
|
||||||
|
goPrev() {
|
||||||
|
if (this.currentPage > 1) {
|
||||||
|
this.currentPage--;
|
||||||
|
this.handleCurrentChange(this.currentPage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goNext() {
|
||||||
|
if (this.currentPage < this.pageCount) {
|
||||||
|
this.currentPage++;
|
||||||
|
this.handleCurrentChange(this.currentPage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
goToPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.handleCurrentChange(page);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -140,22 +195,29 @@ export default {
|
|||||||
$table-bg-color: #ecf1fd;
|
$table-bg-color: #ecf1fd;
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
padding: 20px; display: flex; flex-direction: column;
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
|
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-area {
|
.top-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
|
margin-left: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-search {
|
.page-search {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.btn-search {
|
.btn-search {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
background: linear-gradient(to right, #5778ff, #c793f3);
|
background: linear-gradient(to right, #5778ff, #c793f3);
|
||||||
@@ -164,6 +226,7 @@ $table-bg-color: #ecf1fd;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome {
|
.welcome {
|
||||||
min-width: 900px;
|
min-width: 900px;
|
||||||
min-height: 506px;
|
min-height: 506px;
|
||||||
@@ -196,7 +259,7 @@ $table-bg-color: #ecf1fd;
|
|||||||
background: $table-bg-color;
|
background: $table-bg-color;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
//box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
margin: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table_bottom {
|
.table_bottom {
|
||||||
@@ -204,18 +267,14 @@ $table-bg-color: #ecf1fd;
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
|
|
||||||
.ctrl_btn {
|
.ctrl_btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-left: 25px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.rotated-icon {
|
.rotated-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
@@ -232,8 +291,22 @@ $table-bg-color: #ecf1fd;
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child td {
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.el-table__body tr {
|
.el-table__body tr {
|
||||||
background-color: $table-bg-color;
|
background-color: $table-bg-color;
|
||||||
|
td {
|
||||||
|
border: {
|
||||||
|
top: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,12 +314,15 @@ $table-bg-color: #ecf1fd;
|
|||||||
.search-input {
|
.search-input {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
|
|
||||||
:deep(.el-input__inner) {
|
:deep(.el-input__inner) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
border-color: #d3d6dc;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: #409eff; // 保持聚焦状态下的边框颜色
|
border-color: #409eff;
|
||||||
}
|
}
|
||||||
//文字颜色
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #606266;
|
color: #606266;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
@@ -268,4 +344,86 @@ $table-bg-color: #ecf1fd;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
/* 导航按钮样式 (首页、上一页、下一页) */
|
||||||
|
.pagination-btn:first-child,
|
||||||
|
.pagination-btn:nth-child(2),
|
||||||
|
.pagination-btn:nth-last-child(2) {
|
||||||
|
min-width: 60px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
background: #DEE7FF;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #d7dce6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数字按钮样式 */
|
||||||
|
.pagination-btn:not(:first-child):not(:nth-child(2)):not(:nth-last-child(2)) {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(245, 247, 250, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pagination-btn.active {
|
||||||
|
background: #5f70f3 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
border-color: #5f70f3 !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #6d7cf5 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-text {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox__inner) {
|
||||||
|
background-color: #eeeeee !important;
|
||||||
|
border-color: #cccccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox__inner:hover) {
|
||||||
|
border-color: #cccccc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||||
|
background-color: #5f70f3 !important;
|
||||||
|
border-color: #5f70f3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">XiaoZhi ESP32 Server 测试助手</h1>
|
||||||
|
<div class="chat-container" ref="chatContainer">
|
||||||
|
<div v-for="(message, index) in messages" :key="index"
|
||||||
|
:class="['message', message.role === 'user' ? 'user' : 'assistant']">
|
||||||
|
<span>{{ message.content }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<button @click="toggleRecording" :disabled="wsStatus !== 'connected'"
|
||||||
|
:class="{ recording: isRecording }">
|
||||||
|
{{ isRecording ? '停止录音' : '开始录音' }}
|
||||||
|
</button>
|
||||||
|
<p>WebSocket: {{ wsStatus }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||||
|
import Recorder from 'opus-recorder';
|
||||||
|
import { OpusDecoder } from 'opus-decoder';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TestPage',
|
||||||
|
setup() {
|
||||||
|
const messages = ref([]);
|
||||||
|
const chatContainer = ref(null);
|
||||||
|
const wsStatus = ref('disconnected');
|
||||||
|
const isRecording = ref(false);
|
||||||
|
let ws = null;
|
||||||
|
let recorder = null;
|
||||||
|
let stream = null;
|
||||||
|
let audioContext = null;
|
||||||
|
let sourceNode = null;
|
||||||
|
let audioDecoder = null;
|
||||||
|
let audioBufferQueue = []; // 当前句子的音频缓冲区
|
||||||
|
let playbackQueue = []; // 播放队列,存储待播放的句子
|
||||||
|
let isPlaying = false;
|
||||||
|
|
||||||
|
const connectWebSocket = () => {
|
||||||
|
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
ws.onopen = () => {
|
||||||
|
wsStatus.value = 'connected';
|
||||||
|
console.log('WebSocket 连接成功');
|
||||||
|
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
|
||||||
|
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
|
||||||
|
};
|
||||||
|
ws.onmessage = async (event) => {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
console.log('收到文本消息:', msg);
|
||||||
|
|
||||||
|
if (msg.type === 'stt') {
|
||||||
|
messages.value.push({ role: 'user', content: msg.text });
|
||||||
|
scrollToBottom();
|
||||||
|
} else if (msg.type === 'llm') {
|
||||||
|
messages.value.push({ role: 'assistant', content: msg.text });
|
||||||
|
scrollToBottom();
|
||||||
|
} else if (msg.type === 'tts') {
|
||||||
|
if (msg.state === 'sentence_start') {
|
||||||
|
// 开始新句子,清空当前缓冲区
|
||||||
|
audioBufferQueue = [];
|
||||||
|
const lastMessage = messages.value[messages.value.length - 1];
|
||||||
|
if (!lastMessage || lastMessage.content !== msg.text) {
|
||||||
|
messages.value.push({ role: 'assistant', content: msg.text });
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
} else if (msg.state === 'sentence_end') {
|
||||||
|
// 句子结束,将当前缓冲区加入播放队列
|
||||||
|
if (audioBufferQueue.length > 0) {
|
||||||
|
playbackQueue.push([...audioBufferQueue]);
|
||||||
|
audioBufferQueue = [];
|
||||||
|
playNextInQueue(); // 尝试播放队列中的下一句
|
||||||
|
}
|
||||||
|
} else if (msg.state === 'stop') {
|
||||||
|
console.log('TTS 任务结束');
|
||||||
|
// 确保所有剩余音频播放
|
||||||
|
if (audioBufferQueue.length > 0) {
|
||||||
|
playbackQueue.push([...audioBufferQueue]);
|
||||||
|
audioBufferQueue = [];
|
||||||
|
playNextInQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'hello') {
|
||||||
|
console.log('收到服务器初始化消息:', msg);
|
||||||
|
}
|
||||||
|
} else if (event.data instanceof ArrayBuffer) {
|
||||||
|
console.log('收到音频帧,大小:', event.data.byteLength, '字节');
|
||||||
|
const opusFrame = new Uint8Array(event.data);
|
||||||
|
const frameHead = Array.from(opusFrame.slice(0, 8))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join(' ');
|
||||||
|
console.log('音频帧前8字节:', frameHead);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = audioDecoder.decodeFrame(opusFrame);
|
||||||
|
console.log('解码结果:', decoded);
|
||||||
|
if (decoded && decoded.channelData && decoded.channelData[0]) {
|
||||||
|
const pcmData = decoded.channelData[0];
|
||||||
|
if (pcmData.length > 0) {
|
||||||
|
audioBufferQueue.push(pcmData);
|
||||||
|
console.log('解码音频帧,PCM 数据长度:', pcmData.length);
|
||||||
|
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放
|
||||||
|
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) {
|
||||||
|
playbackQueue.push([...audioBufferQueue]);
|
||||||
|
audioBufferQueue = [];
|
||||||
|
playNextInQueue();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('解码成功,但 PCM 数据长度为 0');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn('解码结果无效:', decoded);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Opus 解码错误:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket 错误:', error);
|
||||||
|
wsStatus.value = 'error';
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
wsStatus.value = 'disconnected';
|
||||||
|
console.log('WebSocket 断开');
|
||||||
|
setTimeout(connectWebSocket, 1000);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const playNextInQueue = async () => {
|
||||||
|
if (isPlaying || playbackQueue.length === 0) {
|
||||||
|
return; // 正在播放或队列为空,等待下次触发
|
||||||
|
}
|
||||||
|
isPlaying = true;
|
||||||
|
|
||||||
|
if (!audioContext || audioContext.state === 'closed') {
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句
|
||||||
|
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
|
||||||
|
if (totalLength === 0) {
|
||||||
|
console.error('音频缓冲区总长度为 0,无法播放');
|
||||||
|
isPlaying = false;
|
||||||
|
playNextInQueue(); // 尝试播放下一句
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedPcm = new Float32Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const pcm of pcmBuffers) {
|
||||||
|
mergedPcm.set(pcm, offset);
|
||||||
|
offset += pcm.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000);
|
||||||
|
audioBuffer.getChannelData(0).set(mergedPcm);
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = audioBuffer;
|
||||||
|
source.connect(audioContext.destination);
|
||||||
|
source.onended = () => {
|
||||||
|
isPlaying = false;
|
||||||
|
console.log('音频播放结束');
|
||||||
|
playNextInQueue(); // 播放结束后继续下一句
|
||||||
|
};
|
||||||
|
source.start();
|
||||||
|
console.log('开始播放音频,总长度:', totalLength);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripOggContainer = (data) => {
|
||||||
|
let arrayBuffer;
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
arrayBuffer = data;
|
||||||
|
} else if (data.buffer instanceof ArrayBuffer) {
|
||||||
|
arrayBuffer = data.buffer;
|
||||||
|
} else {
|
||||||
|
console.error('输入数据类型不支持:', data);
|
||||||
|
return [data];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
const frames = [];
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
while (offset < arrayBuffer.byteLength) {
|
||||||
|
if (dataView.getUint32(offset) === 0x4F676753) { // 'OggS'
|
||||||
|
const segmentTableLength = dataView.getUint8(offset + 26);
|
||||||
|
const segmentTableOffset = offset + 27;
|
||||||
|
let segmentOffset = segmentTableOffset + segmentTableLength;
|
||||||
|
|
||||||
|
for (let i = 0; i < segmentTableLength; i++) {
|
||||||
|
const segmentLength = dataView.getUint8(segmentTableOffset + i);
|
||||||
|
const segmentData = arrayBuffer.slice(segmentOffset, segmentOffset + segmentLength);
|
||||||
|
segmentOffset += segmentLength;
|
||||||
|
|
||||||
|
const header = new TextDecoder().decode(segmentData.slice(0, 8));
|
||||||
|
if (header === 'OpusHead' || header === 'OpusTags') {
|
||||||
|
console.log(`跳过 ${header},大小: ${segmentLength} 字节`);
|
||||||
|
} else if (segmentLength >= 50 && segmentLength <= 300) {
|
||||||
|
frames.push(segmentData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = segmentOffset;
|
||||||
|
} else {
|
||||||
|
const remainingLength = arrayBuffer.byteLength - offset;
|
||||||
|
if (remainingLength >= 50 && remainingLength <= 300) {
|
||||||
|
frames.push(arrayBuffer.slice(offset));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frames.length === 0) {
|
||||||
|
console.warn('未找到有效裸 Opus 帧,返回原始数据');
|
||||||
|
return [arrayBuffer];
|
||||||
|
}
|
||||||
|
console.log('剥离后找到', frames.length, '个裸 Opus 帧');
|
||||||
|
return frames;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initRecorder = async () => {
|
||||||
|
console.log('开始初始化录音');
|
||||||
|
try {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
}
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
console.log('获取麦克风权限成功,stream:', stream);
|
||||||
|
|
||||||
|
if (!audioContext || audioContext.state === 'closed') {
|
||||||
|
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
|
||||||
|
}
|
||||||
|
sourceNode = audioContext.createMediaStreamSource(stream);
|
||||||
|
|
||||||
|
recorder = new Recorder({
|
||||||
|
encoderPath: '/encoderWorker.min.js',
|
||||||
|
sampleRate: 16000,
|
||||||
|
numberOfChannels: 1,
|
||||||
|
frameSize: 960,
|
||||||
|
encoderApplication: 2048,
|
||||||
|
encoderFrameSize: 60,
|
||||||
|
encoderBitRate: 24000,
|
||||||
|
monitorGain: 0,
|
||||||
|
sourceNode: sourceNode,
|
||||||
|
ogg: false,
|
||||||
|
streamPages: false,
|
||||||
|
maxFramesPerPage: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.ondataavailable = (data) => {
|
||||||
|
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
|
||||||
|
const frames = stripOggContainer(data);
|
||||||
|
frames.forEach((frame, index) => {
|
||||||
|
const frameView = new DataView(frame);
|
||||||
|
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
|
||||||
|
.map(b => b.toString(16).padStart(2, '0'))
|
||||||
|
.join(' ');
|
||||||
|
console.log(`帧 ${index} 大小: ${frame.byteLength} 字节,前8字节: ${frameHead}`);
|
||||||
|
|
||||||
|
if (frame.byteLength < 50 || frame.byteLength > 300) {
|
||||||
|
console.warn(`帧 ${index} 大小异常: ${frame.byteLength} 字节,预期 50-300 字节`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(frame);
|
||||||
|
console.log('发送裸 Opus 帧:', frame.byteLength);
|
||||||
|
} else {
|
||||||
|
console.warn('WebSocket 未连接,跳过发送');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstart = () => {
|
||||||
|
console.log('录音已启动');
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
console.log('录音停止');
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
sourceNode = null;
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Recorder 初始化成功');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('初始化录音失败:', err);
|
||||||
|
alert('无法访问麦克风或录音初始化失败,请检查权限');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRecording = async () => {
|
||||||
|
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value);
|
||||||
|
if (!recorder) {
|
||||||
|
await initRecorder();
|
||||||
|
if (!recorder) {
|
||||||
|
console.error('recorder 初始化失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isRecording.value) {
|
||||||
|
console.log('停止录音');
|
||||||
|
recorder.stop();
|
||||||
|
isRecording.value = false;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
console.log('开始录音');
|
||||||
|
await initRecorder();
|
||||||
|
await recorder.start();
|
||||||
|
console.log('录音开始后,状态:', recorder.state);
|
||||||
|
isRecording.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('录音启动失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('组件挂载,初始化 WebSocket');
|
||||||
|
connectWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (ws) ws.close();
|
||||||
|
if (stream) stream.getTracks().forEach(track => track.stop());
|
||||||
|
if (recorder) recorder.stop();
|
||||||
|
if (audioContext) audioContext.close();
|
||||||
|
if (audioDecoder) audioDecoder.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.recording {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -58,7 +58,7 @@ def getWakeupWordFile(file_name):
|
|||||||
for file in os.listdir(WAKEUP_CONFIG["dir"]):
|
for file in os.listdir(WAKEUP_CONFIG["dir"]):
|
||||||
if file.startswith("my_" + file_name):
|
if file.startswith("my_" + file_name):
|
||||||
"""避免缓存文件是一个空文件"""
|
"""避免缓存文件是一个空文件"""
|
||||||
if os.stat(f"config/assets/{file}").st_size > (5 * 1024):
|
if os.stat(f"config/assets/{file}").st_size > (15 * 1024):
|
||||||
return f"config/assets/{file}"
|
return f"config/assets/{file}"
|
||||||
|
|
||||||
"""查找config/assets/目录下名称为wakeup_words的文件"""
|
"""查找config/assets/目录下名称为wakeup_words的文件"""
|
||||||
|
|||||||
Reference in New Issue
Block a user