Merge pull request #2018 from xinnan-tech/feature/mobile-app

Feature/mobile app
This commit is contained in:
欣南科技
2025-08-11 20:49:04 +08:00
committed by GitHub
149 changed files with 26173 additions and 0 deletions
+8
View File
@@ -175,3 +175,11 @@ uploadfile
*.json
.vscode
.cursor
!package.json
!**/package.json
# Do not ignore env and json files inside manager-mobile
!main/manager-mobile/**/env/
!main/manager-mobile/**/.env*
!main/manager-mobile/**/*.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
}
+13
View File
@@ -0,0 +1,13 @@
root = true
[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行
[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off # 关闭最大行长度限制
trim_trailing_whitespace = false # 关闭末尾空格修剪
+44
View File
@@ -0,0 +1,44 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
*.local
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.hbuilderx
.stylelintcache
.eslintcache
docs/.vitepress/dist
docs/.vitepress/cache
src/types
# lock 文件还是不要了,我主要的版本写死就好了
# pnpm-lock.yaml
# package-lock.json
# TIPS:如果某些文件已经加入了版本管理,现在重新加入 .gitignore 是不生效的,需要执行下面的操作
# `git rm -r --cached .` 然后提交 commit 即可。
# git rm -r --cached file1 file2 ## 针对某些文件
# git rm -r --cached dir1 dir2 ## 针对某些文件夹
# git rm -r --cached . ## 针对所有文件
# 更新 uni-app 官方版本
# npx @dcloudio/uvm@latest
+8
View File
@@ -0,0 +1,8 @@
# registry = https://registry.npmjs.org
registry = https://registry.npmmirror.com
strict-peer-dependencies=false
auto-install-peers=true
shamefully-hoist=true
ignore-workspace-root-check=true
install-workspace-root=true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Junsen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+170
View File
@@ -0,0 +1,170 @@
## 智控台移动版(manager-mobile
基于 uni-app v3 + Vue 3 + Vite 的跨端移动管理端,支持 AppAndroid & iOS)和微信小程序。
### 平台兼容性
| H5 | iOS | Android | 微信小程序 | 字节小程序 | 快手小程序 | 支付宝小程序 | 钉钉小程序 | 百度小程序 |
| -- | --- | ------- | ---------- | ---------- | ---------- | ------------ | ---------- | ---------- |
| × | √ | √ | √ | × | × | × | × | × |
提示:不同 UI 组件在不同平台的适配度略有差异,请以对应组件库文档为准。
### 开发环境要求
- Node >= 18
- pnpm >= 7.30(建议使用项目中声明的 `pnpm@10.x`
- 可选:HBuilderX(App 调试/打包)、微信开发者工具(微信小程序)
### 快速开始
1) 配置环境变量
- 复制 `env/.env.example``env/.env.development`
- 根据实际情况修改配置项(特别是 `VITE_SERVER_BASEURL``VITE_UNI_APPID``VITE_WX_APPID`
2) 安装依赖
```bash
pnpm i
```
3) 本地开发(热更新)
- h5: `pnpm dev:h5`,然后观察启动日志显示的ip端口号
- 微信小程序:`pnpm dev:mp``pnpm dev:mp-weixin`,然后用微信开发者工具导入 `dist/dev/mp-weixin`
- App:用 HBuilderX 导入 `manager-mobile`,然后参考下面的教程就能运行了
### 环境变量与配置
项目使用自定义 `env` 目录存放环境文件,按 Vite 规范命名:`.env.development``.env.production` 等。
关键变量(部分):
- VITE_APP_TITLE:应用名称(写入 `manifest.config.ts`
- VITE_UNI_APPIDuni-app 应用 appidApp
- VITE_WX_APPID:微信小程序 appidmp-weixin
- VITE_FALLBACK_LOCALE:默认语言,如 `zh-Hans`
- VITE_SERVER_BASEURL:服务端基础地址(HTTP 请求 baseURL
- VITE_DELETE_CONSOLE:构建时是否移除 console`true`/`false`
- VITE_SHOW_SOURCEMAP:是否生成 sourcemap(默认关闭)
- VITE_LOGIN_URL:未登录跳转的登录页路径(路由拦截器使用)
示例(`env/.env.development`):
```env
VITE_APP_TITLE=小智
VITE_FALLBACK_LOCALE=zh-Hans
VITE_UNI_APPID=
VITE_WX_APPID=
VITE_SERVER_BASEURL=http://localhost:8080
VITE_DELETE_CONSOLE=false
VITE_SHOW_SOURCEMAP=false
VITE_LOGIN_URL=/pages/login/index
```
说明:
- `manifest.config.ts` 会从 `env` 读取标题、appid、语言等配置。
### 重要注意事项
⚠️ **部署前必须修改的配置项:**
1. **应用 ID 配置**
- `VITE_UNI_APPID`:需要在 [DCloud 开发者中心](https://dev.dcloud.net.cn/) 创建应用并获取 AppID
- `VITE_WX_APPID`:需要在 [微信公众平台](https://mp.weixin.qq.com/) 注册小程序并获取 AppID
2. **服务端地址**
- `VITE_SERVER_BASEURL`:修改为您的实际服务端地址
3. **应用信息**
- `VITE_APP_TITLE`:修改为您的应用名称
- 更新 `src/static/logo.png` 等图标资源
4. **其他配置**
- 检查 `manifest.config.ts` 中的应用配置信息
- 根据需要修改 `src/layouts/fg-tabbar/tabbarList.ts` 中的 tabbar 配置
### 详细操作指南
#### 1. 获取 uni-app AppID
![生成AppID](../../docs/images/manager-mobile/生成appid.png)
- 复制生成的 AppID 到环境变量 `VITE_UNI_APPID`
#### 2. 本地运行步骤
![本地运行](../../docs/images/manager-mobile/本地运行.png)
**App 本地调试:**
1. 用 HBuilderX 导入 `manager-mobile` 目录
2. 重新识别项目
3. 连接手机或使用模拟器进行真机调试
**项目识别问题解决:**
![重新识别项目](../../docs/images/manager-mobile/重新识别项目.png)
如果 HBuilderX 无法正确识别项目类型:
- 在项目根目录右键选择"重新识别项目类型"
- 确保项目被识别为 "uni-app" 项目
### 路由与鉴权
-`src/main.ts` 中注册了路由拦截插件 `routeInterceptor`
- 黑名单拦截:仅对配置为需要登录的页面进行校验(来源 `@/utils``getNeedLoginPages`)。
- 登录判断:基于用户信息(`pinia``useUserStore`),未登录将跳转到 `VITE_LOGIN_URL`,并附带重定向回原页面的参数。
### 网络请求
- 基于 `alova` + `@alova/adapter-uniapp`,统一在 `src/http/request/alova.ts` 创建实例。
- `baseURL` 读取环境配置(`getEnvBaseUrl`),可通过 `method.config.meta.domain` 动态切换域名。
- 认证:默认从本地 `token``uni.getStorageSync('token')`)注入 `Authorization` 头,缺失则重定向登录。
- 响应:统一处理 `statusCode !== 200` 的 HTTP 错误与业务 `code !== 0` 的错误;`401` 会清除 token 并跳转登录。
### 构建与发布
**微信小程序:**
1. 确保已配置正确的 `VITE_WX_APPID`
2. 运行 `pnpm build:mp`,产物在 `dist/build/mp-weixin`
3. 用微信开发者工具导入项目目录,并上传代码
4. 在微信公众平台提交审核
**Android & iOS App**
#### 3. App 打包发行步骤
**步骤一:准备打包**
![打包发行步骤1](../../docs/images/manager-mobile/打包发行步骤1.png)
1. 确保已配置正确的 `VITE_UNI_APPID`
2. 运行 `pnpm build:app`,产物在 `dist/build/app`
3. 用 HBuilderX 导入项目目录
4. 在 HBuilderX 中点击"发行" → "原生App-云打包"
**步骤二:配置打包参数**
![打包发行步骤2](../../docs/images/manager-mobile/打包发行步骤2.png)
1. **应用图标和启动图**:上传应用图标和启动页面图片
2. **应用版本号**:设置版本号和版本名称
3. **签名证书**
- Android:上传 keystore 证书文件
- iOS:配置开发者证书和描述文件
4. **包名配置**:设置应用的包名(Bundle ID)
5. **打包类型**:选择测试包或正式包
6. 点击"打包"开始云打包流程
**发布到应用商店:**
- **Android**:将生成的 APK 文件上传到各大 Android 应用市场
- **iOS**:将生成的 IPA 文件通过 App Store Connect 上传到 App Store(需要 Apple 开发者账号)
### 约定与工程化
- 页面与分包:由 `@uni-helper/vite-plugin-uni-pages``pages.config.ts` 统一生成;tabbar 配置在 `src/layouts/fg-tabbar/tabbarList.ts`
- 组件与 hooks 自动导入:见 `vite.config.ts``unplugin-auto-import``@uni-helper/vite-plugin-uni-components`
- 样式:使用 UnoCSS 与 `src/style/index.scss`
- 状态管理:`pinia` + `pinia-plugin-persistedstate`
- 代码规范:内置 `eslint``husky``lint-staged`,提交前自动格式化(`lint-staged`)。
### 常用脚本
```bash
# 开发
pnpm dev:mp # 等价 dev:mp-weixin
# 构建
pnpm build:mp # 等价 build:mp-weixin
# 其他
pnpm type-check
pnpm lint && pnpm lint:fix
```
### License
MIT
+19
View File
@@ -0,0 +1,19 @@
VITE_APP_TITLE = '小智'
VITE_APP_PORT = 9000
VITE_UNI_APPID = '__UNI__36A515E'
VITE_WX_APPID = 'wxa2abb91f64032a2b'
# h5部署网站的base,配置到 manifest.config.ts 里的 h5.router.base
VITE_APP_PUBLIC_BASE=/
# 登录页面
VITE_LOGIN_URL = '/pages/login/index'
# 第一个请求地址
VITE_SERVER_BASEURL = 'https://2662r3426b.vicp.fun/xiaozhi'
VITE_UPLOAD_BASEURL = '/otaMag/upload'
# h5是否需要配置代理
VITE_APP_PROXY=true
VITE_APP_PROXY_PREFIX = '/xiaozhi'
+6
View File
@@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = true
+6
View File
@@ -0,0 +1,6 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = true
# 是否开启sourcemap
VITE_SHOW_SOURCEMAP = false
+4
View File
@@ -0,0 +1,4 @@
# 变量必须以 VITE_ 为前缀才能暴露给外部读取
NODE_ENV = 'development'
# 是否去除console 和 debugger
VITE_DELETE_CONSOLE = false
+43
View File
@@ -0,0 +1,43 @@
import uniHelper from '@uni-helper/eslint-config'
export default uniHelper({
unocss: true,
vue: true,
markdown: false,
ignores: [
'src/uni_modules/',
'dist',
// unplugin-auto-import 生成的类型文件,每次提交都改变,所以加入这里吧,与 .gitignore 配合使用
'auto-import.d.ts',
// vite-plugin-uni-pages 生成的类型文件,每次切换分支都一堆不同的,所以直接 .gitignore
'uni-pages.d.ts',
// 插件生成的文件
'src/pages.json',
'src/manifest.json',
// 忽略自动生成文件
'src/service/app/**',
],
rules: {
'no-console': 'off',
'no-unused-vars': 'off',
'vue/no-unused-refs': 'off',
'unused-imports/no-unused-vars': 'off',
'eslint-comments/no-unlimited-disable': 'off',
'jsdoc/check-param-names': 'off',
'jsdoc/require-returns-description': 'off',
'ts/no-empty-object-type': 'off',
'no-extend-native': 'off',
},
formatters: {
/**
* Format CSS, LESS, SCSS files, also the `<style>` blocks in Vue
* By default uses Prettier
*/
css: true,
/**
* Format HTML files
* By default uses Prettier
*/
html: true,
},
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html build-time="%BUILD_TIME%">
<head>
<meta charset="UTF-8" />
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon" />
<script>
var coverSupport =
'CSS' in window &&
typeof CSS.supports === 'function' &&
(CSS.supports('top: env(a)') || CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') +
'" />',
)
</script>
<title>unibest</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
import path from 'node:path'
import process from 'node:process'
// manifest.config.ts
import { defineManifestConfig } from '@uni-helper/vite-plugin-uni-manifest'
import { loadEnv } from 'vite'
// 手动解析命令行参数获取 mode
function getMode() {
const args = process.argv.slice(2)
const modeFlagIndex = args.findIndex(arg => arg === '--mode')
return modeFlagIndex !== -1 ? args[modeFlagIndex + 1] : args[0] === 'build' ? 'production' : 'development' // 默认 development
}
// 获取环境变量的范例
const env = loadEnv(getMode(), path.resolve(process.cwd(), 'env'))
const {
VITE_APP_TITLE,
VITE_UNI_APPID,
VITE_WX_APPID,
VITE_APP_PUBLIC_BASE,
VITE_FALLBACK_LOCALE,
} = env
export default defineManifestConfig({
'name': VITE_APP_TITLE,
'appid': VITE_UNI_APPID,
'description': '',
'versionName': '1.0.0',
'versionCode': '100',
'transformPx': false,
'locale': VITE_FALLBACK_LOCALE, // 'zh-Hans'
'h5': {
router: {
// base: VITE_APP_PUBLIC_BASE,
},
},
/* 5+App特有相关 */
'app-plus': {
usingComponents: true,
nvueStyleCompiler: 'uni-app',
compilerVersion: 3,
compatible: {
ignoreVersion: true,
},
splashscreen: {
alwaysShowBeforeRender: true,
waiting: true,
autoclose: true,
delay: 0,
},
/* 模块配置 */
modules: {},
/* 应用发布信息 */
distribute: {
/* android打包配置 */
android: {
minSdkVersion: 30,
targetSdkVersion: 30,
abiFilters: ['armeabi-v7a', 'arm64-v8a'],
permissions: [
'<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>',
'<uses-permission android:name="android.permission.VIBRATE"/>',
'<uses-permission android:name="android.permission.READ_LOGS"/>',
'<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>',
'<uses-feature android:name="android.hardware.camera.autofocus"/>',
'<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>',
'<uses-permission android:name="android.permission.CAMERA"/>',
'<uses-permission android:name="android.permission.GET_ACCOUNTS"/>',
'<uses-permission android:name="android.permission.READ_PHONE_STATE"/>',
'<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>',
'<uses-permission android:name="android.permission.WAKE_LOCK"/>',
'<uses-permission android:name="android.permission.FLASHLIGHT"/>',
'<uses-feature android:name="android.hardware.camera"/>',
'<uses-permission android:name="android.permission.WRITE_SETTINGS"/>',
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>',
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>',
],
},
/* ios打包配置 */
ios: {},
/* SDK配置 */
sdkConfigs: {},
/* 图标配置 */
icons: {
android: {
hdpi: 'unpackage/res/icons/72x72.png',
xhdpi: 'unpackage/res/icons/96x96.png',
xxhdpi: 'unpackage/res/icons/144x144.png',
xxxhdpi: 'unpackage/res/icons/192x192.png',
},
ios: {
appstore: 'unpackage/res/icons/1024x1024.png',
ipad: {
'app': 'unpackage/res/icons/76x76.png',
'app@2x': 'unpackage/res/icons/152x152.png',
'notification': 'unpackage/res/icons/20x20.png',
'notification@2x': 'unpackage/res/icons/40x40.png',
'proapp@2x': 'unpackage/res/icons/167x167.png',
'settings': 'unpackage/res/icons/29x29.png',
'settings@2x': 'unpackage/res/icons/58x58.png',
'spotlight': 'unpackage/res/icons/40x40.png',
'spotlight@2x': 'unpackage/res/icons/80x80.png',
},
iphone: {
'app@2x': 'unpackage/res/icons/120x120.png',
'app@3x': 'unpackage/res/icons/180x180.png',
'notification@2x': 'unpackage/res/icons/40x40.png',
'notification@3x': 'unpackage/res/icons/60x60.png',
'settings@2x': 'unpackage/res/icons/58x58.png',
'settings@3x': 'unpackage/res/icons/87x87.png',
'spotlight@2x': 'unpackage/res/icons/80x80.png',
'spotlight@3x': 'unpackage/res/icons/120x120.png',
},
},
},
},
},
/* 快应用特有相关 */
'quickapp': {},
/* 小程序特有相关 */
'mp-weixin': {
appid: VITE_WX_APPID,
setting: {
urlCheck: false,
// 是否启用 ES6 转 ES5
es6: true,
minified: true,
},
optimization: {
subPackages: true,
},
usingComponents: true,
// __usePrivacyCheck__: true,
permission: {
'scope.userLocation': {
desc: 'WiFi配网功能需要获取位置权限',
},
},
requiredPrivateInfos: ['getLocation'],
},
'mp-alipay': {
usingComponents: true,
styleIsolation: 'shared',
},
'mp-baidu': {
usingComponents: true,
},
'mp-toutiao': {
usingComponents: true,
},
'uniStatistics': {
enable: false,
},
'vueVersion': '3',
})
+161
View File
@@ -0,0 +1,161 @@
{
"name": "xiaozhi-mobile-admin",
"type": "commonjs",
"version": "3.4.0",
"unibest-version": "3.4.0",
"packageManager": "pnpm@10.10.0",
"description": "xiaozhi-esp32-server的移动端管理后台",
"generate-time": "2025-08-04",
"author": {
"name": "huangjunsen",
"zhName": "黄俊森",
"email": "huangjunsen@xiaozhi.com",
"github": "https://github.com/huangjunsen0406",
"gitee": "https://gitee.com/huang-jun-sen"
},
"license": "MIT",
"homepage": "https://github.com/xinnan-tech/xiaozhi-esp32-server",
"repository": "https://github.com/xinnan-tech/xiaozhi-esp32-server",
"bugs": {
"url": "https://github.com/xinnan-tech/xiaozhi-esp32-server/issues"
},
"engines": {
"node": ">=18",
"pnpm": ">=7.30"
},
"scripts": {
"preinstall": "npx only-allow pnpm",
"uvm": "npx @dcloudio/uvm@latest",
"uvm-rm": "node ./scripts/postupgrade.js",
"postuvm": "echo upgrade uni-app success!",
"dev:app": "uni -p app",
"dev:app-android": "uni -p app-android",
"dev:app-ios": "uni -p app-ios",
"dev:custom": "uni -p",
"dev": "uni",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp": "uni -p mp-weixin",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-jd": "uni -p mp-jd",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:mp-xhs": "uni -p mp-xhs",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build:app": "uni build -p app",
"build:app-android": "uni build -p app-android",
"build:app-ios": "uni build -p app-ios",
"build:custom": "uni build -p",
"build:h5": "uni build",
"build": "uni build",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp": "uni build -p mp-weixin",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-jd": "uni build -p mp-jd",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-weixin": "uni build -p mp-weixin",
"build:mp-xhs": "uni build -p mp-xhs",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit",
"openapi-ts-request": "openapi-ts",
"prepare": "git init && husky",
"lint": "eslint",
"lint:fix": "eslint --fix"
},
"dependencies": {
"@alova/adapter-uniapp": "^2.0.14",
"@alova/shared": "^1.3.1",
"@dcloudio/uni-app": "3.0.0-4060620250520001",
"@dcloudio/uni-app-harmony": "3.0.0-4060620250520001",
"@dcloudio/uni-app-plus": "3.0.0-4060620250520001",
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-alipay": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-baidu": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-harmony": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-jd": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-lark": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-qq": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-toutiao": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
"@tanstack/vue-query": "^5.62.16",
"abortcontroller-polyfill": "^1.7.8",
"alova": "^3.3.3",
"dayjs": "1.11.10",
"js-cookie": "^3.0.5",
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"vue": "^3.4.21",
"wot-design-uni": "^1.9.1",
"z-paging": "2.8.7"
},
"devDependencies": {
"@alova/wormhole": "^1.0.8",
"@antfu/eslint-config": "^4.15.0",
"@commitlint/cli": "^19.8.1",
"@commitlint/config-conventional": "^19.8.1",
"@dcloudio/types": "^3.4.8",
"@dcloudio/uni-automator": "3.0.0-4060620250520001",
"@dcloudio/uni-cli-shared": "3.0.0-4060620250520001",
"@dcloudio/uni-stacktracey": "3.0.0-4060620250520001",
"@dcloudio/uni-uts-v1": "3.0.0-4060620250520001",
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@esbuild/darwin-arm64": "0.20.2",
"@esbuild/darwin-x64": "0.20.2",
"@iconify-json/carbon": "^1.2.4",
"@rollup/rollup-darwin-x64": "^4.28.0",
"@types/node": "^20.17.9",
"@types/wechat-miniprogram": "^3.4.8",
"@uni-helper/eslint-config": "^0.4.0",
"@uni-helper/uni-types": "1.0.0-alpha.3",
"@uni-helper/unocss-preset-uni": "^0.2.11",
"@uni-helper/vite-plugin-uni-components": "0.2.0",
"@uni-helper/vite-plugin-uni-layouts": "0.1.10",
"@uni-helper/vite-plugin-uni-manifest": "0.2.8",
"@uni-helper/vite-plugin-uni-pages": "0.2.28",
"@uni-helper/vite-plugin-uni-platform": "0.0.4",
"@uni-ku/bundle-optimizer": "^1.3.3",
"@unocss/eslint-plugin": "^66.2.3",
"@unocss/preset-legacy-compat": "^0.59.4",
"@vue/runtime-core": "^3.4.21",
"@vue/tsconfig": "^0.1.3",
"autoprefixer": "^10.4.20",
"eslint": "^9.29.0",
"eslint-plugin-format": "^1.0.1",
"husky": "^9.1.7",
"lint-staged": "^15.2.10",
"openapi-ts-request": "^1.1.2",
"postcss": "^8.4.49",
"postcss-html": "^1.7.0",
"postcss-scss": "^4.0.9",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "1.77.8",
"typescript": "^5.7.2",
"unocss": "65.4.2",
"unplugin-auto-import": "^0.17.8",
"vite": "5.2.8",
"vite-plugin-restart": "^0.4.2",
"vue-tsc": "^2.2.10"
},
"resolutions": {
"bin-wrapper": "npm:bin-wrapper-china"
},
"lint-staged": {
"*": "eslint --fix"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { defineUniPages } from '@uni-helper/vite-plugin-uni-pages'
import { tabBar } from './src/layouts/fg-tabbar/tabbarList'
export default defineUniPages({
globalStyle: {
navigationStyle: 'default',
navigationBarTitleText: '小智',
navigationBarBackgroundColor: '#f8f8f8',
navigationBarTextStyle: 'black',
backgroundColor: '#FFFFFF',
},
easycom: {
autoscan: true,
custom: {
'^fg-(.*)': '@/components/fg-$1/fg-$1.vue',
'^wd-(.*)': 'wot-design-uni/components/wd-$1/wd-$1.vue',
'^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)':
'z-paging/components/z-paging$1/z-paging$1.vue',
},
},
// tabbar 的配置统一在 "./src/layouts/fg-tabbar/tabbarList.ts" 文件中
tabBar: tabBar as any,
})
@@ -0,0 +1,13 @@
diff --git a/dist/uni-h5.es.js b/dist/uni-h5.es.js
index 7421bad97d94ad34a3d4d94292a9ee9071430662..19c6071ee4036ceb8d1cfa09030e471c002d2cda 100644
--- a/dist/uni-h5.es.js
+++ b/dist/uni-h5.es.js
@@ -23410,7 +23410,7 @@ function useShowTabBar(emit2) {
const tabBar2 = useTabBar();
const showTabBar2 = computed(() => route.meta.isTabBar && tabBar2.shown);
updateCssVar({
- "--tab-bar-height": tabBar2.height
+ "--tab-bar-height": tabBar2?.height || 0
});
return showTabBar2;
}
+14866
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
packages:
- '**'
- '!node_modules'
patchedDependencies:
'@dcloudio/uni-h5': patches/@dcloudio__uni-h5.patch
@@ -0,0 +1,35 @@
// # 执行 `pnpm upgrade` 后会升级 `uniapp` 相关依赖
// # 在升级完后,会自动添加很多无用依赖,这需要删除以减小依赖包体积
// # 只需要执行下面的命令即可
const { exec } = require('node:child_process')
// 定义要执行的命令
const dependencies = [
'@dcloudio/uni-app-harmony',
// TODO: 如果不需要某个平台的小程序,请手动删除或注释掉
'@dcloudio/uni-mp-alipay',
'@dcloudio/uni-mp-baidu',
'@dcloudio/uni-mp-jd',
'@dcloudio/uni-mp-kuaishou',
'@dcloudio/uni-mp-lark',
'@dcloudio/uni-mp-qq',
'@dcloudio/uni-mp-toutiao',
'@dcloudio/uni-mp-xhs',
'@dcloudio/uni-quickapp-webview',
// i18n模板要注释掉下面的
'vue-i18n',
]
// 使用exec执行命令
exec(`pnpm un ${dependencies.join(' ')}`, (error, stdout, stderr) => {
if (error) {
// 如果有错误,打印错误信息
console.error(`执行出错: ${error}`)
return
}
// 打印正常输出
console.log(`stdout: ${stdout}`)
// 如果有错误输出,也打印出来
console.error(`stderr: ${stderr}`)
})
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onHide, onLaunch, onShow } from '@dcloudio/uni-app'
import { usePageAuth } from '@/hooks/usePageAuth'
import { useConfigStore } from '@/store'
import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'
usePageAuth()
const configStore = useConfigStore()
onLaunch(() => {
console.log('App Launch')
// 获取公共配置
configStore.fetchPublicConfig().catch((error) => {
console.error('获取公共配置失败:', error)
})
})
onShow(() => {
console.log('App Show')
})
onHide(() => {
console.log('App Hide')
})
</script>
<style lang="scss">
swiper,
scroll-view {
flex: 1;
height: 100%;
overflow: hidden;
}
image {
width: 100%;
height: 100%;
vertical-align: middle;
}
</style>
+185
View File
@@ -0,0 +1,185 @@
import type {
Agent,
AgentCreateData,
AgentDetail,
ModelOption,
RoleTemplate,
} from './types'
import { http } from '@/http/request/alova'
// 获取智能体详情
export function getAgentDetail(id: string) {
return http.Get<AgentDetail>(`/agent/${id}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取角色模板列表
export function getRoleTemplates() {
return http.Get<RoleTemplate[]>('/agent/template', {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取模型选项
export function getModelOptions(modelType: string, modelName: string = '') {
return http.Get<ModelOption[]>('/models/names', {
params: {
modelType,
modelName,
},
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取智能体列表
export function getAgentList() {
return http.Get<Agent[]>('/agent/list', {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 创建智能体
export function createAgent(data: AgentCreateData) {
return http.Post<string>('/agent', data, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
// 删除智能体
export function deleteAgent(id: string) {
return http.Delete(`/agent/${id}`, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
// 获取TTS音色列表
export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, {
params: {
voiceName,
},
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 更新智能体
export function updateAgent(id: string, data: Partial<AgentDetail>) {
return http.Put(`/agent/${id}`, data, {
meta: {
ignoreAuth: false,
toast: true,
},
cacheFor: {
expire: 0,
},
})
}
// 获取插件列表
export function getPluginFunctions() {
return http.Get<any[]>(`/models/provider/plugin/names`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取mcp接入点
export function getMcpAddress(agentId: string) {
return http.Get<string>(`/agent/mcp/address/${agentId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取mcp工具
export function getMcpTools(agentId: string) {
return http.Get<string[]>(`/agent/mcp/tools/${agentId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取声纹列表
export function getVoicePrintList(agentId: string) {
return http.Get<any[]>(`/agent/voice-print/list/${agentId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取语音对话记录
export function getChatHistoryUser(agentId: string) {
return http.Get<any[]>(`/agent/${agentId}/chat-history/user`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 新增声纹说话人
export function createVoicePrint(data: { agentId: string, audioId: string, sourceName: string, introduce: string }) {
return http.Post('/agent/voice-print', data, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
+107
View File
@@ -0,0 +1,107 @@
// 智能体列表数据类型
export interface Agent {
id: string
agentName: string
ttsModelName: string
ttsVoiceName: string
llmModelName: string
vllmModelName: string
memModelId: string
systemPrompt: string
summaryMemory: string | null
lastConnectedAt: string | null
deviceCount: number
}
// 智能体创建数据类型
export interface AgentCreateData {
agentName: string
}
// 智能体详情数据类型
export interface AgentDetail {
id: string
userId: string
agentCode: string
agentName: string
asrModelId: string
vadModelId: string
llmModelId: string
vllmModelId: string
ttsModelId: string
ttsVoiceId: string
memModelId: string
intentModelId: string
chatHistoryConf: number
systemPrompt: string
summaryMemory: string
langCode: string
language: string
sort: number
creator: string
createdAt: string
updater: string
updatedAt: string
functions: AgentFunction[]
}
export interface AgentFunction {
id?: string
agentId?: string
pluginId: string
paramInfo: Record<string, string | number | boolean> | null
}
// 角色模板数据类型
export interface RoleTemplate {
id: string
agentCode: string
agentName: string
asrModelId: string
vadModelId: string
llmModelId: string
vllmModelId: string
ttsModelId: string
ttsVoiceId: string
memModelId: string
intentModelId: string
chatHistoryConf: number
systemPrompt: string
summaryMemory: string
langCode: string
language: string
sort: number
creator: string
createdAt: string
updater: string
updatedAt: string
}
// 模型选项数据类型
export interface ModelOption {
id: string
modelName: string
}
export interface PluginField {
key: string
type: string
label: string
default: string
selected?: boolean
editing?: boolean
}
export interface PluginDefinition {
id: string
modelType: string
providerCode: string
name: string
fields: PluginField[] // 注意:原始是字符串,需要先 JSON.parse
sort: number
updater: string
updateDate: string
creator: string
createDate: string
[key: string]: any
}
+127
View File
@@ -0,0 +1,127 @@
import { http } from '@/http/request/alova'
// 登录接口数据类型
export interface LoginData {
username: string
password: string
captcha: string
captchaId: string
areaCode?: string
mobile?: string
}
// 登录响应数据类型
export interface LoginResponse {
token: string
expire: number
clientHash: string
}
// 验证码响应数据类型
export interface CaptchaResponse {
captchaId: string
captchaImage: string
}
// 获取验证码
export function getCaptcha(uuid: string) {
return http.Get<string>('/user/captcha', {
params: { uuid },
meta: {
ignoreAuth: true,
toast: false,
},
})
}
// 用户登录
export function login(data: LoginData) {
return http.Post<LoginResponse>('/user/login', data, {
meta: {
ignoreAuth: true,
toast: true,
},
})
}
// 用户信息响应数据类型
export interface UserInfo {
id: number
username: string
realName: string
email: string
mobile: string
status: number
superAdmin: number
}
// 公共配置响应数据类型
export interface PublicConfig {
enableMobileRegister: boolean
version: string
year: string
allowUserRegister: boolean
mobileAreaList: Array<{
name: string
key: string
}>
beianIcpNum: string
beianGaNum: string
name: string
}
// 获取用户信息
export function getUserInfo() {
return http.Get<UserInfo>('/user/info', {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取公共配置
export function getPublicConfig() {
return http.Get<PublicConfig>('/user/pub-config', {
meta: {
ignoreAuth: true,
toast: false,
},
})
}
// 注册数据类型
export interface RegisterData {
username: string
password: string
confirmPassword: string
captcha: string
captchaId: string
areaCode: string
mobile: string
mobileCaptcha: string
}
// 发送短信验证码
export function sendSmsCode(data: {
phone: string
captcha: string
captchaId: string
}) {
return http.Post('/user/smsVerification', data, {
meta: {
ignoreAuth: true,
toast: false,
},
})
}
// 用户注册
export function register(data: RegisterData) {
return http.Post('/user/register', data, {
meta: {
ignoreAuth: true,
toast: true,
},
})
}
@@ -0,0 +1,60 @@
import type {
ChatMessage,
ChatSessionsResponse,
GetSessionsParams,
} from './types'
import { http } from '@/http/request/alova'
/**
* 获取聊天会话列表
* @param agentId 智能体ID
* @param params 分页参数
*/
export function getChatSessions(agentId: string, params: GetSessionsParams) {
return http.Get<ChatSessionsResponse>(`/agent/${agentId}/sessions`, {
params,
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
/**
* 获取聊天记录详情
* @param agentId 智能体ID
* @param sessionId 会话ID
*/
export function getChatHistory(agentId: string, sessionId: string) {
return http.Get<ChatMessage[]>(`/agent/${agentId}/chat-history/${sessionId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
/**
* 获取音频下载ID
* @param audioId 音频ID
*/
export function getAudioId(audioId: string) {
return http.Post<string>(`/agent/audio/${audioId}`, {}, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
/**
* 获取音频播放地址
* @param downloadId 下载ID
*/
export function getAudioPlayUrl(downloadId: string) {
// 根据需求文档,这个是直接返回二进制的,所以我们直接构造URL
return `/agent/play/${downloadId}`
}
@@ -0,0 +1,2 @@
export * from './chat-history'
export * from './types'
@@ -0,0 +1,38 @@
// 聊天会话列表项
export interface ChatSession {
sessionId: string
createdAt: string
chatCount: number
}
// 聊天会话列表响应
export interface ChatSessionsResponse {
total: number
list: ChatSession[]
}
// 聊天消息
export interface ChatMessage {
createdAt: string
chatType: 1 | 2 // 1是用户,2是AI
content: string
audioId: string | null
macAddress: string
}
// 用户消息内容(需要解析JSON)
export interface UserMessageContent {
speaker: string
content: string
}
// 获取聊天会话列表参数
export interface GetSessionsParams {
page: number
limit: number
}
// 音频播放相关
export interface AudioResponse {
data: string // 音频下载ID
}
@@ -0,0 +1,55 @@
import type { Device, FirmwareType } from './types'
import { http } from '@/http/request/alova'
/**
* 获取设备类型列表
*/
export function getFirmwareTypes() {
return http.Get<FirmwareType[]>('/admin/dict/data/type/FIRMWARE_TYPE')
}
/**
* 获取绑定设备列表
* @param agentId 智能体ID
*/
export function getBindDevices(agentId: string) {
return http.Get<Device[]>(`/device/bind/${agentId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
/**
* 添加设备
* @param agentId 智能体ID
* @param code 验证码
*/
export function bindDevice(agentId: string, code: string) {
return http.Post(`/device/bind/${agentId}/${code}`, null)
}
/**
* 设置设备OTA升级开关
* @param deviceId 设备ID (MAC地址)
* @param autoUpdate 是否自动升级 0|1
*/
export function updateDeviceAutoUpdate(deviceId: string, autoUpdate: number) {
return http.Put(`/device/update/${deviceId}`, {
autoUpdate,
})
}
/**
* 解绑设备
* @param deviceId 设备ID (MAC地址)
*/
export function unbindDevice(deviceId: string) {
return http.Post('/device/unbind', {
deviceId,
})
}
@@ -0,0 +1,2 @@
export * from './device'
export * from './types'
@@ -0,0 +1,21 @@
export interface FirmwareType {
name: string
key: string
}
export interface Device {
id: string
userId: string
macAddress: string
lastConnectedAt: string
autoUpdate: number
board: string
alias?: string
agentId: string
appVersion: string
sort: number
updater?: string
updateDate: string
creator: string
createDate: string
}
@@ -0,0 +1,2 @@
export * from './types'
export * from './voiceprint'
@@ -0,0 +1,29 @@
// 声纹信息响应类型
export interface VoicePrint {
id: string
audioId: string
sourceName: string
introduce: string
createDate: string
}
// 语音对话记录类型
export interface ChatHistory {
content: string
audioId: string
}
// 创建说话人数据类型
export interface CreateSpeakerData {
agentId: string
audioId: string
sourceName: string
introduce: string
}
// 通用响应类型
export interface ApiResponse<T = any> {
code: number
msg: string
data: T
}
@@ -0,0 +1,62 @@
import type {
ChatHistory,
CreateSpeakerData,
VoicePrint,
} from './types'
import { http } from '@/http/request/alova'
// 获取声纹列表
export function getVoicePrintList(agentId: string) {
return http.Get<VoicePrint[]>(`/agent/voice-print/list/${agentId}`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 获取语音对话记录(用于选择声纹向量)
export function getChatHistory(agentId: string) {
return http.Get<ChatHistory[]>(`/agent/${agentId}/chat-history/user`, {
meta: {
ignoreAuth: false,
toast: false,
},
cacheFor: {
expire: 0,
},
})
}
// 新增说话人
export function createVoicePrint(data: CreateSpeakerData) {
return http.Post<null>('/agent/voice-print', data, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
// 删除声纹
export function deleteVoicePrint(id: string) {
return http.Delete<null>(`/agent/voice-print/${id}`, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
// 更新声纹信息
export function updateVoicePrint(data: VoicePrint) {
return http.Put<null>('/agent/voice-print', data, {
meta: {
ignoreAuth: false,
toast: true,
},
})
}
@@ -0,0 +1,131 @@
<script setup lang="ts">
interface TabItem {
label: string
value: string | number
icon: string
activeIcon: string
}
interface Props {
tabList: TabItem[]
modelValue?: string | number
}
interface Emits {
(e: 'update:modelValue', value: string | number): void
(e: 'change', item: TabItem, index: number): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
})
const emit = defineEmits<Emits>()
const activeValue = computed(() => {
return props.modelValue || (props.tabList[0]?.value ?? '')
})
function handleTabClick(item: TabItem, index: number) {
emit('update:modelValue', item.value)
emit('change', item, index)
}
</script>
<template>
<view class="custom-tabs">
<view
v-for="(item, index) in tabList"
:key="item.value"
class="tab-item"
:class="{ active: activeValue === item.value }"
@click="handleTabClick(item, index)"
>
<view class="tab-icon">
<image
:src="activeValue === item.value ? item.activeIcon : item.icon"
class="icon-img"
mode="aspectFit"
/>
</view>
<view class="tab-text">
{{ item.label }}
</view>
<view v-if="activeValue === item.value" class="tab-indicator" />
</view>
</view>
</template>
<style scoped>
.custom-tabs {
display: flex;
align-items: center;
justify-content: space-around;
background-color: #ffffff;
padding: 0 16rpx;
border-top: 1rpx solid #eeeeee;
box-sizing: border-box;
}
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16rpx 12rpx 12rpx;
flex: 1;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-icon {
width: 48rpx;
height: 48rpx;
margin-bottom: 8rpx;
display: flex;
align-items: center;
justify-content: center;
}
.icon-img {
width: 100%;
height: 100%;
}
.tab-text {
font-size: 24rpx;
color: #9d9ea3;
line-height: 1.2;
text-align: center;
transition: color 0.2s ease;
}
.tab-item.active .tab-text {
color: #336cff;
font-weight: 500;
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 48rpx;
height: 4rpx;
background-color: #336cff;
border-radius: 2rpx;
}
/* 适配不同尺寸屏幕 */
@media (max-width: 375px) {
.tab-text {
font-size: 22rpx;
}
.tab-icon {
width: 44rpx;
height: 44rpx;
}
}
</style>
+34
View File
@@ -0,0 +1,34 @@
/// <reference types="vite/client" />
/// <reference types="vite-svg-loader" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
/** 网站标题,应用名称 */
readonly VITE_APP_TITLE: string
/** 服务端口号 */
readonly VITE_SERVER_PORT: string
/** 后台接口地址 */
readonly VITE_SERVER_BASEURL: string
/** H5是否需要代理 */
readonly VITE_APP_PROXY: 'true' | 'false'
/** H5是否需要代理,需要的话有个前缀 */
readonly VITE_APP_PROXY_PREFIX: string // 一般是/api
/** 上传图片地址 */
readonly VITE_UPLOAD_BASEURL: string
/** 是否清除console */
readonly VITE_DELETE_CONSOLE: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const __VITE_APP_PROXY__: 'true' | 'false'
declare const __UNI_PLATFORM__: 'app' | 'h5' | 'mp-alipay' | 'mp-baidu' | 'mp-kuaishou' | 'mp-lark' | 'mp-qq' | 'mp-tiktok' | 'mp-weixin' | 'mp-xiaochengxu'
@@ -0,0 +1,50 @@
import { onLoad } from '@dcloudio/uni-app'
import { useUserStore } from '@/store'
import { needLoginPages as _needLoginPages, getNeedLoginPages } from '@/utils'
const loginRoute = import.meta.env.VITE_LOGIN_URL
const isDev = import.meta.env.DEV
function isLogined() {
const userStore = useUserStore()
return !!userStore.userInfo.username
}
// 检查当前页面是否需要登录
export function usePageAuth() {
onLoad((options) => {
// 获取当前页面路径
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const currentPath = `/${currentPage.route}`
// 获取需要登录的页面列表
let needLoginPages: string[] = []
if (isDev) {
needLoginPages = getNeedLoginPages()
}
else {
needLoginPages = _needLoginPages
}
// 检查当前页面是否需要登录
const isNeedLogin = needLoginPages.includes(currentPath)
if (!isNeedLogin) {
return
}
const hasLogin = isLogined()
if (hasLogin) {
return true
}
// 构建重定向URL
const queryString = Object.entries(options || {})
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
.join('&')
const currentFullPath = queryString ? `${currentPath}?${queryString}` : currentPath
const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(currentFullPath)}`
// 重定向到登录页
uni.redirectTo({ url: redirectRoute })
})
}
@@ -0,0 +1,51 @@
import type { Ref } from 'vue'
interface IUseRequestOptions<T> {
/** 是否立即执行 */
immediate?: boolean
/** 初始化数据 */
initialData?: T
}
interface IUseRequestReturn<T> {
loading: Ref<boolean>
error: Ref<boolean | Error>
data: Ref<T | undefined>
run: () => Promise<T | undefined>
}
/**
* useRequest是一个定制化的请求钩子,用于处理异步请求和响应。
* @param func 一个执行异步请求的函数,返回一个包含响应数据的Promise。
* @param options 包含请求选项的对象 {immediate, initialData}。
* @param options.immediate 是否立即执行请求,默认为false。
* @param options.initialData 初始化数据,默认为undefined。
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
*/
export default function useRequest<T>(
func: () => Promise<IResData<T>>,
options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async () => {
loading.value = true
return func()
.then((res) => {
data.value = res.data
error.value = false
return data.value
})
.catch((err) => {
error.value = err
throw err
})
.finally(() => {
loading.value = false
})
}
options.immediate && run()
return { loading, error, data, run }
}
+160
View File
@@ -0,0 +1,160 @@
import { ref } from 'vue'
import { getEnvBaseUploadUrl } from '@/utils'
const VITE_UPLOAD_BASEURL = `${getEnvBaseUploadUrl()}`
type TfileType = 'image' | 'file'
type TImage = 'png' | 'jpg' | 'jpeg' | 'webp' | '*'
type TFile = 'doc' | 'docx' | 'ppt' | 'zip' | 'xls' | 'xlsx' | 'txt' | TImage
interface TOptions<T extends TfileType> {
formData?: Record<string, any>
maxSize?: number
accept?: T extends 'image' ? TImage[] : TFile[]
fileType?: T
success?: (params: any) => void
error?: (err: any) => void
}
export default function useUpload<T extends TfileType>(options: TOptions<T> = {} as TOptions<T>) {
const {
formData = {},
maxSize = 5 * 1024 * 1024,
accept = ['*'],
fileType = 'image',
success,
error: onError,
} = options
const loading = ref(false)
const error = ref<Error | null>(null)
const data = ref<any>(null)
const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string, size: number }) => {
if (size > maxSize) {
uni.showToast({
title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
icon: 'none',
})
return
}
// const fileExtension = file?.tempFiles?.name?.split('.').pop()?.toLowerCase()
// const isTypeValid = accept.some((type) => type === '*' || type.toLowerCase() === fileExtension)
// if (!isTypeValid) {
// uni.showToast({
// title: `仅支持 ${accept.join(', ')} 格式的文件`,
// icon: 'none',
// })
// return
// }
loading.value = true
uploadFile({
tempFilePath,
formData,
onSuccess: (res) => {
const { data: _data } = JSON.parse(res)
data.value = _data
// console.log('上传成功', res)
success?.(_data)
},
onError: (err) => {
error.value = err
onError?.(err)
},
onComplete: () => {
loading.value = false
},
})
}
const run = () => {
// 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替。
// 微信小程序在2023年10月17日之后,使用本API需要配置隐私协议
const chooseFileOptions = {
count: 1,
success: (res: any) => {
console.log('File selected successfully:', res)
// 小程序中res:{errMsg: "chooseImage:ok", tempFiles: [{fileType: "image", size: 48976, tempFilePath: "http://tmp/5iG1WpIxTaJf3ece38692a337dc06df7eb69ecb49c6b.jpeg"}]}
// h5中res:{errMsg: "chooseImage:ok", tempFilePaths: "blob:http://localhost:9000/f74ab6b8-a14d-4cb6-a10d-fcf4511a0de5", tempFiles: [File]}
// h5的File有以下字段:{name: "girl.jpeg", size: 48976, type: "image/jpeg"}
// App中res:{errMsg: "chooseImage:ok", tempFilePaths: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", tempFiles: [File]}
// App的File有以下字段:{path: "file:///Users/feige/xxx/gallery/1522437259-compressed-IMG_0006.jpg", size: 48976}
let tempFilePath = ''
let size = 0
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath
size = res.tempFiles[0].size
// #endif
// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0]
size = res.tempFiles[0].size
// #endif
handleFileChoose({ tempFilePath, size })
},
fail: (err: any) => {
console.error('File selection failed:', err)
error.value = err
onError?.(err)
},
}
if (fileType === 'image') {
// #ifdef MP-WEIXIN
uni.chooseMedia({
...chooseFileOptions,
mediaType: ['image'],
})
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage(chooseFileOptions)
// #endif
}
else {
uni.chooseFile({
...chooseFileOptions,
type: 'all',
})
}
}
return { loading, error, data, run }
}
async function uploadFile({
tempFilePath,
formData,
onSuccess,
onError,
onComplete,
}: {
tempFilePath: string
formData: Record<string, any>
onSuccess: (data: any) => void
onError: (err: any) => void
onComplete: () => void
}) {
uni.uploadFile({
url: VITE_UPLOAD_BASEURL,
filePath: tempFilePath,
name: 'file',
formData,
success: (uploadFileRes) => {
try {
const data = uploadFileRes.data
onSuccess(data)
}
catch (err) {
onError(err)
}
},
fail: (err) => {
console.error('Upload failed:', err)
onError(err)
},
complete: onComplete,
})
}
+36
View File
@@ -0,0 +1,36 @@
# 请求库
当前项目使用 Alova 作为唯一的 HTTP 请求库:
## 使用方式
- **Alova HTTP**:路径(src/http/request/alova.ts
- **示例代码**src/api/foo-alova.ts 和 src/api/foo.ts
- **API文档**https://alova.js.org/
## 配置说明
Alova 实例已配置:
- 自动 Token 认证和刷新
- 统一错误处理和提示
- 支持动态域名切换
- 内置请求/响应拦截器
## 使用示例
```typescript
import { http } from '@/http/request/alova'
// GET 请求
http.Get<ResponseType>('/api/path', {
params: { id: 1 },
headers: { 'Custom-Header': 'value' },
meta: { toast: false } // 关闭错误提示
})
// POST 请求
http.Post<ResponseType>('/api/path', data, {
params: { query: 'param' },
headers: { 'Content-Type': 'application/json' }
})
```
@@ -0,0 +1,120 @@
import type { uniappRequestAdapter } from '@alova/adapter-uniapp'
import type { IResponse } from './types'
import AdapterUniapp from '@alova/adapter-uniapp'
import { createAlova } from 'alova'
import { createServerTokenAuthentication } from 'alova/client'
import VueHook from 'alova/vue'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
import { ContentTypeEnum, ResultEnum, ShowMessage } from './enum'
/**
* 创建请求实例
*/
const { onAuthRequired, onResponseRefreshToken } = createServerTokenAuthentication<
typeof VueHook,
typeof uniappRequestAdapter
>({
refreshTokenOnError: {
isExpired: (error) => {
return error.response?.status === ResultEnum.Unauthorized
},
handler: async () => {
try {
// await authLogin();
}
catch (error) {
// 切换到登录页
await uni.reLaunch({ url: '/pages/login/index' })
throw error
}
},
},
})
/**
* alova 请求实例
*/
const alovaInstance = createAlova({
baseURL: getEnvBaseUrl(),
...AdapterUniapp(),
timeout: 5000,
statesHook: VueHook,
beforeRequest: onAuthRequired((method) => {
// 设置默认 Content-Type
method.config.headers = {
'Content-Type': ContentTypeEnum.JSON,
'Accept': 'application/json, text/plain, */*',
...method.config.headers,
}
const { config } = method
const ignoreAuth = config.meta?.ignoreAuth
console.log('ignoreAuth===>', ignoreAuth)
// 处理认证信息
if (!ignoreAuth) {
const token = uni.getStorageSync('token')
if (!token) {
// 跳转到登录页
uni.reLaunch({ url: '/pages/login/index' })
throw new Error('[请求错误]:未登录')
}
// 添加 Authorization 头
method.config.headers.Authorization = `Bearer ${token}`
}
// 处理动态域名
if (config.meta?.domain) {
method.baseURL = config.meta.domain
console.log('当前域名', method.baseURL)
}
}),
responded: onResponseRefreshToken((response, method) => {
const { config } = method
const { requestType } = config
const {
statusCode,
data: rawData,
errMsg,
} = response as UniNamespace.RequestSuccessCallbackResult
console.log(response)
// 处理特殊请求类型(上传/下载)
if (requestType === 'upload' || requestType === 'download') {
return response
}
// 处理 HTTP 状态码错误
if (statusCode !== 200) {
const errorMessage = ShowMessage(statusCode) || `HTTP请求错误[${statusCode}]`
console.error('errorMessage===>', errorMessage)
toast.error(errorMessage)
throw new Error(`${errorMessage}${errMsg}`)
}
// 处理业务逻辑错误
const { code, msg, data } = rawData as IResponse
if (code !== ResultEnum.Success) {
// 检查是否为token失效
if (code === ResultEnum.Unauthorized) {
// 清除token并跳转到登录页
uni.removeStorageSync('token')
uni.reLaunch({ url: '/pages/login/index' })
throw new Error(`请求错误[${code}]${msg}`)
}
if (config.meta?.toast !== false) {
toast.warning(msg)
}
throw new Error(`请求错误[${code}]${msg}`)
}
// 处理成功响应,返回业务数据
return data
}),
})
export const http = alovaInstance
@@ -0,0 +1,66 @@
export enum ResultEnum {
Success = 0, // 成功
Error = 400, // 错误
Unauthorized = 401, // 未授权
Forbidden = 403, // 禁止访问(原为forbidden
NotFound = 404, // 未找到(原为notFound
MethodNotAllowed = 405, // 方法不允许(原为methodNotAllowed
RequestTimeout = 408, // 请求超时(原为requestTimeout
InternalServerError = 500, // 服务器错误(原为internalServerError
NotImplemented = 501, // 未实现(原为notImplemented
BadGateway = 502, // 网关错误(原为badGateway
ServiceUnavailable = 503, // 服务不可用(原为serviceUnavailable
GatewayTimeout = 504, // 网关超时(原为gatewayTimeout
HttpVersionNotSupported = 505, // HTTP版本不支持(原为httpVersionNotSupported
}
export enum ContentTypeEnum {
JSON = 'application/json;charset=UTF-8',
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
FORM_DATA = 'multipart/form-data;charset=UTF-8',
}
/**
* 根据状态码,生成对应的错误信息
* @param {number|string} status 状态码
* @returns {string} 错误信息
*/
export function ShowMessage(status: number | string): string {
let message: string
switch (status) {
case 400:
message = '请求错误(400)'
break
case 401:
message = '未授权,请重新登录(401)'
break
case 403:
message = '拒绝访问(403)'
break
case 404:
message = '请求出错(404)'
break
case 408:
message = '请求超时(408)'
break
case 500:
message = '服务器错误(500)'
break
case 501:
message = '服务未实现(501)'
break
case 502:
message = '网络错误(502)'
break
case 503:
message = '服务不可用(503)'
break
case 504:
message = '网络超时(504)'
break
case 505:
message = 'HTTP版本不受支持(505)'
break
default:
message = `连接出错(${status})!`
}
return `${message},请检查网络或联系管理员!`
}
@@ -0,0 +1,22 @@
// 通用响应格式
export interface IResponse<T = any> {
code: number | string
data: T
msg: string
status: string | number
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
[key: string]: any
}
// 分页响应数据
export interface PageResult<T> {
list: T[]
total: number
page: number
pageSize: number
}
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { ConfigProviderThemeVars } from 'wot-design-uni'
const themeVars: ConfigProviderThemeVars = {
// colorTheme: 'red',
// buttonPrimaryBgColor: '#07c160',
// buttonPrimaryColor: '#07c160',
}
</script>
<template>
<wd-config-provider :theme-vars="themeVars">
<slot />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { tabbarStore } from './tabbar'
// 'i-carbon-code',
import { tabbarList as _tabBarList, cacheTabbarEnable, selectedTabbarStrategy, TABBAR_MAP } from './tabbarList'
const customTabbarEnable
= selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
|| selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITHOUT_CACHE
/** tabbarList 里面的 path 从 pages.config.ts 得到 */
const tabbarList = _tabBarList.map(item => ({ ...item, path: `/${item.pagePath}` }))
function selectTabBar({ value: index }: { value: number }) {
const url = tabbarList[index].path
tabbarStore.setCurIdx(index)
if (cacheTabbarEnable) {
uni.switchTab({ url })
}
else {
uni.navigateTo({ url })
}
}
onLoad(() => {
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
const hideRedundantTabbarEnable = selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
hideRedundantTabbarEnable
&& uni.hideTabBar({
fail(err) {
console.log('hideTabBar fail: ', err)
},
success(res) {
console.log('hideTabBar success: ', res)
},
})
})
</script>
<template>
<wd-tabbar
v-if="customTabbarEnable"
v-model="tabbarStore.curIdx"
bordered
safe-area-inset-bottom
placeholder
fixed
@change="selectTabBar"
>
<block v-for="(item, idx) in tabbarList" :key="item.path">
<wd-tabbar-item v-if="item.iconType === 'uiLib'" :title="item.text" :icon="item.icon" />
<wd-tabbar-item
v-else-if="item.iconType === 'unocss' || item.iconType === 'iconfont'"
:title="item.text"
>
<template #icon>
<view
h-40rpx
w-40rpx
:class="[item.icon, idx === tabbarStore.curIdx ? 'is-active' : 'is-inactive']"
/>
</template>
</wd-tabbar-item>
<wd-tabbar-item v-else-if="item.iconType === 'local'" :title="item.text">
<template #icon>
<image :src="item.icon" h-40rpx w-40rpx />
</template>
</wd-tabbar-item>
</block>
</wd-tabbar>
</template>
@@ -0,0 +1,17 @@
# tabbar 说明
`tabbar` 分为 `4 种` 情况:
- 0 `无 tabbar`,只有一个页面入口,底部无 `tabbar` 显示;常用语临时活动页。
- 1 `原生 tabbar`,使用 `switchTab` 切换 tabbar`tabbar` 页面有缓存。
- 优势:原生自带的 tabbar,最先渲染,有缓存。
- 劣势:只能使用 2 组图片来切换选中和非选中状态,修改颜色只能重新换图片(或者用 iconfont)。
- 2 `有缓存自定义 tabbar`,使用 `switchTab` 切换 tabbar`tabbar` 页面有缓存。使用了第三方 UI 库的 `tabbar` 组件,并隐藏了原生 `tabbar` 的显示。
- 优势:可以随意配置自己想要的 `svg icon`,切换字体颜色方便。有缓存。可以实现各种花里胡哨的动效等。
- 劣势:首次点击 tababr 会闪烁。
- 3 `无缓存自定义 tabbar`,使用 `navigateTo` 切换 `tabbar``tabbar` 页面无缓存。使用了第三方 UI 库的 `tabbar` 组件。
- 优势:可以随意配置自己想要的 svg icon,切换字体颜色方便。可以实现各种花里胡哨的动效等。
- 劣势:首次点击 `tababr` 会闪烁,无缓存。
> 注意:花里胡哨的效果需要自己实现,本模版不提供。
@@ -0,0 +1,11 @@
/**
* tabbar 状态,增加 storageSync 保证刷新浏览器时在正确的 tabbar 页面
* 使用reactive简单状态,而不是 pinia 全局状态
*/
export const tabbarStore = reactive({
curIdx: uni.getStorageSync('app-tabbar-index') || 0,
setCurIdx(idx: number) {
this.curIdx = idx
uni.setStorageSync('app-tabbar-index', idx)
},
})
@@ -0,0 +1,76 @@
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
type FgTabBarItem = TabBar['list'][0] & {
icon: string
iconType: 'uiLib' | 'unocss' | 'iconfont'
}
/**
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
* 0: 'NO_TABBAR' `无 tabbar`
* 1: 'NATIVE_TABBAR' `完全原生 tabbar`
* 2: 'CUSTOM_TABBAR_WITH_CACHE' `有缓存自定义 tabbar`
* 3: 'CUSTOM_TABBAR_WITHOUT_CACHE' `无缓存自定义 tabbar`
*
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致错误
*/
export const TABBAR_MAP = {
NO_TABBAR: 0,
NATIVE_TABBAR: 1,
CUSTOM_TABBAR_WITH_CACHE: 2,
CUSTOM_TABBAR_WITHOUT_CACHE: 3,
}
// TODO:通过这里切换使用tabbar的策略
export const selectedTabbarStrategy = TABBAR_MAP.NATIVE_TABBAR
// selectedTabbarStrategy==NATIVE_TABBAR(1) 时,需要填 iconPath 和 selectedIconPath
// selectedTabbarStrategy==CUSTOM_TABBAR(2,3) 时,需要填 icon 和 iconType
// selectedTabbarStrategy==NO_TABBAR(0) 时,tabbarList 不生效
export const tabbarList: FgTabBarItem[] = [
{
iconPath: 'static/tabbar/robot.png',
selectedIconPath: 'static/tabbar/robot_activate.png',
pagePath: 'pages/index/index',
text: '首页',
icon: 'home',
// 选用 UI 框架自带的 icon 时,iconType 为 uiLib
iconType: 'uiLib',
},
{
iconPath: 'static/tabbar/network.png',
selectedIconPath: 'static/tabbar/network_activate.png',
pagePath: 'pages/device-config/index',
text: '配网',
icon: 'i-carbon-network-3',
iconType: 'uiLib',
},
{
iconPath: 'static/tabbar/system.png',
selectedIconPath: 'static/tabbar/system_activate.png',
pagePath: 'pages/settings/index',
text: '系统',
icon: 'i-carbon-settings',
iconType: 'uiLib',
},
]
// NATIVE_TABBAR(1) 和 CUSTOM_TABBAR_WITH_CACHE(2) 时,需要tabbar缓存
export const cacheTabbarEnable = selectedTabbarStrategy === TABBAR_MAP.NATIVE_TABBAR
|| selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE
const _tabbar: TabBar = {
// 只有微信小程序支持 custom。App 和 H5 不生效
custom: selectedTabbarStrategy === TABBAR_MAP.CUSTOM_TABBAR_WITH_CACHE,
color: '#e6e6e6',
selectedColor: '#667dea',
backgroundColor: '#fff',
borderStyle: 'black',
height: '50px',
fontSize: '10px',
iconWidth: '24px',
spacing: '3px',
list: tabbarList as unknown as TabBar['list'],
}
// 0和1 需要显示底部的tabbar的各种配置,以利用缓存
export const tabBar = cacheTabbarEnable ? _tabbar : undefined
@@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { ConfigProviderThemeVars } from 'wot-design-uni'
import FgTabbar from './fg-tabbar/fg-tabbar.vue'
const themeVars: ConfigProviderThemeVars = {
// colorTheme: 'red',
// buttonPrimaryBgColor: '#07c160',
// buttonPrimaryColor: '#07c160',
}
</script>
<template>
<wd-config-provider :theme-vars="themeVars">
<slot />
<FgTabbar />
<wd-toast />
<wd-message-box />
</wd-config-provider>
</template>
+19
View File
@@ -0,0 +1,19 @@
import { VueQueryPlugin } from '@tanstack/vue-query'
import { createSSRApp } from 'vue'
import App from './App.vue'
import { routeInterceptor } from './router/interceptor'
import store from './store'
import '@/style/index.scss'
import 'virtual:uno.css'
export function createApp() {
const app = createSSRApp(App)
app.use(store)
app.use(routeInterceptor)
app.use(VueQueryPlugin)
return {
app,
}
}
+124
View File
@@ -0,0 +1,124 @@
{
"name": "小智",
"appid": "__UNI__36A515E",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"modules": {},
"distribute": {
"android": {
"permissions": [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>"
],
"minSdkVersion": 30,
"targetSdkVersion": 30,
"abiFilters": [
"armeabi-v7a",
"arm64-v8a"
]
},
"ios": {},
"sdkConfigs": {},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png",
"ipad": {
"app": "unpackage/res/icons/76x76.png",
"app@2x": "unpackage/res/icons/152x152.png",
"notification": "unpackage/res/icons/20x20.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"proapp@2x": "unpackage/res/icons/167x167.png",
"settings": "unpackage/res/icons/29x29.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"spotlight": "unpackage/res/icons/40x40.png",
"spotlight@2x": "unpackage/res/icons/80x80.png"
},
"iphone": {
"app@2x": "unpackage/res/icons/120x120.png",
"app@3x": "unpackage/res/icons/180x180.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"notification@3x": "unpackage/res/icons/60x60.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"settings@3x": "unpackage/res/icons/87x87.png",
"spotlight@2x": "unpackage/res/icons/80x80.png",
"spotlight@3x": "unpackage/res/icons/120x120.png"
}
}
}
},
"compatible": {
"ignoreVersion": true
}
},
"quickapp": {},
"mp-weixin": {
"appid": "wxa2abb91f64032a2b",
"setting": {
"urlCheck": false,
"es6": true,
"minified": true
},
"usingComponents": true,
"optimization": {
"subPackages": true
},
"permission": {
"scope.userLocation": {
"desc": "WiFi配网功能需要获取位置权限"
}
},
"requiredPrivateInfos": [
"getLocation"
]
},
"mp-alipay": {
"usingComponents": true,
"styleIsolation": "shared"
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"h5": {
"router": {}
}
}
@@ -0,0 +1,27 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "分包页面"
}
}
</route>
<script lang="ts" setup>
// code here
</script>
<template>
<view class="text-center">
<view class="m-8">
http://localhost:9000/#/pages-sub/demo/index
</view>
<view class="text-green-500">
分包页面demo
</view>
</view>
</template>
<style lang="scss" scoped>
//
</style>
+158
View File
@@ -0,0 +1,158 @@
{
"globalStyle": {
"navigationStyle": "default",
"navigationBarTitleText": "小智",
"navigationBarBackgroundColor": "#f8f8f8",
"navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF"
},
"easycom": {
"autoscan": true,
"custom": {
"^fg-(.*)": "@/components/fg-$1/fg-$1.vue",
"^wd-(.*)": "wot-design-uni/components/wd-$1/wd-$1.vue",
"^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
}
},
"tabBar": {
"custom": false,
"color": "#e6e6e6",
"selectedColor": "#667dea",
"backgroundColor": "#fff",
"borderStyle": "black",
"height": "50px",
"fontSize": "10px",
"iconWidth": "24px",
"spacing": "3px",
"list": [
{
"iconPath": "static/tabbar/robot.png",
"selectedIconPath": "static/tabbar/robot_activate.png",
"pagePath": "pages/index/index",
"text": "首页",
"icon": "home",
"iconType": "uiLib"
},
{
"iconPath": "static/tabbar/network.png",
"selectedIconPath": "static/tabbar/network_activate.png",
"pagePath": "pages/device-config/index",
"text": "配网",
"icon": "i-carbon-network-3",
"iconType": "uiLib"
},
{
"iconPath": "static/tabbar/system.png",
"selectedIconPath": "static/tabbar/system_activate.png",
"pagePath": "pages/settings/index",
"text": "系统",
"icon": "i-carbon-settings",
"iconType": "uiLib"
}
]
},
"pages": [
{
"path": "pages/index/index",
"type": "home",
"layout": "tabbar",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/agent/edit",
"type": "page"
},
{
"path": "pages/agent/index",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "智能体",
"navigationStyle": "custom"
}
},
{
"path": "pages/agent/tools",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "编辑功能",
"navigationStyle": "custom"
}
},
{
"path": "pages/chat-history/detail",
"type": "page",
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "聊天详情"
}
},
{
"path": "pages/chat-history/index",
"type": "page"
},
{
"path": "pages/device/index",
"type": "page"
},
{
"path": "pages/device-config/index",
"type": "page",
"style": {
"navigationBarTitleText": "设备配网",
"navigationStyle": "custom"
}
},
{
"path": "pages/login/index",
"type": "page",
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登陆"
}
},
{
"path": "pages/register/index",
"type": "page",
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "注册"
}
},
{
"path": "pages/settings/index",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "custom"
}
},
{
"path": "pages/voiceprint/index",
"type": "page"
}
],
"subPackages": [
{
"root": "pages-sub",
"pages": [
{
"path": "demo/index",
"type": "page",
"layout": "default",
"style": {
"navigationBarTitleText": "分包页面"
}
}
]
}
]
}
@@ -0,0 +1,701 @@
<script lang="ts" setup>
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getModelOptions, getPluginFunctions, getRoleTemplates, getTTSVoices, updateAgent } from '@/api/agent/agent'
import { usePluginStore } from '@/store'
import { toast } from '@/utils/toast'
defineOptions({
name: 'AgentEdit',
})
const props = withDefaults(defineProps<Props>(), {
agentId: '',
})
// 组件参数
interface Props {
agentId?: string
}
const agentId = computed(() => props.agentId)
// 表单数据
const formData = ref<Partial<AgentDetail>>({
agentName: '',
systemPrompt: '',
summaryMemory: '',
vadModelId: '',
asrModelId: '',
llmModelId: '',
vllmModelId: '',
intentModelId: '',
memModelId: '',
ttsModelId: '',
ttsVoiceId: '',
})
// 显示名称数据
const displayNames = ref({
vad: '请选择',
asr: '请选择',
llm: '请选择',
vllm: '请选择',
intent: '请选择',
memory: '请选择',
tts: '请选择',
voiceprint: '请选择',
})
// 角色模板数据
const roleTemplates = ref<RoleTemplate[]>([])
const selectedTemplateId = ref('')
// 加载状态
const loading = ref(false)
const saving = ref(false)
// 模型选项数据
const modelOptions = ref<{
[key: string]: ModelOption[]
}>({
VAD: [],
ASR: [],
LLM: [],
VLLM: [],
Intent: [],
Memory: [],
TTS: [],
})
// 音色选项数据
const voiceOptions = ref<{ id: string, name: string }[]>([])
// 选择器显示状态
const pickerShow = ref<{
[key: string]: boolean
}>({
vad: false,
asr: false,
llm: false,
vllm: false,
intent: false,
memory: false,
tts: false,
voiceprint: false,
})
const allFunctions = ref<PluginDefinition[]>([])
// 使用插件store
const pluginStore = usePluginStore()
// tabs
const tabList = [
{
label: '角色配置',
value: 'home',
icon: '/static/tabbar/robot.png',
activeIcon: '/static/tabbar/robot_activate.png',
},
{
label: '设备管理',
value: 'category',
icon: '/static/tabbar/device.png',
activeIcon: '/static/tabbar/device_activate.png',
},
{
label: '聊天记录',
value: 'settings',
icon: '/static/tabbar/chat.png',
activeIcon: '/static/tabbar/chat_activate.png',
},
{
label: '声纹管理',
value: 'profile',
icon: '/static/tabbar/voiceprint.png',
activeIcon: '/static/tabbar/voiceprint_activate.png',
},
]
// 加载智能体详情
async function loadAgentDetail() {
if (!agentId.value)
return
try {
loading.value = true
const detail = await getAgentDetail(agentId.value)
formData.value = { ...detail }
// 更新插件store
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(detail.functions || [])
// 如果有TTS模型,加载对应的音色选项
if (detail.ttsModelId) {
await loadVoiceOptions(detail.ttsModelId)
}
// 等待模型选项加载完成后再更新显示名称
await nextTick()
updateDisplayNames()
}
catch (error) {
console.error('加载智能体详情失败:', error)
toast.error('加载失败')
}
finally {
loading.value = false
}
}
// 获取音色显示名称
function getVoiceDisplayName(ttsVoiceId: string) {
if (!ttsVoiceId)
return '请选择'
console.log('=== 音色映射调试 ===')
console.log('当前音色ID:', ttsVoiceId)
console.log('当前TTS模型:', formData.value.ttsModelId)
console.log('可用音色选项:', voiceOptions.value)
// 首先尝试直接从音色选项中匹配ID
const voice = voiceOptions.value.find(v => v.id === ttsVoiceId)
if (voice) {
console.log('直接匹配成功:', voice)
return voice.name
}
// 如果没找到,尝试兼容性映射
if (voiceOptions.value.length > 0) {
console.log('直接匹配失败,尝试兼容性映射')
// 创建索引映射:voice1 → 第1个音色,voice2 → 第2个音色
const indexMap = {
voice1: 0,
voice2: 1,
voice3: 2,
voice4: 3,
voice5: 4,
}
const index = indexMap[ttsVoiceId]
if (index !== undefined && voiceOptions.value[index]) {
const mappedVoice = voiceOptions.value[index]
console.log(`索引映射: ${ttsVoiceId} → index ${index}${mappedVoice.name}`)
return mappedVoice.name
}
}
console.log('所有映射方式都失败,返回原始ID:', ttsVoiceId)
return ttsVoiceId
}
// 更新显示名称
function updateDisplayNames() {
if (!formData.value)
return
displayNames.value.vad = getModelDisplayName('VAD', formData.value.vadModelId)
displayNames.value.asr = getModelDisplayName('ASR', formData.value.asrModelId)
displayNames.value.llm = getModelDisplayName('LLM', formData.value.llmModelId)
displayNames.value.vllm = getModelDisplayName('VLLM', formData.value.vllmModelId)
displayNames.value.intent = getModelDisplayName('Intent', formData.value.intentModelId)
displayNames.value.memory = getModelDisplayName('Memory', formData.value.memModelId)
displayNames.value.tts = getModelDisplayName('TTS', formData.value.ttsModelId)
// 角色音色特殊处理
displayNames.value.voiceprint = getVoiceDisplayName(formData.value.ttsVoiceId || '')
console.log('最终音色显示名称:', displayNames.value.voiceprint)
}
// 加载角色模板
async function loadRoleTemplates() {
try {
const templates = await getRoleTemplates()
roleTemplates.value = templates
}
catch (error) {
console.error('加载角色模板失败:', error)
}
}
// 加载模型选项
async function loadModelOptions() {
const modelTypes = ['VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS']
try {
await Promise.all(
modelTypes?.map(async (type) => {
console.log(`加载模型类型: ${type}`)
const options = await getModelOptions(type)
modelOptions.value[type] = options
console.log(`${type} 选项:`, options)
}) || [],
)
console.log('所有模型选项加载完成:', modelOptions.value)
}
catch (error) {
console.error('加载模型选项失败:', error)
}
}
// 加载TTS音色选项
async function loadVoiceOptions(ttsModelId?: string) {
if (!ttsModelId)
return
try {
console.log(`加载音色选项: ${ttsModelId}`)
const voices = await getTTSVoices(ttsModelId)
voiceOptions.value = voices
console.log('音色选项:', voices)
}
catch (error) {
console.error('加载音色选项失败:', error)
voiceOptions.value = []
}
}
// 选择角色模板
function selectRoleTemplate(templateId: string) {
if (selectedTemplateId.value === templateId) {
selectedTemplateId.value = ''
return
}
selectedTemplateId.value = templateId
const template = roleTemplates.value.find(t => t.id === templateId)
if (template) {
formData.value.systemPrompt = template.systemPrompt
formData.value.vadModelId = template.vadModelId
formData.value.asrModelId = template.asrModelId
formData.value.llmModelId = template.llmModelId
formData.value.vllmModelId = template.vllmModelId
formData.value.intentModelId = template.intentModelId
formData.value.memModelId = template.memModelId
formData.value.ttsModelId = template.ttsModelId
formData.value.ttsVoiceId = template.ttsVoiceId
}
}
// 打开选择器
function openPicker(type: string) {
pickerShow.value[type] = true
}
// 选择器确认
async function onPickerConfirm(type: string, value: any, name: string) {
console.log('选择器确认:', type, value, name)
// 保存显示名称
displayNames.value[type] = name
switch (type) {
case 'vad':
formData.value.vadModelId = value
break
case 'asr':
formData.value.asrModelId = value
break
case 'llm':
formData.value.llmModelId = value
break
case 'vllm':
formData.value.vllmModelId = value
break
case 'intent':
formData.value.intentModelId = value
displayNames.value.intent = name // 确保显示名称正确更新
break
case 'memory':
formData.value.memModelId = value
displayNames.value.memory = name // 确保显示名称正确更新
break
case 'tts':
formData.value.ttsModelId = value
// 当选择TTS模型时,自动加载对应的音色选项
await loadVoiceOptions(value)
// 重置音色选择
formData.value.ttsVoiceId = ''
displayNames.value.voiceprint = '请选择'
break
case 'voiceprint':
formData.value.ttsVoiceId = value
displayNames.value.voiceprint = name // 确保显示名称正确更新
break
}
pickerShow.value[type] = false
}
// 选择器取消
function onPickerCancel(type: string) {
pickerShow.value[type] = false
}
// 获取模型显示名称
function getModelDisplayName(modelType: string, modelId: string) {
if (!modelId)
return '请选择'
// 直接从API配置数据中查找匹配的ID
const options = modelOptions.value[modelType]
if (!options || options.length === 0) {
return modelId
}
const option = options.find(opt => opt.id === modelId)
if (option) {
return option.modelName
}
return modelId
}
// 保存智能体
async function saveAgent() {
if (!formData.value.agentName?.trim()) {
toast.warning('请输入助手昵称')
return
}
if (!formData.value.systemPrompt?.trim()) {
toast.warning('请输入角色介绍')
return
}
try {
saving.value = true
await updateAgent(agentId.value, formData.value)
toast.success('保存成功')
}
catch (error) {
console.error('保存失败:', error)
toast.error('保存失败')
}
finally {
saving.value = false
}
}
function loadPluginFunctions() {
getPluginFunctions().then((res) => {
const processedFunctions = res?.map((item) => {
const meta = JSON.parse(item.fields || '[]')
const params = meta.reduce((m: any, f: any) => {
m[f.key] = f.default
return m
}, {})
return { ...item, fieldsMeta: meta, params }
}) || []
allFunctions.value = processedFunctions
// 同时更新到store
pluginStore.setAllFunctions(processedFunctions)
})
}
function handleTools() {
console.log('当前插件配置:', formData.value.functions)
// 确保store中有最新数据
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(formData.value.functions || [])
pluginStore.setAllFunctions(allFunctions.value)
uni.navigateTo({
url: '/pages/agent/tools',
})
}
// 监听插件配置更新
function watchPluginUpdates() {
// 监听store中的插件配置变化
watch(() => pluginStore.currentFunctions, (newFunctions) => {
console.log('插件配置已更新:', newFunctions)
formData.value.functions = newFunctions
}, { deep: true })
}
onMounted(async () => {
// 初始化插件配置监听
watchPluginUpdates()
// 先加载模型选项和角色模板
await Promise.all([
loadRoleTemplates(),
loadModelOptions(),
loadPluginFunctions(),
])
// 然后加载智能体详情,这样可以正确映射显示名称
if (agentId.value) {
await loadAgentDetail()
}
})
</script>
<template>
<view class="bg-[#f5f7fb] px-[20rpx]">
<!-- 基础信息标题 -->
<view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
基础信息
</text>
</view>
<!-- 基础信息卡片 -->
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
助手昵称
</text>
<input
v-model="formData.agentName"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text"
placeholder="请输入助手昵称"
>
</view>
<view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
角色模式
</text>
<view class="mt-0 flex flex-wrap gap-[12rpx]">
<view
v-for="template in roleTemplates"
:key="template.id"
class="cursor-pointer rounded-[20rpx] px-[24rpx] py-[12rpx] text-[24rpx] transition-all duration-300"
:class="selectedTemplateId === template.id
? 'bg-[#336cff] text-white border border-[#336cff]'
: 'bg-[rgba(51,108,255,0.1)] text-[#336cff] border border-[rgba(51,108,255,0.2)]'"
@click="selectRoleTemplate(template.id)"
>
{{ template.agentName }}
</view>
</view>
</view>
<view class="mb-[24rpx] last:mb-0">
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
角色介绍
</text>
<textarea
v-model="formData.systemPrompt"
:maxlength="2000"
placeholder="请输入角色介绍"
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/>
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
{{ (formData.systemPrompt || '').length }}/2000
</view>
</view>
</view>
<!-- 模型配置标题 -->
<view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
模型配置
</text>
</view>
<!-- 模型配置卡片 -->
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="flex flex-col gap-[16rpx]">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vad')">
<text class="text-[28rpx] text-[#232338] font-medium">
语音活动检测
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.vad }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('asr')">
<text class="text-[28rpx] text-[#232338] font-medium">
语音识别
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.asr }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('llm')">
<text class="text-[28rpx] text-[#232338] font-medium">
大语言模型
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.llm }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vllm')">
<text class="text-[28rpx] text-[#232338] font-medium">
视觉大模型
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.vllm }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('intent')">
<text class="text-[28rpx] text-[#232338] font-medium">
意图识别
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.intent }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('memory')">
<text class="text-[28rpx] text-[#232338] font-medium">
记忆
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.memory }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
</view>
</view>
<!-- 语音设置标题 -->
<view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
语音设置
</text>
</view>
<!-- 语音设置卡片 -->
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="flex flex-col gap-[16rpx]">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('tts')">
<text class="text-[28rpx] text-[#232338] font-medium">
语音合成
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.tts }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('voiceprint')">
<text class="text-[28rpx] text-[#232338] font-medium">
角色音色
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ displayNames.voiceprint }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx]">
<view class="text-[28rpx] text-[#232338] font-medium">
插件
</view>
<view class="cursor-pointer rounded-[20rpx] bg-[rgba(51,108,255,0.1)] px-[24rpx] py-[12rpx] text-[24rpx] text-[#336cff] transition-all duration-300 active:bg-[#336cff] active:text-white" @click="handleTools">
<text>编辑功能</text>
</view>
</view>
</view>
</view>
<!-- 记忆历史标题 -->
<view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
历史记忆
</text>
</view>
<!-- 记忆历史卡片 -->
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="mb-[24rpx] last:mb-0">
<textarea
v-model="formData.summaryMemory"
placeholder="记忆内容"
disabled
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none"
/>
</view>
</view>
<!-- 保存按钮 -->
<view class="mt-[40rpx] p-0">
<wd-button
type="primary"
:loading="saving"
:disabled="saving"
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
@click="saveAgent"
>
{{ saving ? '保存中...' : '保存' }}
</wd-button>
</view>
<!-- 模型选择器 -->
<wd-action-sheet
v-model="pickerShow.vad"
:actions="modelOptions.VAD && modelOptions.VAD.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('vad')"
@select="({ item }) => onPickerConfirm('vad', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.asr"
:actions="modelOptions.ASR && modelOptions.ASR.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('asr')"
@select="({ item }) => onPickerConfirm('asr', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.llm"
:actions="modelOptions.LLM && modelOptions.LLM.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('llm')"
@select="({ item }) => onPickerConfirm('llm', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.vllm"
:actions="modelOptions.VLLM && modelOptions.VLLM.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('vllm')"
@select="({ item }) => onPickerConfirm('vllm', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.intent"
:actions="modelOptions.Intent && modelOptions.Intent.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('intent')"
@select="({ item }) => onPickerConfirm('intent', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.memory"
:actions="modelOptions.Memory && modelOptions.Memory.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('memory')"
@select="({ item }) => onPickerConfirm('memory', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.tts"
:actions="modelOptions.TTS && modelOptions.TTS.map(item => ({ name: item.modelName, value: item.id }))"
@close="onPickerCancel('tts')"
@select="({ item }) => onPickerConfirm('tts', item.value, item.name)"
/>
<wd-action-sheet
v-model="pickerShow.voiceprint"
:actions="voiceOptions && voiceOptions.map(item => ({ name: item.name, value: item.id }))"
@close="onPickerCancel('voiceprint')"
@select="({ item }) => onPickerConfirm('voiceprint', item.value, item.name)"
/>
</view>
</template>
@@ -0,0 +1,214 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "智能体",
"navigationStyle": "custom"
}
}
</route>
<script lang="ts" setup>
import { onLoad } from '@dcloudio/uni-app'
import { computed, onMounted, ref } from 'vue'
import CustomTabs from '@/components/custom-tabs/index.vue'
import ChatHistory from '@/pages/chat-history/index.vue'
import DeviceManagement from '@/pages/device/index.vue'
import VoiceprintManagement from '@/pages/voiceprint/index.vue'
import AgentEdit from './edit.vue'
defineOptions({
name: 'AgentIndex',
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 智能体ID
const currentAgentId = ref('default')
// 当前 tab
const currentTab = ref('agent-config')
// 刷新和加载状态
const refreshing = ref(false)
// 计算是否启用下拉刷新(角色编辑页面不启用)
const refresherEnabled = computed(() => {
return currentTab.value !== 'agent-config'
})
// 子组件引用
const deviceRef = ref()
const chatRef = ref()
const voiceprintRef = ref()
// Tab 配置
const tabList = [
{
label: '角色配置',
value: 'agent-config',
icon: '/static/tabbar/robot.png',
activeIcon: '/static/tabbar/robot_activate.png',
},
{
label: '设备管理',
value: 'device-management',
icon: '/static/tabbar/device.png',
activeIcon: '/static/tabbar/device_activate.png',
},
{
label: '聊天记录',
value: 'chat-history',
icon: '/static/tabbar/chat.png',
activeIcon: '/static/tabbar/chat_activate.png',
},
{
label: '声纹管理',
value: 'voiceprint-management',
icon: '/static/tabbar/microphone.png',
activeIcon: '/static/tabbar/microphone_activate.png',
},
]
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 处理 tab 切换
function handleTabChange(item: any) {
console.log('Tab changed:', item)
}
// 下拉刷新
async function onRefresh() {
// 角色编辑页面不需要刷新
if (currentTab.value === 'agent-config') {
return
}
refreshing.value = true
try {
switch (currentTab.value) {
case 'device-management':
if (deviceRef.value?.refresh) {
await deviceRef.value.refresh()
}
break
case 'chat-history':
if (chatRef.value?.refresh) {
await chatRef.value.refresh()
}
break
case 'voiceprint-management':
if (voiceprintRef.value?.refresh) {
await voiceprintRef.value.refresh()
}
break
}
}
catch (error) {
console.error('刷新失败:', error)
}
finally {
refreshing.value = false
}
}
// 触底加载更多
async function onLoadMore() {
// 只有聊天记录需要加载更多
if (currentTab.value === 'chat-history' && chatRef.value?.loadMore) {
await chatRef.value.loadMore()
}
}
// 接收页面参数
onLoad((options) => {
if (options?.agentId) {
currentAgentId.value = options.agentId
console.log('接收到智能体ID:', options.agentId)
}
})
onMounted(async () => {
// 页面初始化
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 导航栏 -->
<wd-navbar title="智能体" safe-area-inset-top>
<template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" />
</template>
</wd-navbar>
<!-- 自定义 Tabs -->
<CustomTabs
v-model="currentTab"
:tab-list="tabList"
@change="handleTabChange"
/>
<!-- 主内容滚动区域 -->
<scroll-view
scroll-y
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 180rpx)` }"
class="box-border flex-1 bg-[#f5f7fb]"
enable-back-to-top
:refresher-enabled="refresherEnabled"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- Tab 内容 -->
<view class="flex-1">
<AgentEdit
v-if="currentTab === 'agent-config'"
:agent-id="currentAgentId"
/>
<DeviceManagement
v-else-if="currentTab === 'device-management'"
ref="deviceRef"
:agent-id="currentAgentId"
/>
<ChatHistory
v-else-if="currentTab === 'chat-history'"
ref="chatRef"
:agent-id="currentAgentId"
/>
<VoiceprintManagement
v-else-if="currentTab === 'voiceprint-management'"
ref="voiceprintRef"
:agent-id="currentAgentId"
/>
</view>
</scroll-view>
</view>
</template>
<style scoped>
</style>
@@ -0,0 +1,568 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "编辑功能",
"navigationStyle": "custom",
},
}
</route>
<script lang="ts" setup>
import { useMessage } from 'wot-design-uni'
import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
import { usePluginStore } from '@/store'
const message = useMessage()
const pluginStore = usePluginStore()
const segmentedList = ref<string[]>(['未选', '已选'])
const currentSegmented = ref('未选')
const notSelectedList = ref<any[]>([])
const selectedList = ref<any[]>([])
// 使用计算属性从store获取数据
const allFunctions = computed(() => pluginStore.allFunctions)
const functions = computed(() => pluginStore.currentFunctions)
const agentId = computed(() => pluginStore.currentAgentId)
const mcpAddress = ref('')
const mcpTools = ref<string[]>([])
// 参数编辑相关
const showParamDialog = ref(false)
const currentFunction = ref<any>(null)
const tempParams = ref<Record<string, any>>({})
const arrayTextCache = ref<Record<string, string>>({})
const jsonTextCache = ref<Record<string, string>>({})
async function mergeFunctions() {
selectedList.value = functions.value.map((mapping) => {
const meta = allFunctions.value.find(f => f.id === mapping.pluginId)
if (!meta) {
return { id: mapping.pluginId, name: mapping.pluginId, params: {} }
}
return {
id: mapping.pluginId,
name: meta.name,
params: mapping.paramInfo || { ...meta.params },
fieldsMeta: meta.fieldsMeta,
}
})
// 未选的插件
notSelectedList.value = allFunctions.value.filter(
item => !selectedList.value.some(f => f.id === item.id),
)
if (agentId.value) {
const [address, tools] = await Promise.all([
getMcpAddress(agentId.value),
getMcpTools(agentId.value),
])
mcpAddress.value = address
mcpTools.value = tools || []
}
}
// 添加插件到已选
function selectFunction(func: any) {
// 添加到已选列表
selectedList.value.push({
id: func.id,
name: func.name,
params: { ...func.params },
fieldsMeta: func.fieldsMeta,
})
// 从未选列表中移除
notSelectedList.value = notSelectedList.value.filter(
item => item.id !== func.id,
)
}
// 从已选中移除插件
function removeFunction(func: any) {
// 从已选列表中移除
selectedList.value = selectedList.value.filter(item => item.id !== func.id)
// 添加回未选列表
const originalFunc = allFunctions.value.find(f => f.id === func.id)
if (originalFunc) {
notSelectedList.value.push(originalFunc)
}
}
// 编辑插件参数
function editFunction(func: any) {
currentFunction.value = func
// 直接使用当前函数的参数
tempParams.value = { ...func.params }
// 初始化文本缓存
if (func.fieldsMeta) {
func.fieldsMeta.forEach((field: any) => {
if (field.type === 'array') {
const value = tempParams.value[field.key]
arrayTextCache.value[field.key] = Array.isArray(value)
? value.join('\n')
: value || ''
}
else if (field.type === 'json') {
const value = tempParams.value[field.key]
try {
jsonTextCache.value[field.key] = JSON.stringify(value || {}, null, 2)
}
catch {
jsonTextCache.value[field.key] = '{}'
}
}
})
}
showParamDialog.value = true
}
// 处理参数变化 - 实时保存
function handleParamChange(key: string, value: any, field: any) {
tempParams.value[key] = value
// 实时更新到 selectedList
if (currentFunction.value) {
const index = selectedList.value.findIndex(
f => f.id === currentFunction.value.id,
)
if (index >= 0) {
selectedList.value[index].params = { ...tempParams.value }
}
}
}
// 处理数组类型参数变化 - 实时保存
function handleArrayChange(key: string, value: string, field: any) {
arrayTextCache.value[key] = value
// 转换为数组存储
const arrayValue = value.split('\n').filter(Boolean)
tempParams.value[key] = arrayValue
// 实时更新到 selectedList
if (currentFunction.value) {
const index = selectedList.value.findIndex(
f => f.id === currentFunction.value.id,
)
if (index >= 0) {
selectedList.value[index].params = { ...tempParams.value }
}
}
}
// 处理JSON类型参数变化 - 实时保存
function handleJsonChange(key: string, value: string, field: any) {
jsonTextCache.value[key] = value
try {
const jsonValue = JSON.parse(value)
tempParams.value[key] = jsonValue
// 实时更新到 selectedList
if (currentFunction.value) {
const index = selectedList.value.findIndex(
f => f.id === currentFunction.value.id,
)
if (index >= 0) {
selectedList.value[index].params = { ...tempParams.value }
}
}
}
catch {
message.alert('JSON格式错误')
}
}
// 关闭参数编辑弹窗
function closeParamEdit() {
showParamDialog.value = false
tempParams.value = {}
arrayTextCache.value = {}
jsonTextCache.value = {}
}
// 返回上一页并更新配置
function goBack() {
const finalFunctions = selectedList.value.map(f => ({
pluginId: f.id,
paramInfo: f.params,
}))
// 更新到store中
pluginStore.updateFunctions(finalFunctions)
// 直接返回
uni.navigateBack()
}
// 复制MCP地址
function copyMcpAddress() {
if (!mcpAddress.value) {
message.alert('暂无MCP地址可复制')
return
}
uni.setClipboardData({
data: mcpAddress.value,
showToast: false,
success: () => {
message.alert('MCP地址已复制到剪贴板')
},
fail: () => {
message.alert('复制失败,请重试')
},
})
}
// 渲染参数字段的辅助函数
function getFieldDisplayValue(field: any, value: any) {
if (field.type === 'array') {
return Array.isArray(value) ? value.join('\n') : value || ''
}
return value || ''
}
// 字段说明
function getFieldRemark(field: any) {
let description = field.label || ''
if (field.default) {
description += `(默认值:${field.default}`
}
return description
}
onMounted(async () => {
// 直接从store获取数据并合并
await mergeFunctions()
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 头部导航 -->
<wd-navbar
title=""
safe-area-inset-top
left-arrow
:bordered="false"
@click-left="goBack"
>
<template #left>
<wd-icon name="arrow-left" size="18" />
</template>
</wd-navbar>
<!-- 内容区域 -->
<scroll-view
scroll-y
class="box-border flex-1 bg-transparent px-[20rpx]"
:style="{ height: 'calc(100vh - 120rpx)' }"
:scroll-with-animation="true"
>
<!-- 内置插件区域 -->
<view class="mt-[20rpx] flex flex-1 flex-col">
<view class="text-[32rpx] text-[#333] font-medium">
内置插件
</view>
<view
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
>
<!-- 分段控制器 -->
<wd-segmented
v-model:value="currentSegmented"
:options="segmentedList"
/>
<!-- 插件列表 -->
<view class="mt-[20rpx] flex-1 overflow-hidden">
<!-- 未选插件 -->
<scroll-view
v-if="currentSegmented === '未选'"
class="max-h-[600rpx] bg-transparent"
scroll-y
>
<view
v-if="notSelectedList.length === 0"
class="h-[400rpx] flex items-center justify-center"
>
<wd-status-tip image="content" tip="暂无更多插件" />
</view>
<view v-else class="p-[20rpx] space-y-[20rpx]">
<view
v-for="func in notSelectedList"
:key="func.id"
class="flex items-center justify-between border border-[#e9ecef] rounded-[10rpx] bg-[#f8f9fa] p-[20rpx]"
@click="selectFunction(func)"
>
<view class="flex-1">
<view
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
>
{{ func.name }}
</view>
<view class="text-[24rpx] text-[#666]">
{{ func.providerCode }}
</view>
</view>
<view
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
>
<text class="text-[36rpx] text-white">
+
</text>
</view>
</view>
</view>
</scroll-view>
<!-- 已选插件 -->
<scroll-view v-else class="max-h-[600rpx] bg-transparent" scroll-y>
<view
v-if="selectedList.length === 0"
class="h-[400rpx] flex items-center justify-center"
>
<wd-status-tip image="content" tip="请选择插件功能" />
</view>
<view v-else class="p-[20rpx] space-y-[20rpx]">
<view
v-for="func in selectedList"
:key="func.id"
class="border border-[#d4edff] rounded-[10rpx] bg-[#f0f7ff] p-[20rpx]"
>
<view class="flex items-center justify-between">
<view class="flex-1" @click="editFunction(func)">
<view
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
>
{{ func.name }}
</view>
<view class="text-[24rpx] text-[#1677ff]">
点击配置参数
</view>
</view>
<view class="flex space-x-[20rpx]">
<!-- 配置按钮 -->
<view
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
@click="editFunction(func)"
>
<text class="text-[24rpx] text-white">
</text>
</view>
<!-- 移除按钮 -->
<view
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#ff4757]"
@click="removeFunction(func)"
>
<text class="text-[32rpx] text-white">
×
</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
<!-- MCP接入点区域 -->
<view class="mt-[20rpx] flex flex-1 flex-col">
<view class="text-[32rpx] text-[#333] font-medium">
mcp接入点
</view>
<view
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
>
<view class="flex items-center justify-between text-[24rpx]">
<input
v-model="mcpAddress"
type="text"
disabled
class="flex-1 rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
>
<view
class="ml-[20rpx] h-[70rpx] flex items-center justify-center rounded-[10rpx] bg-[#1677ff] px-[20rpx] text-[24rpx] text-white"
@click="copyMcpAddress"
>
复制
</view>
</view>
<!-- 工具列表 -->
<view class="mt-[20rpx] flex-1 overflow-hidden">
<scroll-view class="max-h-[600rpx] bg-transparent" scroll-y>
<view
v-if="mcpTools && mcpTools.length === 0"
class="h-[400rpx] flex items-center justify-center"
>
<wd-status-tip image="content" tip="暂无工具" />
</view>
<view v-else class="p-[20rpx]">
<view class="flex flex-wrap">
<view
v-for="tool in mcpTools"
:key="tool"
class="mb-[20rpx] mr-[20rpx] rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
>
{{ tool }}
</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</scroll-view>
<!-- 参数编辑弹窗 -->
<wd-action-sheet
v-model="showParamDialog"
:title="`参数配置 - ${currentFunction?.name || ''}`"
custom-header-class="h-[75vh]"
@close="closeParamEdit"
>
<scroll-view
scroll-y
class="bg-[#f5f7fb]"
:style="{ height: 'calc(75vh - 60rpx)' }"
>
<view class="p-[30rpx] pb-[40rpx]">
<!-- 无参数提示 -->
<view
v-if="
!currentFunction?.fieldsMeta
|| currentFunction.fieldsMeta.length === 0
"
class="h-[400rpx] flex items-center justify-center"
>
<text class="text-[28rpx] text-[#999]">
{{ currentFunction?.name }} 无需配置参数
</text>
</view>
<!-- 参数表单 - 卡片式布局 -->
<view v-else class="flex flex-col gap-[24rpx]">
<view
v-for="field in currentFunction.fieldsMeta"
:key="field.key"
class="border border-[#eeeeee] rounded-[20rpx] bg-white p-[30rpx]"
style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"
>
<!-- 字段信息 -->
<view class="mb-[24rpx]">
<text class="mb-[8rpx] block text-[32rpx] text-[#232338] font-medium">
{{ field.label }}
</text>
<text v-if="getFieldRemark(field)" class="block text-[24rpx] text-[#65686f] leading-[1.5]">
{{ getFieldRemark(field) }}
</text>
</view>
<!-- 输入控件 -->
<view>
<!-- 字符串类型 -->
<input
v-if="field.type === 'string'"
v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text"
:placeholder="`请输入${field.label}`"
@input="
handleParamChange(field.key, $event.detail.value, field)
"
>
<!-- 数组类型 -->
<view v-else-if="field.type === 'array'">
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
每行输入一个项目
</text>
<textarea
v-model="arrayTextCache[field.key]"
class="box-border min-h-[200rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
:placeholder="`请输入${field.label},每行一个`"
@input="
handleArrayChange(field.key, $event.detail.value, field)
"
/>
</view>
<!-- JSON类型 -->
<view v-else-if="field.type === 'json'">
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
请输入有效的JSON格式
</text>
<textarea
v-model="jsonTextCache[field.key]"
class="box-border min-h-[300rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] font-mono focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
placeholder="请输入合法的JSON格式"
@blur="
handleJsonChange(field.key, $event.detail.value, field)
"
/>
</view>
<!-- 数字类型 -->
<input
v-else-if="field.type === 'number'"
v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="number"
:placeholder="`请输入${field.label}`"
@input="
handleParamChange(
field.key,
Number($event.detail.value),
field,
)
"
>
<!-- 布尔类型 -->
<view
v-else-if="field.type === 'boolean' || field.type === 'bool'"
class="flex items-center justify-between py-[20rpx]"
>
<view class="flex-1">
<text class="mb-[8rpx] block text-[28rpx] text-[#232338]">
启用功能
</text>
<text class="block text-[24rpx] text-[#65686f]">
开启或关闭此功能
</text>
</view>
<switch
:checked="tempParams[field.key]"
@change="
handleParamChange(field.key, $event.detail.value, field)
"
/>
</view>
<!-- 默认字符串类型 -->
<input
v-else
v-model="tempParams[field.key]"
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text"
:placeholder="`请输入${field.label}`"
@input="
handleParamChange(field.key, $event.detail.value, field)
"
>
</view>
</view>
</view>
</view>
</scroll-view>
</wd-action-sheet>
</view>
</template>
@@ -0,0 +1,331 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "聊天详情"
}
}
</route>
<script lang="ts" setup>
import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
defineOptions({
name: 'ChatDetail',
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 页面参数
const sessionId = ref('')
const agentId = ref('')
// 智能体信息(简化)
const currentAgent = computed(() => {
return {
id: agentId.value,
agentName: '智能助手',
}
})
// 聊天数据
const messageList = ref<ChatMessage[]>([])
const loading = ref(false)
// 音频播放相关
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
const playingAudioId = ref<string | null>(null)
// 返回上一页
function goBack() {
uni.navigateBack()
}
// 加载聊天记录
async function loadChatHistory() {
if (!sessionId.value || !agentId.value) {
console.error('缺少必要参数')
return
}
try {
loading.value = true
const response = await getChatHistory(agentId.value, sessionId.value)
messageList.value = response
}
catch (error) {
console.error('获取聊天记录失败:', error)
toast.error('获取聊天记录失败')
}
finally {
loading.value = false
}
}
// 解析用户消息内容
function parseUserMessage(content: string): UserMessageContent | null {
try {
return JSON.parse(content)
}
catch {
return null
}
}
// 获取消息显示内容
function getMessageContent(message: ChatMessage): string {
if (message.chatType === 1) {
// 用户消息,需要解析JSON
const parsed = parseUserMessage(message.content)
return parsed ? parsed.content : message.content
}
else {
// AI消息,直接显示
return message.content
}
}
// 获取说话人名称
function getSpeakerName(message: ChatMessage): string {
if (message.chatType === 1) {
const parsed = parseUserMessage(message.content)
return parsed ? parsed.speaker : '用户'
}
else {
return currentAgent.value?.agentName || 'AI助手'
}
}
// 格式化时间
function formatTime(timeStr: string) {
const date = new Date(timeStr)
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 播放音频
async function playAudio(audioId: string) {
if (!audioId) {
toast.error('音频ID无效')
return
}
try {
// 如果正在播放其他音频,先停止
if (audioContext.value) {
audioContext.value.stop()
audioContext.value.destroy()
audioContext.value = null
}
// 获取音频下载ID
const downloadId = await getAudioId(audioId)
// 构造音频播放地址
const baseUrl = getEnvBaseUrl()
const audioUrl = `${baseUrl}/agent/play/${downloadId}`
// 创建音频上下文
audioContext.value = uni.createInnerAudioContext()
audioContext.value.src = audioUrl
// 设置播放状态
playingAudioId.value = audioId
// 监听播放完成
audioContext.value.onEnded(() => {
playingAudioId.value = null
if (audioContext.value) {
audioContext.value.destroy()
audioContext.value = null
}
})
// 监听播放错误
audioContext.value.onError((error) => {
console.error('音频播放失败:', error)
toast.error('音频播放失败')
playingAudioId.value = null
if (audioContext.value) {
audioContext.value.destroy()
audioContext.value = null
}
})
// 开始播放
audioContext.value.play()
}
catch (error) {
console.error('播放音频失败:', error)
toast.error('播放音频失败')
playingAudioId.value = null
}
}
onLoad((options) => {
if (options?.sessionId && options?.agentId) {
sessionId.value = options.sessionId
agentId.value = options.agentId
loadChatHistory()
}
else {
console.error('缺少必要参数')
toast.error('页面参数错误')
}
})
// 页面销毁时清理音频资源
onUnload(() => {
if (audioContext.value) {
audioContext.value.stop()
audioContext.value.destroy()
audioContext.value = null
}
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 状态栏背景 -->
<view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" />
<!-- 导航栏 -->
<wd-navbar title="聊天详情">
<template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" />
</template>
</wd-navbar>
<!-- 聊天消息列表 -->
<scroll-view
scroll-y
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 120rpx)` }"
class="box-border flex-1 bg-[#f5f7fb] p-[20rpx]"
:scroll-into-view="`message-${messageList.length - 1}`"
>
<view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]">
<wd-loading />
<text class="text-[28rpx] text-[#65686f]">
加载中...
</text>
</view>
<view v-else class="flex flex-col gap-[20rpx]">
<view
v-for="(message, index) in messageList"
:id="`message-${index}`"
:key="index"
class="w-full flex"
:class="{
'justify-end': message.chatType === 1,
'justify-start': message.chatType === 2,
}"
>
<view
class="max-w-[80%] flex flex-col gap-[8rpx]"
:class="{
'items-end': message.chatType === 1,
'items-start': message.chatType === 2,
}"
>
<!-- 消息气泡 -->
<view
class="shadow-message break-words rounded-[20rpx] p-[24rpx] leading-[1.4]"
:class="{
'bg-[#336cff] text-white': message.chatType === 1,
'bg-white text-[#232338] border border-[#eeeeee]': message.chatType === 2,
}"
>
<!-- 内容区域 - 使用flex布局让图标和文本对齐 -->
<view class="flex items-center gap-[12rpx]">
<!-- 音频播放图标 -->
<view
v-if="message.audioId"
class="flex-shrink-0 cursor-pointer transition-transform duration-200 active:scale-90"
:class="{
'text-white animate-pulse-audio': message.chatType === 1 && playingAudioId === message.audioId,
'text-[#ffd700]': message.chatType === 1 && playingAudioId === message.audioId && playingAudioId,
'text-[#336cff] animate-pulse-audio': message.chatType === 2 && playingAudioId === message.audioId,
'text-[#ff6b35]': message.chatType === 2 && playingAudioId === message.audioId && playingAudioId,
'text-white': message.chatType === 1 && playingAudioId !== message.audioId,
'text-[#336cff]': message.chatType === 2 && playingAudioId !== message.audioId,
}"
@click="playAudio(message.audioId)"
>
<wd-icon
:name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
size="20"
/>
</view>
<!-- 消息内容容器 -->
<view class="min-w-0 flex-1">
<!-- 消息内容 -->
<text class="block text-[28rpx]">
{{ getMessageContent(message) }}
</text>
</view>
</view>
</view>
<!-- 说话人信息 -->
<text
class="mx-[12rpx] text-[22rpx] text-[#9d9ea3]"
:class="{
'text-right': message.chatType === 1,
'text-left': message.chatType === 2,
}"
>
{{ formatTime(message.createdAt) }}
</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<style>
/* 自定义阴影和动画效果,无法用UnoCSS表示的样式 */
.shadow-message {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
@keyframes pulse-audio {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
.animate-pulse-audio {
animation: pulse-audio 1.5s infinite;
}
</style>
@@ -0,0 +1,399 @@
<script lang="ts" setup>
import type { ChatSession } from '@/api/chat-history/types'
import { computed, onMounted, ref } from 'vue'
import { getChatSessions } from '@/api/chat-history/chat-history'
defineOptions({
name: 'ChatHistory',
})
// 接收props
interface Props {
agentId?: string
}
const props = withDefaults(defineProps<Props>(), {
agentId: 'default'
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 聊天会话数据
const sessionList = ref<ChatSession[]>([])
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentPage = ref(1)
const pageSize = 10
// 使用传入的智能体ID
const currentAgentId = computed(() => {
return props.agentId
})
// 加载聊天会话列表
async function loadChatSessions(page = 1, isRefresh = false) {
try {
console.log('获取聊天会话列表', { page, isRefresh })
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
sessionList.value = []
return
}
if (page === 1) {
loading.value = true
}
else {
loadingMore.value = true
}
const response = await getChatSessions(currentAgentId.value, {
page,
limit: pageSize,
})
if (page === 1) {
sessionList.value = response.list || []
}
else {
sessionList.value.push(...(response.list || []))
}
// 更新分页信息
hasMore.value = (response.list?.length || 0) === pageSize
currentPage.value = page
}
catch (error) {
console.error('获取聊天会话列表失败:', error)
if (page === 1) {
sessionList.value = []
}
}
finally {
loading.value = false
loadingMore.value = false
}
}
// 暴露给父组件的刷新方法
async function refresh() {
currentPage.value = 1
hasMore.value = true
await loadChatSessions(1, true)
}
// 暴露给父组件的加载更多方法
async function loadMore() {
if (!hasMore.value || loadingMore.value) {
return
}
await loadChatSessions(currentPage.value + 1)
}
// 格式化时间
function formatTime(timeStr: string) {
if (!timeStr)
return '未知时间'
// 处理时间字符串,确保格式正确
const date = new Date(timeStr.replace(' ', 'T')) // 转换为ISO格式
const now = new Date()
// 检查日期是否有效
if (Number.isNaN(date.getTime())) {
return timeStr // 如果解析失败,直接返回原字符串
}
const diff = now.getTime() - date.getTime()
// 小于1分钟
if (diff < 60000)
return '刚刚'
// 小于1小时
if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前`
// 小于1天(24小时)
if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前`
// 小于7天
if (diff < 604800000) {
const days = Math.floor(diff / 86400000)
return `${days}天前`
}
// 超过7天,显示具体日期
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const currentYear = now.getFullYear()
// 如果是当前年份,不显示年份
if (year === currentYear) {
return `${month}-${day}`
}
return `${year}-${month}-${day}`
}
// 进入聊天详情
function goToChatDetail(session: ChatSession) {
uni.navigateTo({
url: `/pages/chat-history/detail?sessionId=${session.sessionId}&agentId=${currentAgentId.value}`,
})
}
onMounted(async () => {
// 智能体已简化为默认
loadChatSessions(1)
})
// 暴露方法给父组件
defineExpose({
refresh,
loadMore,
})
</script>
<template>
<view class="chat-history-container" style="background: #f5f7fb; min-height: 100%;">
<!-- 加载状态 -->
<view v-if="loading && sessionList.length === 0" class="loading-container">
<wd-loading color="#336cff" />
<text class="loading-text">
加载中...
</text>
</view>
<!-- 会话列表 -->
<view v-else-if="sessionList.length > 0" class="session-container">
<!-- 聊天会话列表 -->
<view class="session-list">
<view
v-for="session in sessionList"
:key="session.sessionId"
class="session-item"
@click="goToChatDetail(session)"
>
<view class="session-card">
<view class="session-info">
<view class="session-header">
<text class="session-title">
对话记录 {{ session.sessionId.substring(0, 8) }}...
</text>
<text class="session-time">
{{ formatTime(session.createdAt) }}
</text>
</view>
<view class="session-meta">
<text class="chat-count">
{{ session.chatCount }} 条对话
</text>
</view>
</view>
<wd-icon name="arrow-right" custom-class="arrow-icon" />
</view>
</view>
</view>
<!-- 加载更多状态 -->
<view v-if="loadingMore" class="loading-more">
<wd-loading color="#336cff" size="24" />
<text class="loading-more-text">
加载中...
</text>
</view>
<!-- 没有更多数据 -->
<view v-else-if="!hasMore && sessionList.length > 0" class="no-more">
<text class="no-more-text">
没有更多数据了
</text>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="!loading" class="empty-state">
<wd-icon name="chat" custom-class="empty-icon" />
<text class="empty-text">
暂无聊天记录
</text>
<text class="empty-desc">
与智能体的对话记录会显示在这里
</text>
</view>
</view>
</template>
<style lang="scss" scoped>
.chat-history-container {
position: relative;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #666666;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx;
gap: 16rpx;
.loading-more-text {
font-size: 26rpx;
color: #666666;
}
}
.no-more {
display: flex;
align-items: center;
justify-content: center;
padding: 30rpx;
.no-more-text {
font-size: 26rpx;
color: #999999;
}
}
.navbar-section {
background: #ffffff;
}
.status-bar {
background: #ffffff;
width: 100%;
}
.session-list {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 20rpx;
box-sizing: border-box;
}
.session-item {
background: #fbfbfb;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #eeeeee;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:active {
background: #f8f9fa;
}
}
.session-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
.session-info {
flex: 1;
.session-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
.session-title {
font-size: 32rpx;
font-weight: 600;
color: #232338;
max-width: 70%;
word-break: break-all;
}
.session-time {
font-size: 24rpx;
color: #9d9ea3;
}
}
.session-meta {
.chat-count {
font-size: 28rpx;
color: #65686f;
}
}
}
:deep(.arrow-icon) {
font-size: 24rpx;
color: #c7c7cc;
margin-left: 16rpx;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
text-align: center;
:deep(.empty-icon) {
font-size: 120rpx;
color: #d9d9d9;
margin-bottom: 32rpx;
}
.empty-text {
font-size: 32rpx;
color: #666666;
margin-bottom: 16rpx;
font-weight: 500;
}
.empty-desc {
font-size: 26rpx;
color: #999999;
line-height: 1.5;
}
}
</style>
@@ -0,0 +1,684 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
selectedNetwork: WiFiNetwork | null
password: string
}
const props = defineProps<Props>()
// Toast 实例
const toast = useToast()
// 响应式数据
const generating = ref(false)
const playing = ref(false)
const audioGenerated = ref(false)
const autoLoop = ref(true)
const audioFilePath = ref('')
const audioContext = ref<any>(null)
// AFSK调制参数 - 参考HTML文件
const MARK = 1800 // 二进制1的频率 (Hz)
const SPACE = 1500 // 二进制0的频率 (Hz)
const SAMPLE_RATE = 44100 // 采样率
const BIT_RATE = 100 // 比特率 (bps)
const START_BYTES = [0x01, 0x02] // 起始标记
const END_BYTES = [0x03, 0x04] // 结束标记
// 计算属性
const canGenerate = computed(() => {
if (!props.selectedNetwork)
return false
if (props.selectedNetwork.authmode > 0 && !props.password)
return false
return true
})
const audioLengthText = computed(() => {
if (!props.selectedNetwork)
return '0秒'
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
const textBytes = stringToBytes(dataStr)
const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8
const duration = Math.ceil(totalBits / BIT_RATE)
return `${duration}`
})
// 字符串转字节数组 - uniapp兼容版本
function stringToBytes(str: string): number[] {
const bytes: number[] = []
for (let i = 0; i < str.length; i++) {
const code = str.charCodeAt(i)
if (code < 0x80) {
bytes.push(code)
}
else if (code < 0x800) {
bytes.push(0xC0 | (code >> 6))
bytes.push(0x80 | (code & 0x3F))
}
else if (code < 0xD800 || code >= 0xE000) {
bytes.push(0xE0 | (code >> 12))
bytes.push(0x80 | ((code >> 6) & 0x3F))
bytes.push(0x80 | (code & 0x3F))
}
else {
// 代理对处理
i++
const hi = code
const lo = str.charCodeAt(i)
const codePoint = 0x10000 + (((hi & 0x3FF) << 10) | (lo & 0x3FF))
bytes.push(0xF0 | (codePoint >> 18))
bytes.push(0x80 | ((codePoint >> 12) & 0x3F))
bytes.push(0x80 | ((codePoint >> 6) & 0x3F))
bytes.push(0x80 | (codePoint & 0x3F))
}
}
return bytes
}
// 校验和计算 - 参考HTML文件
function checksum(data: number[]): number {
return data.reduce((sum, b) => (sum + b) & 0xFF, 0)
}
// 字节转比特位 - 参考HTML文件
function toBits(byte: number): number[] {
const bits: number[] = []
for (let i = 7; i >= 0; i--) {
bits.push((byte >> i) & 1)
}
return bits
}
// AFSK调制 - 参考HTML文件算法
function afskModulate(bits: number[]): Float32Array {
const samplesPerBit = SAMPLE_RATE / BIT_RATE
const totalSamples = Math.floor(bits.length * samplesPerBit)
const buffer = new Float32Array(totalSamples)
for (let i = 0; i < bits.length; i++) {
const freq = bits[i] ? MARK : SPACE
for (let j = 0; j < samplesPerBit; j++) {
const t = (i * samplesPerBit + j) / SAMPLE_RATE
buffer[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t)
}
}
return buffer
}
// 浮点转16位PCM - 参考HTML文件
function floatTo16BitPCM(floatSamples: Float32Array): Uint8Array {
const buffer = new Uint8Array(floatSamples.length * 2)
for (let i = 0; i < floatSamples.length; i++) {
const s = Math.max(-1, Math.min(1, floatSamples[i]))
const val = s < 0 ? s * 0x8000 : s * 0x7FFF
buffer[i * 2] = val & 0xFF
buffer[i * 2 + 1] = (val >> 8) & 0xFF
}
return buffer
}
// base64编码表
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
// 兼容的base64编码实现
function base64Encode(bytes: Uint8Array): string {
let result = ''
let i = 0
while (i < bytes.length) {
const a = bytes[i++]
const b = i < bytes.length ? bytes[i++] : 0
const c = i < bytes.length ? bytes[i++] : 0
const bitmap = (a << 16) | (b << 8) | c
result += base64Chars.charAt((bitmap >> 18) & 63)
result += base64Chars.charAt((bitmap >> 12) & 63)
result += i - 2 < bytes.length ? base64Chars.charAt((bitmap >> 6) & 63) : '='
result += i - 1 < bytes.length ? base64Chars.charAt(bitmap & 63) : '='
}
return result
}
// 数组转base64编码 - 兼容版本
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
// 尝试使用原生btoa,如果不存在则使用自定义实现
if (typeof btoa !== 'undefined') {
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
else {
return base64Encode(bytes)
}
}
// 构建WAV文件 - 返回ArrayBuffer而不是Blob
function buildWav(pcm: Uint8Array): ArrayBuffer {
const wavHeader = new Uint8Array(44)
const dataLen = pcm.length
const fileLen = 36 + dataLen
const writeStr = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
wavHeader[offset + i] = str.charCodeAt(i)
}
}
const write32 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
wavHeader[offset + 2] = (value >> 16) & 0xFF
wavHeader[offset + 3] = (value >> 24) & 0xFF
}
const write16 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
}
writeStr(0, 'RIFF')
write32(4, fileLen)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
write32(16, 16)
write16(20, 1)
write16(22, 1)
write32(24, SAMPLE_RATE)
write32(28, SAMPLE_RATE * 2)
write16(32, 2)
write16(34, 16)
writeStr(36, 'data')
write32(40, dataLen)
// 合并header和数据
const result = new ArrayBuffer(44 + dataLen)
const resultView = new Uint8Array(result)
resultView.set(wavHeader)
resultView.set(pcm, 44)
return result
}
// 生成并播放声波 - 主要功能函数
async function generateAndPlay() {
if (!canGenerate.value || !props.selectedNetwork)
return
generating.value = true
try {
console.log('生成超声波配网音频...')
// 准备配网数据 - 参考HTML文件格式
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
const textBytes = stringToBytes(dataStr)
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
console.log('配网数据:', { ssid: props.selectedNetwork.ssid, password: props.password })
console.log('数据字节长度:', textBytes.length)
// 转换为比特流
let bits: number[] = []
fullBytes.forEach((b) => {
bits = bits.concat(toBits(b))
})
console.log('比特流长度:', bits.length)
// AFSK调制 - 减少采样率降低文件大小
const reducedSampleRate = 22050 // 降低采样率
const samplesPerBit = reducedSampleRate / BIT_RATE
const totalSamples = Math.floor(bits.length * samplesPerBit)
const floatBuf = new Float32Array(totalSamples)
for (let i = 0; i < bits.length; i++) {
const freq = bits[i] ? MARK : SPACE
for (let j = 0; j < samplesPerBit; j++) {
const t = (i * samplesPerBit + j) / reducedSampleRate
floatBuf[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t) * 0.5 // 降低音量
}
}
const pcmBuf = floatTo16BitPCM(floatBuf)
// 生成WAV文件 - 使用降低的采样率
const wavBuffer = buildWavOptimized(pcmBuf, reducedSampleRate)
const base64 = arrayBufferToBase64(wavBuffer)
const dataUri = `data:audio/wav;base64,${base64}`
console.log('base64长度:', base64.length, '约', Math.round(base64.length / 1024), 'KB')
// 检查数据大小
if (base64.length > 1024 * 1024) { // 超过1MB
throw new Error('音频文件过大,请缩短SSID或密码长度')
}
audioFilePath.value = dataUri
audioGenerated.value = true
console.log('音频生成成功,比特流长度:', bits.length, '采样点数:', floatBuf.length)
toast.success('声波生成成功')
// 延迟播放
setTimeout(async () => {
await playAudio()
}, 800) // 增加延迟时间
}
catch (error) {
console.error('音频生成失败:', error)
toast.error(`声波生成失败: ${error.message || error}`)
}
finally {
generating.value = false
}
}
// 优化的WAV构建函数
function buildWavOptimized(pcm: Uint8Array, sampleRate: number): ArrayBuffer {
const wavHeader = new Uint8Array(44)
const dataLen = pcm.length
const fileLen = 36 + dataLen
const writeStr = (offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
wavHeader[offset + i] = str.charCodeAt(i)
}
}
const write32 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
wavHeader[offset + 2] = (value >> 16) & 0xFF
wavHeader[offset + 3] = (value >> 24) & 0xFF
}
const write16 = (offset: number, value: number) => {
wavHeader[offset] = value & 0xFF
wavHeader[offset + 1] = (value >> 8) & 0xFF
}
writeStr(0, 'RIFF')
write32(4, fileLen)
writeStr(8, 'WAVE')
writeStr(12, 'fmt ')
write32(16, 16)
write16(20, 1)
write16(22, 1)
write32(24, sampleRate) // 使用传入的采样率
write32(28, sampleRate * 2)
write16(32, 2)
write16(34, 16)
writeStr(36, 'data')
write32(40, dataLen)
// 合并header和数据
const result = new ArrayBuffer(44 + dataLen)
const resultView = new Uint8Array(result)
resultView.set(wavHeader)
resultView.set(pcm, 44)
return result
}
// 播放音频
async function playAudio() {
if (!audioFilePath.value) {
toast.error('请先生成音频')
return
}
try {
// 强制清理所有旧的音频实例
await cleanupAudio()
// 等待一下确保清理完成
await new Promise(resolve => setTimeout(resolve, 200))
playing.value = true
console.log('开始播放超声波配网音频')
// 创建新的音频上下文
const innerAudioContext = uni.createInnerAudioContext()
audioContext.value = innerAudioContext
// 最简化的音频设置
innerAudioContext.src = audioFilePath.value
innerAudioContext.loop = autoLoop.value
innerAudioContext.volume = 0.8
innerAudioContext.autoplay = false
// 简化的事件监听
innerAudioContext.onPlay(() => {
console.log('超声波音频开始播放')
toast.success('开始播放配网声波')
})
innerAudioContext.onEnded(() => {
console.log('超声波音频播放结束')
if (!autoLoop.value) {
playing.value = false
cleanupAudio()
}
})
innerAudioContext.onError((error) => {
console.error('音频播放失败:', error)
playing.value = false
let errorMsg = '音频播放失败'
if (error.errCode === -99) {
errorMsg = '音频资源繁忙,请稍后重试'
}
else if (error.errCode === 10004) {
errorMsg = '音频格式不支持,可能是data URI问题'
}
else if (error.errCode === 10003) {
errorMsg = '音频文件错误'
}
toast.error(errorMsg)
cleanupAudio()
})
innerAudioContext.onStop(() => {
console.log('音频播放停止')
playing.value = false
})
// 延迟播放
setTimeout(() => {
if (audioContext.value) {
console.log('尝试播放音频,src长度:', audioFilePath.value.length)
audioContext.value.play()
}
}, 300)
}
catch (error) {
console.error('播放音频异常:', error)
playing.value = false
await cleanupAudio()
toast.error(`播放失败: ${error.message}`)
}
}
// 清理音频资源
async function cleanupAudio() {
if (audioContext.value) {
try {
audioContext.value.pause()
audioContext.value.destroy()
console.log('清理音频上下文')
}
catch (e) {
console.log('清理音频上下文失败:', e)
}
finally {
audioContext.value = null
}
}
}
// 停止播放
async function stopAudio() {
playing.value = false
await cleanupAudio()
console.log('停止播放超声波音频')
toast.success('已停止播放')
}
</script>
<template>
<view class="ultrasonic-config">
<!-- 选中的网络信息 -->
<view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info">
<view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }}
</view>
<view class="network-details">
<text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm
</text>
<text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
</text>
</view>
<view v-if="props.password" class="network-password">
密码: {{ '*'.repeat(props.password.length) }}
</view>
</view>
</view>
<!-- 超声波配网操作 -->
<view class="submit-section">
<wd-button
type="primary"
size="large"
block
:loading="generating"
:disabled="!canGenerate"
@click="generateAndPlay"
>
{{ generating ? '生成中...' : '🎵 生成并播放声波' }}
</wd-button>
<wd-button
v-if="audioGenerated"
type="success"
size="large"
block
:loading="playing"
@click="playAudio"
>
{{ playing ? '播放中...' : '🔊 播放声波' }}
</wd-button>
<wd-button
v-if="playing"
type="warning"
size="large"
block
@click="stopAudio"
>
停止播放
</wd-button>
</view>
<!-- 音频控制选项 -->
<view v-if="audioGenerated" class="audio-options">
<view class="option-item">
<wd-checkbox v-model="autoLoop">
自动循环播放声波
</wd-checkbox>
</view>
</view>
<!-- 音频播放器 -->
<view v-if="audioGenerated" class="audio-player">
<view class="player-info">
<text class="audio-title">
配网音频文件
</text>
<text class="audio-duration">
时长: {{ audioLengthText }}
</text>
</view>
</view>
<!-- 使用说明 -->
<view class="help-section">
<view class="help-title">
超声波配网说明
</view>
<view class="help-content">
<text class="help-item">
1. 确保已选择WiFi网络并输入密码
</text>
<text class="help-item">
2. 点击生成并播放声波系统会将配网信息编码为音频
</text>
<text class="help-item">
3. 将手机靠近xiaozhi设备距离1-2
</text>
<text class="help-item">
4. 音频播放时xiaozhi会接收并解码配网信息
</text>
<text class="help-item">
5. 配网成功后设备会自动连接WiFi网络
</text>
<text class="help-tip">
使用AFSK调制技术通过1800Hz和1500Hz频率传输数据
</text>
<text class="help-tip">
请确保手机音量适中避免环境噪音干扰
</text>
</view>
</view>
</view>
</template>
<style scoped>
.ultrasonic-config {
padding: 20rpx 0;
}
.selected-network {
margin-bottom: 32rpx;
}
.network-info {
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.network-name {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.network-details {
display: flex;
gap: 24rpx;
margin-bottom: 8rpx;
}
.network-signal,
.network-security {
font-size: 24rpx;
color: #65686f;
}
.network-password {
font-size: 24rpx;
color: #65686f;
}
.submit-section {
margin-bottom: 32rpx;
}
.submit-section .wd-button {
margin-bottom: 16rpx;
}
.submit-section .wd-button:last-child {
margin-bottom: 0;
}
.audio-options {
margin-bottom: 32rpx;
padding: 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.option-item {
font-size: 28rpx;
}
.audio-player {
margin-bottom: 32rpx;
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.player-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.audio-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
}
.audio-duration {
font-size: 24rpx;
color: #65686f;
}
.help-section {
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.help-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 20rpx;
}
.help-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.help-item {
font-size: 24rpx;
color: #65686f;
line-height: 1.5;
}
.help-tip {
font-size: 24rpx;
color: #336cff;
font-weight: 500;
margin-top: 8rpx;
}
</style>
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
selectedNetwork: WiFiNetwork | null
password: string
}
const props = defineProps<Props>()
// Toast 实例
const toast = useToast()
// 响应式数据
const configuring = ref(false)
// 计算属性
const canSubmit = computed(() => {
if (!props.selectedNetwork)
return false
if (props.selectedNetwork.authmode > 0 && !props.password)
return false
return true
})
// ESP32连接检查
async function checkESP32Connection() {
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 3000,
})
return response.statusCode === 200
}
catch (error) {
console.log('ESP32连接检查失败:', error)
return false
}
}
// 提交配网
async function submitConfig() {
if (!props.selectedNetwork)
return
// 检查ESP32连接
const connected = await checkESP32Connection()
if (!connected) {
toast.error('请先连接xiaozhi热点')
return
}
configuring.value = true
console.log('开始WiFi配网:', props.selectedNetwork.ssid)
try {
const response = await uni.request({
url: 'http://192.168.4.1/submit',
method: 'POST',
header: {
'Content-Type': 'application/json',
},
data: {
ssid: props.selectedNetwork.ssid,
password: props.selectedNetwork.authmode > 0 ? props.password : '',
},
timeout: 15000,
})
console.log('WiFi配网响应:', response)
if (response.statusCode === 200 && (response.data as any)?.success) {
toast.success(`配网成功!设备将连接到 ${props.selectedNetwork.ssid},设备会自动重启。请断开xiaozhi热点连接。`)
}
else {
const errorMsg = (response.data as any)?.error || '配网失败'
toast.error(errorMsg)
}
}
catch (error) {
console.error('WiFi配网失败:', error)
toast.error('配网失败,请检查网络连接')
}
finally {
configuring.value = false
}
}
</script>
<template>
<view class="wifi-config">
<!-- 选中的网络信息 -->
<view v-if="props.selectedNetwork" class="selected-network">
<view class="network-info">
<view class="network-name">
选中网络: {{ props.selectedNetwork.ssid }}
</view>
<view class="network-details">
<text class="network-signal">
信号: {{ props.selectedNetwork.rssi }}dBm
</text>
<text class="network-security">
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
</text>
</view>
</view>
</view>
<!-- 配网按钮 -->
<view class="submit-section">
<wd-button
type="primary"
size="large"
block
:loading="configuring"
:disabled="!canSubmit"
@click="submitConfig"
>
{{ configuring ? '配网中...' : '开始WiFi配网' }}
</wd-button>
</view>
<!-- 使用说明 -->
<view class="help-section">
<view class="help-title">
WiFi配网说明
</view>
<view class="help-content">
<text class="help-item">
1. 手机连接xiaozhi热点 (xiaozhi-XXXXXX)
</text>
<text class="help-item">
2. 选择要配网的目标WiFi网络
</text>
<text class="help-item">
3. 输入WiFi密码如果需要
</text>
<text class="help-item">
4. 点击开始配网等待设备连接
</text>
<text class="help-tip">
配网成功后设备会自动重启并连接目标WiFi
</text>
</view>
</view>
</view>
</template>
<style scoped>
.wifi-config {
padding: 20rpx 0;
}
.selected-network {
margin-bottom: 32rpx;
}
.network-info {
padding: 24rpx;
background-color: #f0f6ff;
border: 1rpx solid #336cff;
border-radius: 16rpx;
}
.network-name {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.network-details {
display: flex;
gap: 24rpx;
}
.network-signal,
.network-security {
font-size: 24rpx;
color: #65686f;
}
.submit-section {
margin-bottom: 32rpx;
}
.help-section {
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.help-title {
font-size: 28rpx;
font-weight: 600;
color: #232338;
margin-bottom: 20rpx;
}
.help-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.help-item {
font-size: 24rpx;
color: #65686f;
line-height: 1.5;
}
.help-tip {
font-size: 24rpx;
color: #336cff;
font-weight: 500;
margin-top: 8rpx;
}
</style>
@@ -0,0 +1,556 @@
<script setup lang="ts">
import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// Props
interface Props {
autoConnect?: boolean // 是否自动检测ESP32连接
}
const props = withDefaults(defineProps<Props>(), {
autoConnect: true,
})
// Emits
const emit = defineEmits<{
'network-selected': [network: WiFiNetwork | null, password: string]
'connection-status': [connected: boolean]
}>()
// Toast 实例
const toast = useToast()
// 响应式数据
const isConnectedToESP32 = ref(false)
const checkingConnection = ref(false)
const scanning = ref(false)
const wifiNetworks = ref<WiFiNetwork[]>([])
const selectedNetwork = ref<WiFiNetwork | null>(null)
const password = ref('')
const selectorExpanded = ref(false)
// 计算属性
const networkDisplayText = computed(() => {
if (!selectedNetwork.value)
return '请选择WiFi网络'
return selectedNetwork.value.ssid
})
// 检查xiaozhi连接状态
async function checkESP32Connection() {
checkingConnection.value = true
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 3000,
})
isConnectedToESP32.value = response.statusCode === 200
emit('connection-status', isConnectedToESP32.value)
console.log('xiaozhi连接状态:', isConnectedToESP32.value)
}
catch (error) {
isConnectedToESP32.value = false
emit('connection-status', false)
console.log('xiaozhi连接检查失败:', error)
}
finally {
checkingConnection.value = false
}
}
// 扫描WiFi网络
async function scanWifi() {
if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点')
return
}
scanning.value = true
console.log('开始扫描WiFi网络')
try {
const response = await uni.request({
url: 'http://192.168.4.1/scan',
method: 'GET',
timeout: 10000,
})
console.log('WiFi扫描响应:', response)
if (response.statusCode === 200 && response.data) {
const data = response.data as any
if (data.success && Array.isArray(data.networks)) {
wifiNetworks.value = data.networks
console.log(`扫描成功,发现 ${data.networks.length} 个网络`)
}
else if (Array.isArray(response.data)) {
// 兼容旧格式
wifiNetworks.value = response.data.map((item: any) => ({
ssid: item.ssid,
rssi: item.rssi,
authmode: item.authmode,
channel: item.channel || 0,
}))
}
else {
throw new TypeError('扫描接口返回格式异常')
}
}
else {
throw new Error(`HTTP ${response.statusCode}`)
}
}
catch (error) {
console.error('WiFi扫描失败:', error)
toast.error('扫描失败,请检查xiaozhi连接')
}
finally {
scanning.value = false
}
}
// 显示网络选择器
async function showNetworkSelector() {
// 实时检测xiaozhi连接状态
await checkESP32Connection()
if (!isConnectedToESP32.value) {
toast.error('请先连接xiaozhi热点')
return
}
selectorExpanded.value = true
// 如果还没有网络列表,自动扫描
if (wifiNetworks.value.length === 0) {
scanWifi()
}
}
// 选择网络
function selectNetwork(network: WiFiNetwork) {
selectedNetwork.value = network
password.value = ''
selectorExpanded.value = false
console.log('选择网络:', network.ssid)
// 通知父组件
emit('network-selected', network, '')
}
// 密码变化时通知父组件
function onPasswordChange() {
emit('network-selected', selectedNetwork.value, password.value)
}
// 获取当前选择的网络和密码
function getSelectedNetworkInfo() {
return {
network: selectedNetwork.value,
password: password.value,
}
}
// 重置选择
function reset() {
selectedNetwork.value = null
password.value = ''
wifiNetworks.value = []
selectorExpanded.value = false
emit('network-selected', null, '')
}
// 获取信号强度描述
function getSignalStrength(rssi: number): string {
if (rssi >= -50)
return '信号强'
if (rssi >= -60)
return '信号良好'
if (rssi >= -70)
return '信号一般'
return '信号弱'
}
// 获取信号强度颜色
function getSignalColor(rssi: number): string {
if (rssi >= -50)
return '#52c41a'
if (rssi >= -60)
return '#73d13d'
if (rssi >= -70)
return '#faad14'
return '#ff4d4f'
}
// 暴露方法给父组件
defineExpose({
checkESP32Connection,
scanWifi,
getSelectedNetworkInfo,
reset,
})
// 生命周期
onMounted(() => {
if (props.autoConnect) {
checkESP32Connection()
}
})
</script>
<template>
<view class="wifi-selector">
<!-- Xiaozhi连接状态 -->
<view v-if="props.autoConnect" class="connection-status">
<view v-if="!isConnectedToESP32" class="status-warning">
<view class="status-content">
<text class="warning-text">
请先连接xiaozhi热点 (xiaozhi-XXXXXX)
</text>
<wd-button
size="small"
type="primary"
:loading="checkingConnection"
@click="checkESP32Connection"
>
{{ checkingConnection ? '检测中...' : '重新检测' }}
</wd-button>
</view>
</view>
<view v-else class="status-success">
<view class="status-content">
<text class="success-text">
已连接xiaozhi热点
</text>
<wd-button
size="small"
:loading="checkingConnection"
@click="checkESP32Connection"
>
{{ checkingConnection ? '检测中...' : '刷新状态' }}
</wd-button>
</view>
</view>
</view>
<!-- WiFi网络选择器 -->
<view class="network-selector">
<view class="selector-item" @click="showNetworkSelector">
<text class="selector-label">
WiFi网络
</text>
<text class="selector-value">
{{ networkDisplayText }}
</text>
<wd-icon name="arrow-right" custom-class="arrow-icon" />
</view>
</view>
<!-- 展开的网络列表 -->
<view v-if="selectorExpanded" class="network-list-overlay">
<view class="network-list-container">
<view class="list-header">
<text class="list-title">
选择WiFi网络
</text>
<view class="list-actions">
<wd-button
type="primary"
size="small"
:loading="scanning"
@click="scanWifi"
>
{{ scanning ? '扫描中...' : '刷新扫描' }}
</wd-button>
<wd-button
size="small"
@click="selectorExpanded = false"
>
取消
</wd-button>
</view>
</view>
<view class="network-list">
<view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state">
<text class="empty-text">
暂无WiFi网络
</text>
<text class="empty-tip">
请点击刷新扫描
</text>
</view>
<view v-else class="wifi-list">
<view
v-for="network in wifiNetworks"
:key="network.ssid"
class="wifi-item"
@click="selectNetwork(network)"
>
<view class="wifi-info">
<view class="wifi-name">
{{ network.ssid }}
</view>
<view class="wifi-details">
<text class="wifi-signal">
信号: {{ network.rssi }}dBm
</text>
<text class="wifi-channel">
频道: {{ network.channel }}
</text>
</view>
</view>
<view class="wifi-security">
<text class="security-icon">
{{ network.authmode === 0 ? '开放' : '加密' }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 密码输入 -->
<view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section">
<view class="password-item">
<text class="password-label">
网络密码
</text>
<wd-input
v-model="password"
placeholder="请输入WiFi密码"
show-password
clearable
@input="onPasswordChange"
/>
</view>
</view>
</view>
</template>
<style scoped>
.wifi-selector {
width: 100%;
}
.connection-status {
margin-bottom: 24rpx;
}
.status-warning {
padding: 24rpx;
background-color: #fff3cd;
border: 1rpx solid #ffeaa7;
border-radius: 16rpx;
}
.status-success {
padding: 24rpx;
background-color: #d4edda;
border: 1rpx solid #c3e6cb;
border-radius: 16rpx;
}
.status-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.warning-text {
color: #856404;
font-size: 28rpx;
}
.success-text {
color: #155724;
font-size: 28rpx;
}
.network-selector {
margin-bottom: 24rpx;
}
.selector-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background: #f5f7fb;
border-radius: 12rpx;
border: 1rpx solid #eeeeee;
cursor: pointer;
transition: all 0.3s ease;
}
.selector-item:active {
background: #eef3ff;
border-color: #336cff;
}
.selector-label {
font-size: 28rpx;
color: #232338;
font-weight: 500;
}
.selector-value {
flex: 1;
text-align: right;
font-size: 26rpx;
color: #65686f;
margin: 0 16rpx;
}
:deep(.arrow-icon) {
font-size: 20rpx;
color: #9d9ea3;
}
.network-list-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: flex-end;
}
.network-list-container {
width: 100%;
max-height: 70vh;
background-color: #ffffff;
border-radius: 20rpx 20rpx 0 0;
padding: 32rpx;
box-sizing: border-box;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #eeeeee;
}
.list-title {
font-size: 32rpx;
font-weight: 600;
color: #232338;
}
.list-actions {
display: flex;
gap: 16rpx;
}
.network-list {
max-height: 50vh;
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 20rpx;
background-color: #fbfbfb;
border-radius: 16rpx;
border: 1rpx solid #eeeeee;
}
.empty-text {
font-size: 32rpx;
color: #65686f;
margin-bottom: 16rpx;
}
.empty-tip {
font-size: 24rpx;
color: #9d9ea3;
}
.wifi-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.wifi-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32rpx 24rpx;
background-color: #fbfbfb;
border: 2rpx solid #eeeeee;
border-radius: 16rpx;
transition: all 0.3s ease;
cursor: pointer;
}
.wifi-item:active {
transform: scale(0.98);
background-color: #f0f6ff;
border-color: #336cff;
}
.wifi-info {
flex: 1;
}
.wifi-name {
font-size: 32rpx;
font-weight: 600;
color: #232338;
margin-bottom: 8rpx;
}
.wifi-details {
display: flex;
gap: 24rpx;
}
.wifi-signal,
.wifi-channel {
font-size: 24rpx;
color: #65686f;
}
.security-icon {
font-size: 24rpx;
color: #65686f;
margin-left: 20rpx;
}
.password-section {
margin-top: 24rpx;
}
.password-item {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.password-label {
font-size: 28rpx;
color: #232338;
font-weight: 500;
}
</style>
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { ref } from 'vue'
import UltrasonicConfig from './components/ultrasonic-config.vue'
import WifiConfig from './components/wifi-config.vue'
import WifiSelector from './components/wifi-selector.vue'
// 类型定义
interface WiFiNetwork {
ssid: string
rssi: number
authmode: number
channel: number
}
// 配网类型
const configType = ref<'wifi' | 'ultrasonic'>('wifi')
// 配网模式选择器状态
const configTypeSelectorShow = ref(false)
// WiFi选择器引用
const wifiSelectorRef = ref<InstanceType<typeof WifiSelector>>()
// 选择的WiFi网络信息
const selectedWifiInfo = ref<{
network: WiFiNetwork | null
password: string
}>({
network: null,
password: '',
})
// 配网模式选项
const configTypeOptions = [
{
name: 'WiFi配网',
value: 'wifi' as const,
},
// {
// name: '超声波配网',
// value: 'ultrasonic' as const,
// },
]
// 显示配网模式选择器
function showConfigTypeSelector() {
configTypeSelectorShow.value = true
}
// 配网模式选择器确认
function onConfigTypeConfirm(item: { name: string, value: 'wifi' | 'ultrasonic' }) {
configType.value = item.value
configTypeSelectorShow.value = false
}
// 配网模式选择器取消
function onConfigTypeCancel() {
configTypeSelectorShow.value = false
}
// WiFi网络选择事件
function onNetworkSelected(network: WiFiNetwork | null, password: string) {
selectedWifiInfo.value = { network, password }
}
// ESP32连接状态变化事件
function onConnectionStatusChange(connected: boolean) {
console.log('ESP32连接状态:', connected)
}
</script>
<template>
<view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设备配网" safe-area-inset-top />
<view class="box-border px-[20rpx]">
<!-- 配网方式选择 -->
<view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
配网方式
</text>
</view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
<text class="text-[28rpx] text-[#232338] font-medium">
配网方式
</text>
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
{{ configType === 'wifi' ? 'WiFi配网' : '超声波配网' }}
</text>
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
</view>
<!-- WiFi网络选择 -->
<view class="pb-[20rpx]">
<text class="text-[32rpx] text-[#232338] font-bold">
网络配置
</text>
</view>
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
<wifi-selector
ref="wifiSelectorRef"
@network-selected="onNetworkSelected"
@connection-status="onConnectionStatusChange"
/>
</view>
<!-- 配网操作 -->
<view v-if="selectedWifiInfo.network" class="flex-1">
<!-- WiFi配网组件 -->
<wifi-config
v-if="configType === 'wifi'"
:selected-network="selectedWifiInfo.network"
:password="selectedWifiInfo.password"
/>
<!-- 超声波配网组件 -->
<ultrasonic-config
v-else-if="configType === 'ultrasonic'"
:selected-network="selectedWifiInfo.network"
:password="selectedWifiInfo.password"
/>
</view>
</view>
<!-- 配网模式选择器 -->
<wd-action-sheet
v-model="configTypeSelectorShow"
:actions="configTypeOptions.map(item => ({ name: item.name, value: item.value }))"
@close="onConfigTypeCancel"
@select="({ item }) => onConfigTypeConfirm(item)"
/>
</view>
</template>
<route lang="jsonc" type="page">
{
"style": {
"navigationBarTitleText": "设备配网",
"navigationStyle": "custom"
}
}
</route>
@@ -0,0 +1,333 @@
<script lang="ts" setup>
import type { Device, FirmwareType } from '@/api/device'
import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'wot-design-uni'
import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { toast } from '@/utils/toast'
defineOptions({
name: 'DeviceManage',
})
// 接收props
interface Props {
agentId?: string
}
const props = withDefaults(defineProps<Props>(), {
agentId: 'default'
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 设备数据
const deviceList = ref<Device[]>([])
const firmwareTypes = ref<FirmwareType[]>([])
const loading = ref(false)
// 消息组件
const message = useMessage()
// 使用传入的智能体ID
const currentAgentId = computed(() => {
return props.agentId
})
// 获取设备列表
async function loadDeviceList() {
try {
console.log('获取设备列表')
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
deviceList.value = []
return
}
loading.value = true
const response = await getBindDevices(currentAgentId.value)
deviceList.value = response || []
}
catch (error) {
console.error('获取设备列表失败:', error)
deviceList.value = []
}
finally {
loading.value = false
}
}
// 暴露给父组件的刷新方法
async function refresh() {
await loadDeviceList()
}
// 获取设备类型名称
function getDeviceTypeName(boardKey: string): string {
const firmwareType = firmwareTypes.value.find(type => type.key === boardKey)
return firmwareType?.name || boardKey
}
// 格式化时间
function formatTime(timeStr: string) {
if (!timeStr)
return '从未连接'
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000)
return '刚刚'
if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前`
if (diff < 604800000)
return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString()
}
// 切换OTA自动更新
async function toggleAutoUpdate(device: Device) {
try {
const newStatus = device.autoUpdate === 1 ? 0 : 1
await updateDeviceAutoUpdate(device.id, newStatus)
device.autoUpdate = newStatus
toast.success(newStatus === 1 ? 'OTA自动升级已开启' : 'OTA自动升级已关闭')
}
catch (error: any) {
console.error('更新设备OTA状态失败:', error)
toast.error('操作失败,请重试')
}
}
// 解绑设备
async function handleUnbindDevice(device: Device) {
try {
await unbindDevice(device.id)
await loadDeviceList()
toast.success('设备已解绑')
}
catch (error: any) {
console.error('解绑设备失败:', error)
toast.error('解绑失败,请重试')
}
}
// 确认解绑设备
function confirmUnbindDevice(device: Device) {
message.confirm({
title: '解绑设备',
msg: `确定要解绑设备 "${device.macAddress}" 吗?`,
confirmButtonText: '确定解绑',
cancelButtonText: '取消',
}).then(() => {
handleUnbindDevice(device)
}).catch(() => {
// 用户取消
})
}
// 绑定新设备
async function handleBindDevice(code: string) {
try {
if (!currentAgentId.value) {
toast.error('请先选择智能体')
return
}
await bindDevice(currentAgentId.value, code.trim())
await loadDeviceList()
toast.success('设备绑定成功!')
}
catch (error: any) {
console.error('绑定设备失败:', error)
const errorMessage = error?.message || '绑定失败,请检查验证码是否正确'
toast.error(errorMessage)
}
}
// 打开绑定设备对话框
function openBindDialog() {
message
.prompt({
title: '绑定设备',
inputPlaceholder: '请输入设备验证码',
inputValue: '',
inputPattern: /^\d{6}$/,
confirmButtonText: '立即绑定',
cancelButtonText: '取消',
})
.then(async (result: any) => {
if (result.value && String(result.value).trim()) {
await handleBindDevice(String(result.value).trim())
}
})
.catch(() => {
// 用户取消操作
})
}
// 获取设备类型列表
async function loadFirmwareTypes() {
try {
const response = await getFirmwareTypes()
firmwareTypes.value = response
}
catch (error) {
console.error('获取设备类型失败:', error)
}
}
onMounted(async () => {
// 智能体已简化为默认
loadFirmwareTypes()
loadDeviceList()
})
// 暴露方法给父组件
defineExpose({
refresh,
})
</script>
<template>
<view class="device-container" style="background: #f5f7fb; min-height: 100%;">
<!-- 加载状态 -->
<view v-if="loading && deviceList.length === 0" class="loading-container">
<wd-loading color="#336cff" />
<text class="loading-text">
加载中...
</text>
</view>
<!-- 设备列表 -->
<view v-else-if="deviceList.length > 0" class="device-list">
<!-- 设备卡片列表 -->
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
<view v-for="device in deviceList" :key="device.id">
<wd-swipe-action>
<view class="cursor-pointer bg-[#fbfbfb] p-[32rpx] transition-all duration-200 active:bg-[#f8f9fa]">
<view class="flex items-start justify-between">
<view class="flex-1">
<view class="mb-[16rpx] flex items-center justify-between">
<text class="max-w-[60%] break-all text-[32rpx] text-[#232338] font-semibold">
{{ getDeviceTypeName(device.board) }}
</text>
</view>
<view class="mb-[20rpx]">
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
MAC地址{{ device.macAddress }}
</text>
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
固件版本{{ device.appVersion }}
</text>
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
最近对话{{ formatTime(device.lastConnectedAt) }}
</text>
</view>
<view class="flex items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx]">
<text class="text-[28rpx] text-[#232338] font-medium">
OTA升级
</text>
<wd-switch
:model-value="device.autoUpdate === 1"
size="24"
@change="toggleAutoUpdate(device)"
/>
</view>
</view>
</view>
</view>
<template #right>
<view class="h-full flex">
<view
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
@click.stop="confirmUnbindDevice(device)"
>
<wd-icon name="delete" />
<text>解绑</text>
</view>
</view>
</template>
</wd-swipe-action>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="!loading" class="empty-container">
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
<wd-icon name="phone" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
暂无设备
</text>
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
点击右下角 + 号绑定您的第一个设备
</text>
</view>
</view>
<!-- FAB 绑定设备按钮 -->
<wd-fab type="primary" size="small" icon="add" :draggable="true" :expandable="false" @click="openBindDialog" />
<!-- MessageBox 组件 -->
<wd-message-box />
</view>
</template>
<style scoped>
.device-container {
position: relative;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #666666;
}
:deep(.wd-swipe-action) {
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #eeeeee;
}
:deep(.wd-icon) {
font-size: 32rpx;
}
</style>
@@ -0,0 +1,578 @@
<!-- 使用 type="home" 属性设置首页其他页面不需要设置默认为page -->
<route lang="jsonc" type="home">
{
"layout": "tabbar",
"style": {
// 'custom' 表示开启自定义导航栏,默认 'default'
"navigationStyle": "custom",
"navigationBarTitleText": "首页"
}
}
</route>
<script lang="ts" setup>
import type { Agent } from '@/api/agent/types'
import { ref } from 'vue'
import { useMessage } from 'wot-design-uni'
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
import { toast } from '@/utils/toast'
defineOptions({
name: 'Home',
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
// 微信小程序使用新的API
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
// 其他平台继续使用uni API
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 智能体数据
const agentList = ref<Agent[]>([])
const pagingRef = ref()
useZPaging(pagingRef)
// 消息组件
const message = useMessage()
// z-paging查询列表数据
async function queryList(pageNo: number, pageSize: number) {
try {
console.log('z-paging获取智能体列表')
const response = await getAgentList()
// 更新本地列表
agentList.value = response
// 直接返回全部数据,不需要分页处理
pagingRef.value.complete(response)
}
catch (error) {
console.error('获取智能体列表失败:', error)
// 告知z-paging数据加载失败
pagingRef.value.complete(false)
}
}
// 创建智能体
async function handleCreateAgent(agentName: string) {
try {
await createAgent({ agentName: agentName.trim() })
// 创建成功后刷新列表
pagingRef.value.reload()
toast.success(`智能体"${agentName}"创建成功!`)
}
catch (error: any) {
console.error('创建智能体失败:', error)
const errorMessage = error?.message || '创建失败,请重试'
toast.error(errorMessage)
}
}
// 删除智能体
async function handleDeleteAgent(agent: Agent) {
try {
await deleteAgent(agent.id)
// 删除成功后刷新列表
pagingRef.value.reload()
toast.success(`智能体"${agent.agentName}"已删除`)
}
catch (error: any) {
console.error('删除智能体失败:', error)
const errorMessage = error?.message || '删除失败,请重试'
toast.error(errorMessage)
}
}
// 进入编辑页面
function goToEditAgent(agent: Agent) {
// 传递智能体ID到编辑页面
uni.navigateTo({
url: `/pages/agent/index?agentId=${agent.id}`,
})
}
// 点击卡片进入编辑
function handleCardClick(agent: Agent) {
goToEditAgent(agent)
}
// 打开创建对话框
function openCreateDialog() {
message
.prompt({
title: '创建智能体',
msg: '',
inputPlaceholder: '例如:客服助手、语音助理、知识问答',
inputValue: '',
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i,
confirmButtonText: '立即创建',
cancelButtonText: '取消',
})
.then(async (result: any) => {
if (result.value && String(result.value).trim()) {
await handleCreateAgent(String(result.value).trim())
}
})
.catch(() => {
// 用户取消操作
})
}
// 格式化时间
function formatTime(timeStr: string) {
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000)
return '刚刚'
if (diff < 3600000)
return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000)
return `${Math.floor(diff / 3600000)}小时前`
return `${Math.floor(diff / 86400000)}天前`
}
// 页面显示时刷新列表
onShow(() => {
console.log('首页 onShow,刷新智能体列表')
if (pagingRef.value) {
pagingRef.value.reload()
}
})
</script>
<template>
<z-paging
ref="pagingRef" v-model="agentList" :refresher-enabled="true" :auto-show-back-to-top="true"
:loading-more-enabled="false" :show-loading-more="false" :hide-empty-view="false" empty-view-text="暂无智能体"
empty-view-img="" :refresher-threshold="80" :back-to-top-style="{
backgroundColor: '#fff',
borderRadius: '50%',
width: '56px',
height: '56px',
}" @query="queryList"
>
<!-- 固定在顶部的横幅区域 -->
<template #top>
<view class="banner-section" :style="{ paddingTop: `${safeAreaInsets?.top + 100}rpx` }">
<view class="banner-content">
<view class="welcome-info">
<text class="greeting">
你好小智
</text>
<text class="subtitle">
让我们度过 <text class="highlight">
美好的一天
</text>
</text>
<text class="english-subtitle">
Hello, Let's have a wonderful day!
</text>
</view>
<view class="wave-decoration">
<!-- 添加波浪装饰 -->
<view class="wave" />
<view class="wave wave-2" />
</view>
</view>
</view>
<!-- 内容区域开始标识 -->
<view class="content-section-header" />
</template>
<!-- 智能体卡片列表 -->
<view class="agent-list">
<view v-for="agent in agentList" :key="agent.id" class="agent-item">
<wd-swipe-action>
<view class="simple-card" @click="handleCardClick(agent)">
<view class="card-content">
<view class="card-main">
<view class="agent-title">
<text class="agent-name">
{{ agent.agentName }}
</text>
</view>
<view class="model-info">
<text class="model-text">
语言模型 {{ agent.llmModelName }}
</text>
<text class="model-text">
音色模型 {{ agent.ttsModelName }} ({{ agent.ttsVoiceName }})
</text>
</view>
<view class="stats-row">
<view class="stat-chip">
<wd-icon name="phone" custom-class="chip-icon" />
<text class="chip-text">
设备管理({{ agent.deviceCount }})
</text>
</view>
<view v-if="agent.lastConnectedAt" class="stat-chip">
<wd-icon name="time" custom-class="chip-icon" />
<text class="chip-text">
最近对话{{ formatTime(agent.lastConnectedAt) }}
</text>
</view>
</view>
</view>
<wd-icon name="arrow-right" custom-class="arrow-icon" />
</view>
</view>
<template #right>
<view class="swipe-actions">
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
<wd-icon name="delete" />
<text>删除</text>
</view>
</view>
</template>
</wd-swipe-action>
</view>
</view>
<!-- 自定义空状态 -->
<template #empty>
<view class="empty-state">
<wd-icon name="robot" custom-class="empty-icon" />
<text class="empty-text">
暂无智能体
</text>
<text class="empty-desc">
点击右下角 + 号创建您的第一个智能体
</text>
</view>
</template>
<!-- FAB 新增按钮 -->
<wd-fab type="primary" icon="add" :draggable="true" :expandable="false" @click="openCreateDialog" />
<!-- MessageBox 组件 -->
<wd-message-box />
</z-paging>
</template>
<style lang="scss" scoped>
.banner-section {
background: linear-gradient(145deg, #9ebbfc, #6baaff, #9ebbfc, #f5f8fd);
position: relative;
padding: 40rpx 40rpx 80rpx 40rpx;
overflow: hidden;
.banner-content {
position: relative;
z-index: 2;
}
.header-actions {
position: absolute;
top: -50rpx;
right: 0;
display: flex;
gap: 32rpx;
.filter-icon,
.setting-icon {
color: white;
cursor: pointer;
transition: opacity 0.2s ease;
&:active {
opacity: 0.7;
}
}
}
.welcome-info {
.greeting {
display: block;
font-size: 48rpx;
font-weight: 700;
color: #ffffff;
margin-bottom: 16rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.subtitle {
display: block;
font-size: 32rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12rpx;
font-weight: 500;
.highlight {
color: #ffd700;
font-weight: 600;
}
}
.english-subtitle {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
font-style: italic;
}
}
.wave-decoration {
position: absolute;
top: 0;
right: -100rpx;
width: 400rpx;
height: 100%;
opacity: 0.1;
pointer-events: none;
.wave {
position: absolute;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
border-radius: 50%;
animation: float 6s ease-in-out infinite;
&.wave-2 {
top: 20%;
right: 20%;
animation-delay: -3s;
opacity: 0.5;
}
}
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-30rpx) rotate(180deg);
}
}
// 内容区域开始标识,创建白色背景过渡
.content-section-header {
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
margin-top: -32rpx;
height: 32rpx;
position: relative;
z-index: 1;
}
// z-paging内容区域样式
:deep(.z-paging-content) {
background: #ffffff;
padding: 0 0 40rpx 0;
}
.agent-list {
display: flex;
flex-direction: column;
gap: 16rpx;
padding: 0 20rpx;
}
.agent-item {
:deep(.wd-swipe-action) {
border-radius: 16rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #f0f0f0;
}
}
.simple-card {
background: #ffffff;
padding: 24rpx;
cursor: pointer;
transition: all 0.2s ease;
&:active {
background: #f8f9fa;
}
.card-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-main {
flex: 1;
}
.agent-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
.agent-name {
font-size: 32rpx;
font-weight: 600;
color: #1a1a1a;
}
}
.model-info {
margin-bottom: 16rpx;
.model-text {
display: block;
font-size: 24rpx;
color: #666666;
line-height: 1.5;
margin-bottom: 4rpx;
&:last-child {
margin-bottom: 0;
}
}
}
.stats-row {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
.stat-chip {
display: flex;
align-items: center;
padding: 6rpx 12rpx;
background: #f8f9fa;
border-radius: 20rpx;
border: 1rpx solid #eaeaea;
:deep(.chip-icon) {
font-size: 20rpx;
color: #666666;
margin-right: 6rpx;
}
.chip-text {
font-size: 22rpx;
color: #666666;
}
}
}
:deep(.arrow-icon) {
font-size: 24rpx;
color: #c7c7cc;
margin-left: 16rpx;
}
}
.swipe-actions {
display: flex;
height: 100%;
.action-btn {
width: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
color: #ffffff;
font-size: 24rpx;
font-weight: 500;
transition: all 0.3s ease;
&.edit-btn {
background: #1890ff;
&:active {
background: #096dd9;
}
}
&.delete-btn {
background: #ff4d4f;
&:active {
background: #d9363e;
}
}
:deep(.wd-icon) {
font-size: 32rpx;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
text-align: center;
:deep(.empty-icon) {
font-size: 120rpx;
color: #d9d9d9;
margin-bottom: 32rpx;
}
.empty-text {
font-size: 32rpx;
color: #666666;
margin-bottom: 16rpx;
font-weight: 500;
}
.empty-desc {
font-size: 26rpx;
color: #999999;
line-height: 1.5;
}
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.filter-actions {
padding: 32rpx;
text-align: center;
border-top: 1rpx solid #eeeeee;
}
</style>
@@ -0,0 +1,778 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "登陆"
}
}
</route>
<script lang="ts" setup>
import type { LoginData } from '@/api/auth'
import { computed, onMounted, ref } from 'vue'
import { login } from '@/api/auth'
import { useConfigStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
// 获取屏幕边界到安全区域距离
let safeAreaInsets
let systemInfo
// #ifdef MP-WEIXIN
// 微信小程序使用新的API
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
// 其他平台继续使用uni API
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 表单数据
const formData = ref<LoginData>({
username: '',
password: '',
captcha: '',
captchaId: '',
areaCode: '+86',
mobile: '',
})
// 验证码图片
const captchaImage = ref('')
const loading = ref(false)
// 登录方式:'username' | 'mobile'
const loginType = ref<'username' | 'mobile'>('username')
// 获取配置store
const configStore = useConfigStore()
// 区号选择相关
const showAreaCodeSheet = ref(false)
const selectedAreaCode = ref('+86')
const selectedAreaName = ref('中国大陆')
// 计算属性:是否启用手机号登录
const enableMobileLogin = computed(() => {
return configStore.config.enableMobileRegister
})
// 计算属性:区号列表
const areaCodeList = computed(() => {
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
})
// 切换登录方式
function toggleLoginType() {
loginType.value = loginType.value === 'username' ? 'mobile' : 'username'
// 清空输入框
formData.value.username = ''
formData.value.mobile = ''
}
// 打开区号选择弹窗
function openAreaCodeSheet() {
showAreaCodeSheet.value = true
}
// 选择区号
function selectAreaCode(item: { name: string, key: string }) {
selectedAreaCode.value = item.key
selectedAreaName.value = item.name
formData.value.areaCode = item.key
showAreaCodeSheet.value = false
}
// 关闭区号选择弹窗
function closeAreaCodeSheet() {
showAreaCodeSheet.value = false
}
// 跳转到注册页面
function goToRegister() {
uni.navigateTo({
url: '/pages/register/index',
})
}
// 生成UUID
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// 获取验证码
async function refreshCaptcha() {
const uuid = generateUUID()
formData.value.captchaId = uuid
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
}
// 登录
async function handleLogin() {
// 表单验证
if (loginType.value === 'username') {
if (!formData.value.username) {
toast.warning('请输入用户名')
return
}
}
else {
if (!formData.value.mobile) {
toast.warning('请输入手机号')
return
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号')
return
}
}
if (!formData.value.password) {
toast.warning('请输入密码')
return
}
if (!formData.value.captcha) {
toast.warning('请输入验证码')
return
}
try {
loading.value = true
// 构建登录数据
const loginData = { ...formData.value }
// 如果是手机号登录,将区号+手机号拼接到username字段
if (loginType.value === 'mobile') {
loginData.username = `${selectedAreaCode.value}${formData.value.mobile}`
}
const response = await login(loginData)
// 存储token
uni.setStorageSync('token', response.token)
uni.setStorageSync('expire', response.expire)
toast.success('登录成功')
// 跳转到主页
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index',
})
}, 1000)
}
catch (error: any) {
// 登录失败重新获取验证码
refreshCaptcha()
}
finally {
loading.value = false
}
}
// 页面加载时获取验证码
onLoad(() => {
refreshCaptcha()
})
// 组件挂载时确保配置已加载
onMounted(async () => {
if (!configStore.config.name) {
try {
await configStore.fetchPublicConfig()
}
catch (error) {
console.error('获取配置失败:', error)
}
}
})
</script>
<template>
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
<view class="header">
<view class="logo-section">
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
<text class="welcome-text">
欢迎回来
</text>
<text class="subtitle">
请登录您的账户
</text>
</view>
</view>
<view class="form-container">
<view class="form">
<!-- 手机号登录 -->
<template v-if="loginType === 'mobile'">
<view class="input-group">
<view class="input-wrapper mobile-wrapper">
<view class="area-code-selector" @click="openAreaCodeSheet">
<text class="area-code-text">
{{ selectedAreaCode }}
</text>
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
</view>
<view class="mobile-input-wrapper">
<wd-input
v-model="formData.mobile"
custom-class="styled-input"
no-border
placeholder="请输入手机号码"
type="number"
:maxlength="11"
/>
</view>
</view>
</view>
</template>
<!-- 用户名登录 -->
<template v-else>
<view class="input-group">
<view class="input-wrapper">
<wd-input
v-model="formData.username"
custom-class="styled-input"
no-border
placeholder="请输入用户名"
/>
</view>
</view>
</template>
<view class="input-group">
<view class="input-wrapper">
<wd-input
v-model="formData.password"
custom-class="styled-input"
no-border
placeholder="请输入密码"
clearable
show-password
:maxlength="20"
/>
</view>
</view>
<view class="input-group">
<view class="input-wrapper captcha-wrapper">
<wd-input
v-model="formData.captcha"
custom-class="styled-input"
no-border
placeholder="请输入验证码"
:maxlength="6"
/>
<view class="captcha-image" @click="refreshCaptcha">
<image :src="captchaImage" class="captcha-img" />
</view>
</view>
</view>
<view class="forgot-password">
<text class="forgot-text">
忘记密码
</text>
</view>
<view
class="login-btn"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</view>
<view class="register-hint">
<text class="hint-text">
还没有账户
</text>
<text class="register-link" @click="goToRegister">
立即注册
</text>
</view>
<!-- 登录方式切换 -->
<view v-if="enableMobileLogin" class="login-type-switch">
<view class="switch-tabs">
<view
class="switch-tab"
:class="{ active: loginType === 'username' }"
@click="toggleLoginType"
>
<wd-icon name="user" />
</view>
<view
class="switch-tab"
:class="{ active: loginType === 'mobile' }"
@click="toggleLoginType"
>
<wd-icon name="phone" />
</view>
</view>
</view>
</view>
</view>
<!-- 区号选择弹窗 -->
<wd-action-sheet
v-model="showAreaCodeSheet"
title="选择国家/地区"
:close-on-click-modal="true"
@close="closeAreaCodeSheet"
>
<view class="area-code-sheet">
<scroll-view scroll-y class="area-code-list">
<view
v-for="item in areaCodeList"
:key="item.key"
class="area-code-item"
:class="{ selected: selectedAreaCode === item.key }"
@click="selectAreaCode(item)"
>
<view class="area-info">
<text class="area-name">
{{ item.name }}
</text>
<text class="area-code">
{{ item.key }}
</text>
</view>
<wd-icon
v-if="selectedAreaCode === item.key"
name="check"
custom-class="check-icon"
/>
</view>
</scroll-view>
<view class="sheet-footer">
<wd-button
type="primary"
custom-class="confirm-btn"
@click="closeAreaCodeSheet"
>
确认
</wd-button>
</view>
</view>
</wd-action-sheet>
</view>
</template>
<style lang="scss" scoped>
.app-container {
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 100vh;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
animation: float 6s ease-in-out infinite;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
.header {
flex: 0 0 auto;
min-height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 15% 0 40rpx 0;
.logo-section {
text-align: center;
.logo {
margin-bottom: 20rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
.welcome-text {
display: block;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
margin-bottom: 12rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.subtitle {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 26rpx;
font-weight: 400;
}
}
}
.form-container {
padding: 0 40rpx 40rpx 40rpx;
flex: 1;
display: flex;
align-items: flex-start;
.form {
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
padding: 40rpx 30rpx 30rpx 30rpx;
backdrop-filter: blur(10rpx);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
border: 1rpx solid rgba(255, 255, 255, 0.2);
max-height: calc(100vh - 350rpx);
overflow-y: auto;
.input-group {
margin-bottom: 24rpx;
.input-wrapper {
position: relative;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
transition: all 0.3s ease;
display: flex;
align-items: center;
&:focus-within {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
&.captcha-wrapper {
.captcha-image {
margin-left: 20rpx;
width: 120rpx;
height: 60rpx;
border-radius: 8rpx;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #e9ecef;
border: 1rpx solid #ddd;
.captcha-img {
width: 100%;
height: 100%;
}
.captcha-loading {
font-size: 20rpx;
color: #999;
}
}
}
&.mobile-wrapper {
padding: 0;
background: transparent;
border: none;
display: flex;
gap: 20rpx;
.area-code-selector {
flex: 0 0 160rpx;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
height: 45rpx;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
.area-code-text {
font-size: 28rpx;
color: #333333;
font-weight: 500;
}
:deep(.area-code-arrow) {
font-size: 24rpx;
color: #999999;
transition: transform 0.3s ease;
}
}
.mobile-input-wrapper {
flex: 1;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
transition: all 0.3s ease;
&:focus-within {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
}
}
:deep(.styled-input) {
background: transparent !important;
border: none !important;
outline: none !important;
font-size: 32rpx;
color: #333333;
flex: 1;
&::placeholder {
color: #999999;
font-size: 28rpx;
}
}
}
}
.forgot-password {
text-align: right;
margin-bottom: 30rpx;
.forgot-text {
color: #667eea;
font-size: 26rpx;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.login-btn {
width: 100%;
height: 80rpx;
border: none;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 30rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
text-align: center;
line-height: 80rpx;
&:active {
transform: translateY(2rpx);
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.register-hint {
text-align: center;
.hint-text {
color: #666666;
font-size: 26rpx;
margin-right: 8rpx;
}
.register-link {
color: #667eea;
font-size: 26rpx;
font-weight: 500;
cursor: pointer;
// text-decoration: underline;
// &:hover {
// text-decoration: underline;
// }
}
}
.login-type-switch {
margin-top: 20rpx;
text-align: center;
.switch-tabs {
display: flex;
justify-content: center;
gap: 60rpx;
margin-bottom: 20rpx;
.switch-tab {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2rpx solid transparent;
&.active {
background: #667eea;
color: #ffffff;
border-color: #667eea;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
:deep(.wd-icon) {
font-size: 24rpx;
}
}
}
.switch-hint {
font-size: 24rpx;
color: #666666;
}
}
}
}
// 区号选择弹窗样式
.area-code-sheet {
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
.sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 40rpx 40rpx 20rpx 40rpx;
border-bottom: 1rpx solid #f0f0f0;
.sheet-title {
font-size: 36rpx;
font-weight: 600;
color: #333333;
}
:deep(.close-icon) {
font-size: 32rpx;
color: #999999;
cursor: pointer;
padding: 10rpx;
&:hover {
color: #333333;
}
}
}
.area-code-list {
max-height: 60vh;
padding: 0 40rpx;
.area-code-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 0;
border-bottom: 1rpx solid #f8f9fa;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #f8f9fa;
}
&.selected {
background-color: rgba(102, 126, 234, 0.05);
.area-name {
color: #667eea;
font-weight: 500;
}
.area-code {
color: #667eea;
}
}
.area-info {
display: flex;
flex-direction: column;
gap: 8rpx;
.area-name {
font-size: 32rpx;
color: #333333;
}
.area-code {
font-size: 28rpx;
color: #666666;
}
}
:deep(.check-icon) {
font-size: 32rpx;
color: #667eea;
}
}
}
.sheet-footer {
padding: 30rpx 40rpx 40rpx 40rpx;
border-top: 1rpx solid #f0f0f0;
:deep(.confirm-btn) {
width: 100%;
height: 88rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
}
}
}
</style>
@@ -0,0 +1,878 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "注册"
}
}
</route>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { register, sendSmsCode } from '@/api/auth'
import { useConfigStore } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast'
// 获取屏幕边界到安全区域距离
let safeAreaInsets
let systemInfo
// #ifdef MP-WEIXIN
// 微信小程序使用新的API
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
// 其他平台继续使用uni API
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
// 注册表单数据
interface RegisterData {
username: string
password: string
confirmPassword: string
captcha: string
captchaId: string
areaCode: string
mobile: string
mobileCaptcha: string
}
const formData = ref<RegisterData>({
username: '',
password: '',
confirmPassword: '',
captcha: '',
captchaId: '',
areaCode: '+86',
mobile: '',
mobileCaptcha: '',
})
// 验证码图片
const captchaImage = ref('')
const loading = ref(false)
const smsLoading = ref(false)
const smsCountdown = ref(0)
// 注册方式:'username' | 'mobile'
const registerType = ref<'username' | 'mobile'>('username')
// 获取配置store
const configStore = useConfigStore()
// 区号选择相关
const showAreaCodeSheet = ref(false)
const selectedAreaCode = ref('+86')
const selectedAreaName = ref('中国大陆')
// 计算属性:是否启用手机号注册
const enableMobileRegister = computed(() => {
return configStore.config.enableMobileRegister
})
// 计算属性:区号列表
const areaCodeList = computed(() => {
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
})
// 切换注册方式
function toggleRegisterType() {
registerType.value = registerType.value === 'username' ? 'mobile' : 'username'
// 清空输入框
formData.value.username = ''
formData.value.mobile = ''
formData.value.mobileCaptcha = ''
}
// 打开区号选择弹窗
function openAreaCodeSheet() {
showAreaCodeSheet.value = true
}
// 选择区号
function selectAreaCode(item: { name: string, key: string }) {
selectedAreaCode.value = item.key
selectedAreaName.value = item.name
formData.value.areaCode = item.key
showAreaCodeSheet.value = false
}
// 关闭区号选择弹窗
function closeAreaCodeSheet() {
showAreaCodeSheet.value = false
}
// 生成UUID
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
// 获取验证码
async function refreshCaptcha() {
const uuid = generateUUID()
formData.value.captchaId = uuid
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
}
// 发送短信验证码
async function sendSmsVerification() {
if (!formData.value.mobile) {
toast.warning('请输入手机号')
return
}
if (!formData.value.captcha) {
toast.warning('请输入图形验证码')
return
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号')
return
}
try {
smsLoading.value = true
await sendSmsCode({
phone: `${selectedAreaCode.value}${formData.value.mobile}`,
captcha: formData.value.captcha,
captchaId: formData.value.captchaId,
})
toast.success('验证码发送成功')
// 开始倒计时
smsCountdown.value = 60
const timer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
}
catch (error: any) {
// 发送失败重新获取图形验证码
refreshCaptcha()
}
finally {
smsLoading.value = false
}
}
// 注册
async function handleRegister() {
// 表单验证
if (registerType.value === 'username') {
if (!formData.value.username) {
toast.warning('请输入用户名')
return
}
}
else {
if (!formData.value.mobile) {
toast.warning('请输入手机号')
return
}
// 手机号格式验证
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(formData.value.mobile)) {
toast.warning('请输入正确的手机号')
return
}
if (!formData.value.mobileCaptcha) {
toast.warning('请输入短信验证码')
return
}
}
if (!formData.value.password) {
toast.warning('请输入密码')
return
}
if (!formData.value.confirmPassword) {
toast.warning('请确认密码')
return
}
if (formData.value.password !== formData.value.confirmPassword) {
toast.warning('两次输入的密码不一致')
return
}
if (!formData.value.captcha) {
toast.warning('请输入验证码')
return
}
try {
loading.value = true
// 构建注册数据
const registerData = {
username: registerType.value === 'mobile' ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username,
password: formData.value.password,
confirmPassword: formData.value.confirmPassword,
captcha: formData.value.captcha,
captchaId: formData.value.captchaId,
areaCode: formData.value.areaCode,
mobile: formData.value.mobile,
mobileCaptcha: formData.value.mobileCaptcha,
}
await register(registerData)
toast.success('注册成功')
// 跳转到登录页
setTimeout(() => {
uni.navigateBack()
}, 1000)
}
catch (error: any) {
// 注册失败重新获取验证码
refreshCaptcha()
}
finally {
loading.value = false
}
}
// 返回登录
function goBack() {
uni.navigateBack()
}
// 页面加载时获取验证码
onLoad(() => {
refreshCaptcha()
})
// 组件挂载时确保配置已加载
onMounted(async () => {
if (!configStore.config.name) {
try {
await configStore.fetchPublicConfig()
}
catch (error) {
console.error('获取配置失败:', error)
}
}
})
</script>
<template>
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
<view class="header">
<view class="back-button" @click="goBack">
<wd-icon name="arrow-left" custom-class="back-icon" />
</view>
<view class="logo-section">
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
<text class="welcome-text">
欢迎注册
</text>
<text class="subtitle">
创建您的新账户
</text>
</view>
</view>
<view class="form-container">
<view class="form">
<!-- 手机号注册 -->
<template v-if="registerType === 'mobile'">
<view class="input-group">
<view class="input-wrapper mobile-wrapper">
<view class="area-code-selector" @click="openAreaCodeSheet">
<text class="area-code-text">
{{ selectedAreaCode }}
</text>
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
</view>
<view class="mobile-input-wrapper">
<wd-input
v-model="formData.mobile"
custom-class="styled-input"
no-border
placeholder="请输入手机号码"
type="number"
:maxlength="11"
/>
</view>
</view>
</view>
</template>
<!-- 用户名注册 -->
<template v-else>
<view class="input-group">
<view class="input-wrapper">
<wd-input
v-model="formData.username"
custom-class="styled-input"
no-border
placeholder="请输入用户名"
/>
</view>
</view>
</template>
<view class="input-group">
<view class="input-wrapper">
<wd-input
v-model="formData.password"
custom-class="styled-input"
no-border
placeholder="请输入密码"
show-password
:maxlength="20"
/>
</view>
</view>
<view class="input-group">
<view class="input-wrapper">
<wd-input
v-model="formData.confirmPassword"
custom-class="styled-input"
no-border
placeholder="请确认密码"
show-password
:maxlength="20"
/>
</view>
</view>
<view class="input-group">
<view class="input-wrapper captcha-wrapper">
<wd-input
v-model="formData.captcha"
custom-class="styled-input"
no-border
placeholder="请输入验证码"
:maxlength="6"
/>
<view class="captcha-image" @click="refreshCaptcha">
<image :src="captchaImage" class="captcha-img" />
</view>
</view>
</view>
<!-- 手机验证码输入框 -->
<view v-if="registerType === 'mobile'" class="input-group">
<view class="input-wrapper sms-wrapper">
<wd-input
v-model="formData.mobileCaptcha"
custom-class="styled-input"
no-border
placeholder="请输入短信验证码"
type="number"
:maxlength="6"
/>
<wd-button
:loading="smsLoading"
:disabled="smsCountdown > 0"
custom-class="sms-btn"
@click="sendSmsVerification"
>
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
</wd-button>
</view>
</view>
<view
class="register-btn"
type="primary"
:loading="loading"
@click="handleRegister"
>
{{ loading ? '注册中...' : '注册' }}
</view>
<view class="login-hint">
<text class="hint-text">
已有账户
</text>
<text class="login-link" @click="goBack">
立即登录
</text>
</view>
<!-- 注册方式切换 -->
<view v-if="enableMobileRegister" class="register-type-switch">
<view class="switch-tabs">
<view
class="switch-tab"
:class="{ active: registerType === 'username' }"
@click="toggleRegisterType"
>
<wd-icon name="user" />
</view>
<view
class="switch-tab"
:class="{ active: registerType === 'mobile' }"
@click="toggleRegisterType"
>
<wd-icon name="phone" />
</view>
</view>
</view>
</view>
</view>
<!-- 区号选择弹窗 -->
<wd-action-sheet
v-model="showAreaCodeSheet"
title="选择国家/地区"
:close-on-click-modal="true"
@close="closeAreaCodeSheet"
>
<view class="area-code-sheet">
<scroll-view scroll-y class="area-code-list">
<view
v-for="item in areaCodeList"
:key="item.key"
class="area-code-item"
:class="{ selected: selectedAreaCode === item.key }"
@click="selectAreaCode(item)"
>
<view class="area-info">
<text class="area-name">
{{ item.name }}
</text>
<text class="area-code">
{{ item.key }}
</text>
</view>
<wd-icon
v-if="selectedAreaCode === item.key"
name="check"
custom-class="check-icon"
/>
</view>
</scroll-view>
<view class="sheet-footer">
<wd-button
type="primary"
custom-class="confirm-btn"
@click="closeAreaCodeSheet"
>
确认
</wd-button>
</view>
</view>
</wd-action-sheet>
</view>
</template>
<style lang="scss" scoped>
.app-container {
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 100vh;
&::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
animation: float 6s ease-in-out infinite;
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
.header {
flex: 0 0 auto;
min-height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 15% 0 40rpx 0;
position: relative;
.back-button {
position: absolute;
left: 40rpx;
top: 60rpx;
width: 60rpx;
height: 60rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
:deep(.back-icon) {
font-size: 32rpx;
color: #ffffff;
}
}
.logo-section {
text-align: center;
.logo {
margin-bottom: 20rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
.welcome-text {
display: block;
color: #ffffff;
font-size: 40rpx;
font-weight: 600;
margin-bottom: 12rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.subtitle {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 26rpx;
font-weight: 400;
}
}
}
.form-container {
padding: 0 40rpx 40rpx 40rpx;
flex: 1;
display: flex;
align-items: flex-start;
.form {
width: 100%;
background: rgba(255, 255, 255, 0.95);
border-radius: 24rpx;
padding: 40rpx 30rpx 30rpx 30rpx;
backdrop-filter: blur(10rpx);
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
border: 1rpx solid rgba(255, 255, 255, 0.2);
max-height: calc(100vh - 350rpx);
overflow-y: auto;
.input-group {
margin-bottom: 24rpx;
.input-wrapper {
position: relative;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
transition: all 0.3s ease;
display: flex;
align-items: center;
&:focus-within {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
&.captcha-wrapper {
.captcha-image {
margin-left: 20rpx;
width: 120rpx;
height: 60rpx;
border-radius: 8rpx;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #e9ecef;
border: 1rpx solid #ddd;
.captcha-img {
width: 100%;
height: 100%;
}
}
}
&.sms-wrapper {
:deep(.sms-btn) {
margin-left: 20rpx;
padding: 0 20rpx;
height: 60rpx;
border-radius: 8rpx;
font-size: 24rpx;
white-space: nowrap;
min-width: 140rpx;
}
}
&.mobile-wrapper {
padding: 0;
background: transparent;
border: none;
display: flex;
gap: 20rpx;
.area-code-selector {
flex: 0 0 160rpx;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
height: 45rpx;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
.area-code-text {
font-size: 28rpx;
color: #333333;
font-weight: 500;
}
:deep(.area-code-arrow) {
font-size: 24rpx;
color: #999999;
transition: transform 0.3s ease;
}
}
.mobile-input-wrapper {
flex: 1;
background: #f8f9fa;
border-radius: 16rpx;
padding: 20rpx 16rpx;
border: 2rpx solid #e9ecef;
transition: all 0.3s ease;
&:focus-within {
border-color: #667eea;
background: #ffffff;
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
}
}
}
:deep(.styled-input) {
background: transparent !important;
border: none !important;
outline: none !important;
font-size: 32rpx;
color: #333333;
flex: 1;
&::placeholder {
color: #999999;
font-size: 28rpx;
}
}
}
}
:deep(.register-btn) {
width: 100%;
height: 80rpx;
border: none;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
margin-bottom: 30rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
text-align: center;
line-height: 80rpx;
&:active {
transform: translateY(2rpx);
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.login-hint {
text-align: center;
margin-bottom: 30rpx;
.hint-text {
color: #666666;
font-size: 26rpx;
margin-right: 8rpx;
}
.login-link {
color: #667eea;
font-size: 26rpx;
font-weight: 500;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.register-type-switch {
margin-top: 20rpx;
text-align: center;
.switch-tabs {
display: flex;
justify-content: center;
gap: 60rpx;
margin-bottom: 20rpx;
.switch-tab {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2rpx solid transparent;
&.active {
background: #667eea;
color: #ffffff;
border-color: #667eea;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
}
:deep(.wd-icon) {
font-size: 24rpx;
}
}
}
.switch-hint {
font-size: 24rpx;
color: #666666;
}
}
}
}
// 区号选择弹窗样式
.area-code-sheet {
background: #ffffff;
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
.area-code-list {
max-height: 60vh;
padding: 0 40rpx;
.area-code-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 0;
border-bottom: 1rpx solid #f8f9fa;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #f8f9fa;
}
&.selected {
background-color: rgba(102, 126, 234, 0.05);
.area-name {
color: #667eea;
font-weight: 500;
}
.area-code {
color: #667eea;
}
}
.area-info {
display: flex;
flex-direction: column;
gap: 8rpx;
.area-name {
font-size: 32rpx;
color: #333333;
}
.area-code {
font-size: 28rpx;
color: #666666;
}
}
:deep(.check-icon) {
font-size: 32rpx;
color: #667eea;
}
}
}
.sheet-footer {
padding: 30rpx 40rpx 40rpx 40rpx;
border-top: 1rpx solid #f0f0f0;
:deep(.confirm-btn) {
width: 100%;
height: 88rpx;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 600;
}
}
}
</style>
@@ -0,0 +1,307 @@
<route lang="jsonc" type="page">{
"layout": "default",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "custom"
}
}</route>
<script lang="ts" setup>
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
import { isMp } from '@/utils/platform'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
defineOptions({
name: 'SettingsPage',
})
const toast = useToast()
// 缓存信息
const cacheInfo = reactive({
storageSize: '0MB',
imageCache: '0MB',
dataCache: '0MB',
})
// 服务端地址设置
const baseUrlInput = ref('')
// 系统信息(保留)
const systemInfo = computed(() => {
const info = uni.getSystemInfoSync()
return `${info.platform} ${info.system}`
})
// 读取本地覆盖地址
function loadServerBaseUrl() {
const override = getServerBaseUrlOverride()
baseUrlInput.value = override || getEnvBaseUrl()
}
// 获取缓存信息
function getCacheInfo() {
try {
const info = uni.getStorageInfoSync()
const totalSize = (info.currentSize || 0) / 1024 // KB to MB
cacheInfo.storageSize = `${totalSize.toFixed(2)}MB`
}
catch (error) {
console.error('获取缓存信息失败:', error)
}
}
// 保存服务端地址
function saveServerBaseUrl() {
if (!baseUrlInput.value || !/^https?:\/\//.test(baseUrlInput.value)) {
toast.warning('请输入有效的服务端地址(以 http 或 https 开头)')
return
}
setServerBaseUrlOverride(baseUrlInput.value)
// 切换请求地址后清空所有缓存
clearAllCacheAfterUrlChange()
uni.showModal({
title: '重启应用',
content: '服务端地址已保存并清空缓存,是否立即重启生效?',
confirmText: '立即重启',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
restartApp()
}
else {
toast.success('已保存,可稍后手动重启应用')
}
},
})
}
// 重置为 env 默认
function resetServerBaseUrl() {
clearServerBaseUrlOverride()
baseUrlInput.value = getEnvBaseUrl()
// 切换请求地址后清空所有缓存
clearAllCacheAfterUrlChange()
uni.showModal({
title: '重启应用',
content: '已重置为默认地址并清空缓存,是否立即重启生效?',
confirmText: '立即重启',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
restartApp()
}
else {
toast.success('已重置,可稍后手动重启应用')
}
},
})
}
// 重启应用(App 原生重启;其他端回到首页)
function restartApp() {
// #ifdef APP-PLUS
plus.runtime.restart()
// #endif
// #ifndef APP-PLUS
uni.reLaunch({ url: '/pages/index/index' })
// #endif
}
// 切换地址后自动清空所有缓存
function clearAllCacheAfterUrlChange() {
try {
// 完全清空所有缓存,包括token
uni.clearStorageSync()
// 清空localStorageH5环境)
// #ifdef H5
if (typeof localStorage !== 'undefined') {
localStorage.clear()
}
// #endif
// 重新获取缓存信息
getCacheInfo()
}
catch (error) {
console.error('清除缓存失败:', error)
}
}
// 清除缓存
async function clearCache() {
try {
uni.showModal({
title: '确认清除',
content: '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。',
success: (res) => {
if (res.confirm) {
clearAllCacheAfterUrlChange()
toast.success('缓存清除成功,即将跳转到登录页')
// 延迟跳转到登录页
setTimeout(() => {
uni.reLaunch({ url: '/pages/login/index' })
}, 1500)
}
},
})
}
catch (error) {
console.error('清除缓存失败:', error)
toast.error('清除缓存失败')
}
}
// 关于我们
function showAbout() {
uni.showModal({
title: `关于${import.meta.env.VITE_APP_TITLE}`,
content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 xiaozhi-esp32-server`,
showCancel: false,
confirmText: '确定',
})
}
onMounted(async () => {
// 仅在非小程序环境加载服务端地址设置
if (!isMp) {
loadServerBaseUrl()
}
getCacheInfo()
})
</script>
<template>
<view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设置" placeholder safe-area-inset-top fixed />
<view class="p-[24rpx]">
<!-- 网络设置 - 仅在非小程序环境显示 -->
<view v-if="!isMp" class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold">
网络设置
</text>
</view>
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
<view class="mb-[24rpx]">
<text class="text-[28rpx] text-[#232338] font-semibold">
服务端接口地址
</text>
<text class="mt-[8rpx] block text-[24rpx] text-[#9d9ea3]">
修改后将自动清空缓存并重启应用
</text>
</view>
<view class="mb-[24rpx]">
<input v-model="baseUrlInput"
class="h-[88rpx] w-full border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] px-[24rpx] text-[28rpx] text-[#232338] transition-all focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3] focus:shadow-[0_0_0_4rpx_rgba(51,108,255,0.1)]"
type="text" placeholder="输入服务端地址,如 https://example.com/api">
</view>
<view class="flex gap-[16rpx]">
<wd-button type="primary"
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-[#336cff] border-none shadow-[0_4rpx_16rpx_rgba(51,108,255,0.3)] active:shadow-[0_2rpx_8rpx_rgba(51,108,255,0.4)] active:scale-98"
@click="saveServerBaseUrl">
保存设置
</wd-button>
<wd-button type="default"
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-white border-[#eeeeee] text-[#65686f] active:bg-[#f5f7fb]"
@click="resetServerBaseUrl">
恢复默认
</wd-button>
</view>
</view>
</view>
<!-- 缓存管理 -->
<view class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold">
缓存管理
</text>
</view>
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
<view class="space-y-[16rpx]">
<!-- 缓存信息展示参考插件样式 -->
<view
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]">
<view>
<text class="text-[28rpx] text-[#232338] font-medium">
总缓存大小
</text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
应用数据总大小
</text>
</view>
<text class="text-[28rpx] text-[#65686f] font-semibold">
{{ cacheInfo.storageSize }}
</text>
</view>
<!-- 清除缓存按钮参考插件编辑按钮样式 -->
<view
class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]">
<view>
<text class="text-[28rpx] text-[#232338] font-medium">
缓存清理
</text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
清空所有缓存数据
</text>
</view>
<view
class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white"
@click="clearCache">
清除缓存
</view>
</view>
</view>
</view>
</view>
<!-- 应用信息 -->
<view class="mb-[32rpx]">
<view class="mb-[24rpx] flex items-center">
<text class="text-[32rpx] text-[#232338] font-bold">
应用信息
</text>
</view>
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]"
style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
<view
class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]"
@click="showAbout">
<view>
<text class="text-[28rpx] text-[#232338] font-medium">
关于我们
</text>
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
应用版本与团队信息
</text>
</view>
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
</view>
</view>
</view>
<!-- 底部安全距离 -->
<view style="height: env(safe-area-inset-bottom);" />
</view>
</view>
</template>
<style lang="scss" scoped>
// 保持与 edit.vue 一致的风格,样式主要通过类名控制</style>
@@ -0,0 +1,512 @@
<script lang="ts" setup>
import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprint'
import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'wot-design-uni'
import { useToast } from 'wot-design-uni/components/wd-toast'
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
defineOptions({
name: 'VoicePrintManage',
})
// 接收props
interface Props {
agentId?: string
}
const props = withDefaults(defineProps<Props>(), {
agentId: 'default'
})
// 获取屏幕边界到安全区域距离
let safeAreaInsets: any
let systemInfo: any
// #ifdef MP-WEIXIN
systemInfo = uni.getWindowInfo()
safeAreaInsets = systemInfo.safeArea
? {
top: systemInfo.safeArea.top,
right: systemInfo.windowWidth - systemInfo.safeArea.right,
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
left: systemInfo.safeArea.left,
}
: null
// #endif
// #ifndef MP-WEIXIN
systemInfo = uni.getSystemInfoSync()
safeAreaInsets = systemInfo.safeAreaInsets
// #endif
const message = useMessage()
const toast = useToast()
// 页面数据
const voicePrintList = ref<VoicePrint[]>([])
const chatHistoryList = ref<ChatHistory[]>([])
const chatHistoryActions = ref<any[]>([])
const swipeStates = ref<Record<string, 'left' | 'close' | 'right'>>({})
const loading = ref(false)
// 使用传入的智能体ID
const currentAgentId = computed(() => {
return props.agentId
})
// 智能体选择相关功能已移除
// 弹窗相关
const showAddDialog = ref(false)
const showEditDialog = ref(false)
const showChatHistoryDialog = ref(false)
const addForm = ref<CreateSpeakerData>({
agentId: '',
audioId: '',
sourceName: '',
introduce: '',
})
const editForm = ref<VoicePrint>({
id: '',
audioId: '',
sourceName: '',
introduce: '',
createDate: '',
})
// 获取声纹列表
async function loadVoicePrintList() {
try {
console.log('获取声纹列表')
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
voicePrintList.value = []
return
}
loading.value = true
const data = await getVoicePrintList(currentAgentId.value)
// 初始化滑动状态
const list = data || []
list.forEach((item) => {
if (!swipeStates.value[item.id]) {
swipeStates.value[item.id] = 'close'
}
})
voicePrintList.value = list
}
catch (error) {
console.error('获取声纹列表失败:', error)
voicePrintList.value = []
}
finally {
loading.value = false
}
}
// 暴露给父组件的刷新方法
async function refresh() {
await loadVoicePrintList()
}
// 获取语音对话记录
async function loadChatHistory() {
try {
if (!currentAgentId.value) {
toast.error('请先选择智能体')
return
}
const data = await getChatHistory(currentAgentId.value)
chatHistoryList.value = data || []
// 转换为ActionSheet格式
chatHistoryActions.value = chatHistoryList.value.map((item, index) => ({
name: item.content,
audioId: item.audioId,
index,
}))
showChatHistoryDialog.value = true
}
catch (error) {
console.error('获取对话记录失败:', error)
toast.error('获取对话记录失败')
}
}
// 打开添加弹窗
function openAddDialog() {
if (!currentAgentId.value) {
toast.error('请先选择智能体')
return
}
addForm.value = {
agentId: currentAgentId.value,
audioId: '',
sourceName: '',
introduce: '',
}
showAddDialog.value = true
}
// 打开编辑弹窗
function openEditDialog(item: VoicePrint) {
editForm.value = { ...item }
showEditDialog.value = true
}
// 获取选中音频的显示内容
function getSelectedAudioContent(audioId: string) {
if (!audioId)
return '点击选择声纹向量'
const chatItem = chatHistoryList.value.find(item => item.audioId === audioId)
return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...`
}
// 选择声纹向量
function selectAudioId({ item }: { item: any }) {
if (showAddDialog.value) {
addForm.value.audioId = item.audioId
}
else if (showEditDialog.value) {
editForm.value.audioId = item.audioId
}
showChatHistoryDialog.value = false
}
// 提交添加说话人
async function submitAdd() {
if (!addForm.value.sourceName.trim()) {
toast.error('请输入姓名')
return
}
if (!addForm.value.audioId) {
toast.error('请选择声纹向量')
return
}
try {
await createVoicePrint(addForm.value)
toast.success('添加成功')
showAddDialog.value = false
await loadVoicePrintList()
}
catch (error) {
console.error('添加说话人失败:', error)
toast.error('添加说话人失败')
}
}
// 提交编辑说话人
async function submitEdit() {
if (!editForm.value.sourceName.trim()) {
toast.error('请输入姓名')
return
}
if (!editForm.value.audioId) {
toast.error('请选择声纹向量')
return
}
try {
await updateVoicePrint({
id: editForm.value.id,
audioId: editForm.value.audioId,
sourceName: editForm.value.sourceName,
introduce: editForm.value.introduce,
createDate: editForm.value.createDate,
})
toast.success('编辑成功')
showEditDialog.value = false
await loadVoicePrintList()
}
catch (error) {
console.error('编辑说话人失败:', error)
toast.error('编辑说话人失败')
}
}
// 处理编辑操作
function handleEdit(item: VoicePrint) {
openEditDialog(item)
swipeStates.value[item.id] = 'close'
}
// 删除声纹
async function handleDelete(id: string) {
message.confirm({
msg: '确定要删除这个说话人吗?',
title: '确认删除',
}).then(async () => {
await deleteVoicePrint(id)
toast.success('删除成功')
await loadVoicePrintList()
}).catch(() => {
console.log('点击了取消按钮')
})
}
onMounted(async () => {
// 智能体已简化为默认
loadVoicePrintList()
})
// 暴露方法给父组件
defineExpose({
refresh,
})
</script>
<template>
<view class="voiceprint-container" style="background: #f5f7fb; min-height: 100%;">
<!-- 加载状态 -->
<view v-if="loading && voicePrintList.length === 0" class="loading-container">
<wd-loading color="#336cff" />
<text class="loading-text">
加载中...
</text>
</view>
<!-- 声纹列表 -->
<view v-else-if="voicePrintList.length > 0" class="voiceprint-list">
<!-- 声纹卡片列表 -->
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
<view v-for="item in voicePrintList" :key="item.id">
<wd-swipe-action
:model-value="swipeStates[item.id] || 'close'"
@update:model-value="swipeStates[item.id] = $event"
>
<view class="bg-[#fbfbfb] p-[32rpx]" @click="handleEdit(item)">
<view>
<text class="mb-[12rpx] block text-[32rpx] text-[#232338] font-semibold">
{{ item.sourceName }}
</text>
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
{{ item.introduce || '暂无描述' }}
</text>
<text class="block text-[24rpx] text-[#9d9ea3]">
{{ item.createDate }}
</text>
</view>
</view>
<template #right>
<view class="h-full flex">
<view
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
@click="handleDelete(item.id)"
>
<wd-icon name="delete" />
删除
</view>
</view>
</template>
</wd-swipe-action>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-else-if="!loading" class="empty-container">
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
<wd-icon name="voice" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
暂无声纹数据
</text>
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
点击右下角 + 号添加您的第一个说话人
</text>
</view>
</view>
<!-- 浮动操作按钮 -->
<wd-fab type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
<wd-icon name="add" />
</wd-fab>
<!-- MessageBox 组件 -->
<wd-message-box />
</view>
<!-- 添加说话人弹窗 -->
<wd-popup
v-model="showAddDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
safe-area-inset-bottom
>
<view>
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
添加说话人
</text>
</view>
<view class="p-[32rpx]">
<!-- 声纹向量选择 -->
<view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 声纹向量
</text>
<view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@click="loadChatHistory"
>
<text
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
:class="{ 'text-[#9d9ea3]': !addForm.audioId }"
>
{{ getSelectedAudioContent(addForm.audioId) }}
</text>
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
</view>
<!-- 姓名 -->
<view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 姓名
</text>
<input
v-model="addForm.sourceName"
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" placeholder="请输入姓名"
>
</view>
<!-- 描述 -->
<view>
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 描述
</text>
<textarea
v-model="addForm.introduce" :maxlength="100" placeholder="请输入描述"
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/>
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
{{ (addForm.introduce || '').length }}/100
</view>
</view>
</view>
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
<wd-button type="info" custom-class="flex-1" @click="showAddDialog = false">
取消
</wd-button>
<wd-button type="primary" custom-class="flex-1" @click="submitAdd">
保存
</wd-button>
</view>
</view>
</wd-popup>
<!-- 编辑说话人弹窗 -->
<wd-popup
v-model="showEditDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
safe-area-inset-bottom
>
<view>
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
编辑说话人
</text>
</view>
<view class="p-[32rpx]">
<!-- 声纹向量选择 -->
<view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 声纹向量
</text>
<view
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
@click="loadChatHistory"
>
<text
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
:class="{ 'text-[#9d9ea3]': !editForm.audioId }"
>
{{ getSelectedAudioContent(editForm.audioId) }}
</text>
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
</view>
</view>
<!-- 姓名 -->
<view class="mb-[32rpx]">
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 姓名
</text>
<input
v-model="editForm.sourceName"
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
type="text" placeholder="请输入姓名"
>
</view>
<!-- 描述 -->
<view>
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
* 描述
</text>
<textarea
v-model="editForm.introduce" :maxlength="100" placeholder="请输入描述"
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
/>
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
{{ (editForm.introduce || '').length }}/100
</view>
</view>
</view>
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
<wd-button type="info" custom-class="flex-1" @click="showEditDialog = false">
取消
</wd-button>
<wd-button type="primary" custom-class="flex-1" @click="submitEdit">
保存
</wd-button>
</view>
</view>
</wd-popup>
<!-- 语音对话记录选择动作面板 -->
<wd-action-sheet
v-model="showChatHistoryDialog" :actions="chatHistoryActions" title="选择声纹向量"
@select="selectAudioId"
/>
</template>
<style scoped>
.voiceprint-container {
position: relative;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #666666;
}
:deep(.wd-swipe-action) {
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #eeeeee;
}
:deep(.flex-1) {
flex: 1;
}
</style>
@@ -0,0 +1,66 @@
/**
* by 菲鸽 on 2024-03-06
* 路由拦截,通常也是登录拦截
* 可以设置路由白名单,或者黑名单,看业务需要选哪一个
* 我这里应为大部分都可以随便进入,所以使用黑名单
*/
import { useUserStore } from '@/store'
import { needLoginPages as _needLoginPages, getLastPage, getNeedLoginPages } from '@/utils'
// TODO Check
const loginRoute = import.meta.env.VITE_LOGIN_URL
function isLogined() {
const userStore = useUserStore()
return !!userStore.userInfo.username
}
const isDev = import.meta.env.DEV
// 黑名单登录拦截器 - (适用于大部分页面不需要登录,少部分页面需要登录)
const navigateToInterceptor = {
// 注意,这里的url是 '/' 开头的,如 '/pages/index/index',跟 'pages.json' 里面的 path 不同
// 增加对相对路径的处理,BY 网友 @ideal
invoke({ url }: { url: string }) {
// console.log(url) // /pages/route-interceptor/index?name=feige&age=30
let path = url.split('?')[0]
console.log('页面变动')
// 处理相对路径
if (!path.startsWith('/')) {
const currentPath = getLastPage().route
const normalizedCurrentPath = currentPath.startsWith('/') ? currentPath : `/${currentPath}`
const baseDir = normalizedCurrentPath.substring(0, normalizedCurrentPath.lastIndexOf('/'))
path = `${baseDir}/${path}`
}
let needLoginPages: string[] = []
// 为了防止开发时出现BUG,这里每次都获取一下。生产环境可以移到函数外,性能更好
if (isDev) {
needLoginPages = getNeedLoginPages()
}
else {
needLoginPages = _needLoginPages
}
const isNeedLogin = needLoginPages.includes(path)
if (!isNeedLogin) {
return true
}
const hasLogin = isLogined()
if (hasLogin) {
return true
}
const redirectRoute = `${loginRoute}?redirect=${encodeURIComponent(url)}`
uni.navigateTo({ url: redirectRoute })
return false
},
}
export const routeInterceptor = {
install() {
uni.addInterceptor('navigateTo', navigateToInterceptor)
uni.addInterceptor('reLaunch', navigateToInterceptor)
uni.addInterceptor('redirectTo', navigateToInterceptor)
uni.addInterceptor('switchTab', navigateToInterceptor)
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.39 113.39">
<defs>
<style>
.cls-1 {
fill: none;
}
.cls-2 {
fill: #d14328;
}
.cls-3 {
fill: #2c8d3a;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<rect class="cls-1" width="113.39" height="113.39" />
<g>
<path class="cls-3"
d="M86.31,11.34H25.08c-8.14,0-14.74,6.6-14.74,14.74v61.23c0,8.14,6.6,14.74,14.74,14.74h61.23c.12,0,.24-.02,.37-.02-9.76-.2-17.64-8.18-17.64-17.99,0-.56,.03-1.12,.08-1.67H34.1c-1.57,0-2.83-1.27-2.83-2.83V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v36.52c0,.78,.63,1.42,1.42,1.42h22.02c.78,0,1.42-.63,1.42-1.42V32.43c0-.78,.63-1.42,1.42-1.42h9.17c.78,0,1.42,.63,1.42,1.42v34.99c2.13-.89,4.47-1.39,6.92-1.39,5.66,0,10.7,2.63,14.01,6.72V26.08c0-8.14-6.6-14.74-14.74-14.74Z" />
<g>
<path class="cls-2"
d="M87.04,68.03c-8.83,0-16.01,7.18-16.01,16.01s7.18,16.01,16.01,16.01,16.01-7.18,16.01-16.01-7.18-16.01-16.01-16.01Zm-.27,24.84h-7.2v-3h1.18v-10.48h4.58v2.81h1.42c.84,0,1.46-.16,1.88-.48s.62-.87,.62-1.64c0-.69-.25-1.17-.74-1.45s-1.19-.42-2.09-.42h-6.84v-3h7.2c2.38,0,4.15,.38,5.31,1.15,1.16,.77,1.74,1.93,1.74,3.48,0,1.71-.83,2.93-2.5,3.64,1.07,.4,1.87,.95,2.39,1.65s.79,1.56,.79,2.58c0,3.44-2.58,5.16-7.73,5.16Z" />
<path class="cls-2"
d="M86.49,85.17h-1.16v4.7h1.8c.81,0,1.46-.18,1.94-.55s.72-.95,.72-1.73c0-.86-.25-1.48-.74-1.85s-1.35-.56-2.56-.56Z" />
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Some files were not shown because too many files have changed in this diff Show More