Merge branch 'main' into test-server-pr

This commit is contained in:
欣南科技
2025-03-31 23:28:37 +08:00
committed by GitHub
20 changed files with 924 additions and 119 deletions
+1 -3
View File
@@ -18,6 +18,4 @@ xiaozhi-esp32-server
# manager-web 、manager-api接口协议
[manager前后端接口协议](https://app.apifox.com/invite/project?token=eXg2_tUv85q-gc3ZRowmn)
[前端页面设计图](https://codesign.qq.com/app/s/526108506410828)
https://2662r3426b.vicp.fun/xiaozhi-esp32-api/api/v1/doc.html
-2
View File
@@ -5,8 +5,6 @@ manager-api 该项目基于SpringBoot框架开发。
开发使用代码编辑器,导入项目时,选择`manager-api`文件夹作为项目目录
参照[manager前后端接口协议](https://app.apifox.com/invite/project?token=H_8qhgfjUeaAL0wybghgU)开发
# 开发环境
JDK 21
Maven 3.8+
@@ -75,7 +75,7 @@ public class TimbreController {
@Operation(summary = "音色修改")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> update(
@PathVariable Long id,
@PathVariable String id,
@RequestBody TimbreDataDTO dto) {
ValidatorUtils.validateEntity(dto);
timbreService.update(id, dto);
@@ -85,8 +85,8 @@ public class TimbreController {
@DeleteMapping("/{id}")
@Operation(summary = "音色删除")
@RequiresPermissions("sys:role:superAdmin")
public Result<Void> delete(@PathVariable Long id) {
timbreService.delete(new Long[] { id });
public Result<Void> delete(@PathVariable String id) {
timbreService.delete(new String[] { id });
return new Result<>();
}
@@ -45,14 +45,14 @@ public interface TimbreService extends BaseService<TimbreEntity> {
* @param timbreId 需要修改的id
* @param dto 需要修改的数据
*/
void update(Long timbreId, TimbreDataDTO dto);
void update(String timbreId, TimbreDataDTO dto);
/**
* 批量删除音色
*
* @param ids 需要被删除的音色id列表
*/
void delete(Long[] ids);
void delete(String[] ids);
List<String> getVoiceNames(String ttsModelId, String voiceName);
}
@@ -70,7 +70,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Override
@Transactional(rollbackFor = Exception.class)
public void update(Long timbreId, TimbreDataDTO dto) {
public void update(String timbreId, TimbreDataDTO dto) {
isTtsModelId(dto.getTtsModelId());
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
timbreEntity.setId(timbreId);
@@ -79,7 +79,7 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long[] ids) {
public void delete(String[] ids) {
baseDao.deleteBatchIds(Arrays.asList(ids));
}
@@ -16,7 +16,7 @@
<appender name="LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<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>
<!--日志文件保留天数-->
+42
View File
@@ -11,6 +11,8 @@
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
@@ -149,6 +151,11 @@
"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": {
"version": "9.3.0",
"resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -1004,6 +1011,15 @@
"dev": true,
"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": {
"version": "1.14.1",
"resolved": "https://registry.npmmirror.com/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -5731,6 +5747,23 @@
"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": {
"version": "5.4.1",
"resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz",
@@ -7619,6 +7652,15 @@
"dev": true,
"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": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/sirv/-/sirv-2.0.4.tgz",
+2
View File
@@ -10,6 +10,8 @@
"element-ui": "^2.15.14",
"flyio": "^0.6.14",
"normalize.css": "^8.0.1",
"opus-decoder": "^0.7.7",
"opus-recorder": "^8.0.5",
"vue": "^2.6.14",
"vue-axios": "^3.5.2",
"vue-router": "^3.6.5",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@ import admin from './module/admin.js'
* 如果你想调用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开发完成完成后使用这个
// const DEV_API_SERVICE = '/xiaozhi-esp32-api'
+8 -5
View File
@@ -5,16 +5,19 @@ import {getServiceUrl} from '../api'
export default {
// 用户列表
getUserList(callback) {
RequestService.sendRequest().url(`${getServiceUrl()}/api/v1/admin/users`)
RequestService.sendRequest()
.url(`${getServiceUrl()}/api/v1/admin/users`)
.method('GET')
.success((res) => {
RequestService.clearRequestTime()
callback(res)
})
.fail(() => {
.fail((err) => {
console.error('请求失败:', err)
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

+21 -2
View File
@@ -43,7 +43,7 @@
<el-dropdown-menu slot="dropdown">
<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-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>
</div>
@@ -57,6 +57,8 @@
<script>
import userApi from '@/apis/module/user';
import ChangePasswordDialog from './ChangePasswordDialog.vue'; // 引入修改密码弹窗组件
import { mapActions } from 'vuex'; // 导入 mapActions
export default {
name: 'HeaderBar',
@@ -118,7 +120,24 @@ export default {
// 显示修改密码弹窗
showChangePasswordDialog() {
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>
+7
View File
@@ -62,6 +62,13 @@ const routes = [
return import('../views/ModelConfig.vue')
}
},
{
path: '/test',
name: 'TestServer',
component: function () {
return import('../views/test.vue')
}
},
]
+22 -3
View File
@@ -6,7 +6,8 @@ Vue.use(Vuex)
export default new Vuex.Store({
state: {
token: ''
token: '',
userInfo: {} // 添加用户信息存储
},
getters: {
getToken(state) {
@@ -14,16 +15,34 @@ export default new Vuex.Store({
state.token = localStorage.getItem('token')
}
return state.token
},
getUserInfo(state) {
return state.userInfo
}
},
mutations: {
setToken(state, 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: {
// 添加 logout action
logout({ commit }) {
return new Promise((resolve) => {
commit('clearAuth')
resolve()
})
}
},
modules: {
}
})
})
+178 -32
View File
@@ -3,19 +3,22 @@
<HeaderBar />
<div class="operation-bar">
<h2 class="page-title">模型配置</h2>
<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 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 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>
</div>
</div>
<!-- 主体内容 -->
<div class="main-wrapper">
<div class="content-panel">
@@ -61,12 +64,12 @@
</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 label="模型名称" prop="candidateName" 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="是否启用" align="center" width="120">
<el-table-column label="是否启用" align="center">
<template slot-scope="scope">
<el-switch v-model="scope.row.isApplied" class="custom-switch" :active-color="null" :inactive-color="null"/>
</template>
@@ -82,10 +85,10 @@
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<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>
@@ -131,10 +134,18 @@ export default {
],
currentPage: 1,
pageSize: 4,
total: 20
total: 20,
selectedModels: [],
isAllSelected: false
};
},
methods: {
headerCellClassName({ column, columnIndex }) {
if (columnIndex === 0) {
return 'custom-selection-header';
}
return '';
},
handleMenuSelect(index) {
this.activeTab = index;
},
@@ -142,8 +153,24 @@ export default {
console.log('查询:', this.search);
},
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() {
this.addDialogVisible = true;
},
@@ -156,10 +183,20 @@ export default {
this.editDialogVisible = true;
},
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) {
this.currentPage = page;
this.$refs.modelTable.clearSelection();
console.log('当前页码:', page);
},
handleImport() {
@@ -172,7 +209,18 @@ export default {
console.log('保存的模型数据:', formData);
},
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) {
console.log('新增模型数据:', newModel);
@@ -185,6 +233,7 @@ export default {
::v-deep .el-table tr{
background: transparent;
}
.welcome {
min-width: 900px;
min-height: 506px;
@@ -200,21 +249,32 @@ export default {
.main-wrapper {
margin: 5px 60px;
background-image: url("@/assets/home/background.png");
border-radius: 15px;
min-height: 600px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
position: relative;
background: rgba(237,242,255,0.5);
}
.operation-bar {
display: flex;
justify-content: flex-end;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid #ebeef5;
}
.page-title {
font-size: 24px;
margin: 0;
}
.right-operations {
display: flex;
gap: 10px;
margin-left: auto;
}
.content-panel {
flex: 1;
display: flex;
@@ -225,12 +285,17 @@ export default {
}
.nav-panel {
width: 18%;
min-width: 200px;
min-width: 242px;
height: 100%;
border-right: 1px solid #ebeef5;
background-size: cover;
background: url("../assets/model/model.png") no-repeat center;
background:
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;
flex-shrink: 0;
display: flex;
@@ -238,22 +303,36 @@ export default {
}
.nav-panel .el-menu-item {
height: 48px;
line-height: 40px;
height: 50px;
line-height: 50px;
border-radius: 4px;
transition: all 0.3s;
display: flex !important;
justify-content: flex-end;
padding-right: 12px !important;
width: fit-content;
margin: 8px 12px 8px auto;
margin: 8px 0px 8px auto;
min-width: unset;
}
.nav-panel .el-menu-item.is-active {
background: #ecf5ff;
color: #409EFF;
border-right: 3px solid #409EFF;
background: #e9f0ff;
color: #0ba6f4 !important;
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 {
@@ -264,14 +343,12 @@ export default {
padding-right: 8px;
}
.content-area {
flex: 1;
padding: 24px;
height: 100%;
min-width: 600px;
overflow-x: auto;
}
.title-bar {
@@ -303,6 +380,15 @@ export default {
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 {
background: linear-gradient(135deg, #6B8CFF, #A966FF);
border: none;
@@ -313,7 +399,6 @@ export default {
border-radius: 6px;
overflow: hidden;
background-color: transparent !important;
}
.data-table /deep/ .el-table__row {
@@ -355,24 +440,27 @@ export default {
}
.edit-btn {
color: #409EFF !important;
color: #7079aa !important;
}
.delete-btn {
color: #F56C6C !important;
color: #7079aa !important;
}
.add-btn {
background: #87CEFA;
background: #cce5f9;
width: 75px;
border: none;
color: white;
color: black;
padding: 8px 16px;
}
.title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.batch-actions .el-button:first-child {
background: linear-gradient(135deg, #409EFF, #6B8CFF);
border: none;
@@ -396,6 +484,64 @@ export default {
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>
+220 -62
View File
@@ -5,35 +5,27 @@
<div class="top-area">
<div class="page-title">用户管理</div>
<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 type="danger" @click="batchDelete">批量删除</el-button>
<el-button type="danger" @click="batchDisable">批量禁用</el-button> -->
</div>
</div>
<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-column label="选择" type="selection" width="55"></el-table-column>
<el-table-column label="用户Id" prop="user_id"></el-table-column>
<el-table-column label="手机号码" prop="mobile"></el-table-column>
<el-table-column label="设备数量" prop="device_count"></el-table-column>
<el-table-column label="状态" prop="status"></el-table-column>
<el-table-column label="操作">
<el-table-column label="选择" type="selection" align="center" width="120"></el-table-column>
<el-table-column label="用户Id" prop="user_id" align="center"></el-table-column>
<el-table-column label="手机号码" prop="mobile" align="center"></el-table-column>
<el-table-column label="设备数量" prop="device_count" align="center"></el-table-column>
<el-table-column label="状态" prop="status" align="center"></el-table-column>
<el-table-column label="操作" align="center">
<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"
v-if="scope.row.status === '正常'"
@click="disableUser(scope.row)">禁用</el-button>
@click="disableUser(scope.row)">禁用账户</el-button>
<el-button size="mini" type="text"
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>
</template>
</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="danger" icon="el-icon-delete" style="background: #fd5b63">删除</el-button>
</div>
<div class="pagination-container">
<el-pagination
background
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="[5, 10, 15]"
:page-size="pageSize"
layout="prev, pager, next"
:total="total"
/>
<div class="custom-pagination">
<button class="pagination-btn" :disabled="currentPage === 1" @click="goFirst">首页</button>
<button class="pagination-btn" :disabled="currentPage === 1" @click="goPrev">上一页</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination-btn"
: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>
</el-card>
@@ -69,7 +67,6 @@
<script>
import HeaderBar from "@/components/HeaderBar.vue";
import Api from '@/apis/api';
import adminApi from '@/apis/module/admin';
@@ -78,29 +75,67 @@ export default {
data() {
return {
searchPhone: '',
userList: [
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 10 },
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 9 },
{ userId: '123456', phone: '13800138000', status: '正常', deviceCount: 7 },
{ userId: '123456', phone: '13800138000', status: '禁用', deviceCount: 7 }
],
userList: [],
originalUserList: [], // 原始数据
currentPage: 1,
pageSize: 4,
pageSize: 5,
total: 20
};
},
created() {
adminApi.getUserList(({data}) => {
//mock偶尔会返回-1导致出错,又会返回两个list,所以这里只取第一个
this.userList = data.data[0].list;
console.log('用户列表:', this.userList);
})
this.fetchUsers();
},
computed: {
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: {
handleSearch() {
// 模拟搜索逻辑
console.log('执行查询,搜索号码:', this.searchPhone);
// 获取用户列表
fetchUsers() {
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() {
console.log('执行批量删除操作');
},
@@ -121,15 +156,35 @@ export default {
deleteUser(row) {
console.log('删除用户:', row);
},
handleCurrentChange(page) {
this.currentPage = page;
console.log('当前页码', page);
handleSizeChange(val) {
this.pageSize = val;
console.log('每页条数', val);
},
headerCellClassName({ column, columnIndex }) {
if (columnIndex === 0) {
return 'custom-selection-header'
}
return ''
headerCellClassName({column, columnIndex}) {
if (columnIndex === 0) {
return 'custom-selection-header'
}
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;
.main {
padding: 20px; display: flex; flex-direction: column;
padding: 20px;
display: flex;
flex-direction: column;
background: linear-gradient(to bottom right, #dce8ff, #e4eeff, #e6cbfd);
}
.top-area {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
margin-left: 20px;
}
.page-search {
display: flex;
align-items: center;
.btn-search {
margin-left: 10px;
background: linear-gradient(to right, #5778ff, #c793f3);
@@ -164,6 +226,7 @@ $table-bg-color: #ecf1fd;
}
}
}
.welcome {
min-width: 900px;
min-height: 506px;
@@ -196,7 +259,7 @@ $table-bg-color: #ecf1fd;
background: $table-bg-color;
border-radius: 12px;
padding: 20px;
//box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
margin: 15px;
}
.table_bottom {
@@ -204,18 +267,14 @@ $table-bg-color: #ecf1fd;
justify-content: space-between;
align-items: center;
margin-top: 20px;
.ctrl_btn {
display: flex;
align-items: center;
margin-left: 25px;
}
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
.rotated-icon {
display: inline-block;
transform: rotate(45deg);
@@ -232,8 +291,22 @@ $table-bg-color: #ecf1fd;
color: black;
}
&::before {
display: none;
}
&:last-child td {
border-bottom: none !important;
}
.el-table__body tr {
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 {
width: 300px;
margin-right: 10px;
:deep(.el-input__inner) {
background-color: transparent;
border-color: #d3d6dc;
&:focus {
border-color: #409eff; // 保持聚焦状态下的边框颜色
border-color: #409eff;
}
//文字颜色
&::placeholder {
color: #606266;
opacity: 0.7;
@@ -268,4 +344,86 @@ $table-bg-color: #ecf1fd;
}
}
</style>
.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>
+412
View File
@@ -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"]):
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}"
"""查找config/assets/目录下名称为wakeup_words的文件"""