feat: 初始化移动端项目

This commit is contained in:
Junsen
2025-08-08 17:35:35 +08:00
parent 2f3c466833
commit 90f7af7afa
134 changed files with 26790 additions and 0 deletions
+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>
@@ -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,
},
})
}
@@ -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,57 @@
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,
},
})
}
/**
* 获取聊天记录详情
* @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,
},
})
}
+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 }
}
@@ -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,
})
}
@@ -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,119 @@
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 { 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: import.meta.env.VITE_SERVER_BASEURL,
...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,97 @@
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/device.png',
selectedIconPath: 'static/tabbar/device_activate.png',
pagePath: 'pages/device/index',
text: '设备管理',
icon: 'i-carbon-laptop',
// 注意 unocss 的图标需要在 页面上引入一下,或者配置到 unocss.config.ts 的 safelist 中
iconType: 'uiLib',
},
{
iconPath: 'static/tabbar/microphone.png',
selectedIconPath: 'static/tabbar/microphone_activate.png',
pagePath: 'pages/voiceprint/index',
text: '声纹识别',
icon: 'i-carbon-microphone-filled',
iconType: 'uiLib',
},
{
iconPath: 'static/tabbar/chat.png',
selectedIconPath: 'static/tabbar/chat_activate.png',
pagePath: 'pages/chat-history/index',
text: '聊天记录',
icon: 'i-carbon-chat-bot',
iconType: 'uiLib',
},
// {
// pagePath: 'pages/my/index',
// text: '我的',
// icon: '/static/logo.svg',
// iconType: 'local',
// },
// {
// pagePath: 'pages/mine/index',
// text: '我的',
// icon: 'iconfont icon-my',
// iconType: 'iconfont',
// },
]
// 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,
}
}
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,726 @@
<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="page-container">
<!-- 头部导航 -->
<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="content-scroll-view box-border 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="plugin-scroll-view"
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="plugin-scroll-view" 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="plugin-scroll-view" 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="param-scroll-container"
:style="{ height: 'calc(75vh - 60rpx)' }"
>
<view class="param-content">
<!-- 无参数提示 -->
<view
v-if="
!currentFunction?.fieldsMeta
|| currentFunction.fieldsMeta.length === 0
"
class="empty-params"
>
<text class="empty-text">
{{ currentFunction?.name }} 无需配置参数
</text>
</view>
<!-- 参数表单 - 卡片式布局 -->
<view v-else class="param-cards">
<view
v-for="field in currentFunction.fieldsMeta"
:key="field.key"
class="param-card"
>
<!-- 字段信息 -->
<view class="field-info">
<text class="field-label">
{{ field.label }}
</text>
<text v-if="getFieldRemark(field)" class="field-desc">
{{ getFieldRemark(field) }}
</text>
</view>
<!-- 输入控件 -->
<view class="field-input-container">
<!-- 字符串类型 -->
<input
v-if="field.type === 'string'"
v-model="tempParams[field.key]"
class="field-input"
type="text"
:placeholder="`请输入${field.label}`"
@input="
handleParamChange(field.key, $event.detail.value, field)
"
>
<!-- 数组类型 -->
<view v-else-if="field.type === 'array'" class="array-field">
<text class="field-hint">
每行输入一个项目
</text>
<textarea
v-model="arrayTextCache[field.key]"
class="field-textarea"
:placeholder="`请输入${field.label},每行一个`"
@input="
handleArrayChange(field.key, $event.detail.value, field)
"
/>
</view>
<!-- JSON类型 -->
<view v-else-if="field.type === 'json'" class="json-field">
<text class="field-hint">
请输入有效的JSON格式
</text>
<textarea
v-model="jsonTextCache[field.key]"
class="field-textarea json-textarea"
placeholder="请输入合法的JSON格式"
@blur="
handleJsonChange(field.key, $event.detail.value, field)
"
/>
</view>
<!-- 数字类型 -->
<input
v-else-if="field.type === 'number'"
v-model="tempParams[field.key]"
class="field-input"
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="switch-field"
>
<view class="switch-info">
<text class="switch-label">
启用功能
</text>
<text class="switch-desc">
开启或关闭此功能
</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="field-input"
type="text"
:placeholder="`请输入${field.label}`"
@input="
handleParamChange(field.key, $event.detail.value, field)
"
>
</view>
</view>
</view>
</view>
</scroll-view>
</wd-action-sheet>
</view>
</template>
<style lang="scss" scoped>
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f7fb;
}
.content-scroll-view {
flex: 1;
background: transparent;
}
// 插件列表滚动视图样式
.plugin-scroll-view {
max-height: 600rpx; // 最大高度为4个插件的高度
background: transparent;
}
// 参数编辑弹窗样式
.param-scroll-container {
background: #f5f7fb;
}
.param-content {
padding: 30rpx;
padding-bottom: 40rpx;
}
.empty-params {
display: flex;
justify-content: center;
align-items: center;
height: 400rpx;
.empty-text {
font-size: 28rpx;
color: #999;
}
}
.param-cards {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.param-card {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid #eeeeee;
}
.field-info {
margin-bottom: 24rpx;
.field-label {
display: block;
font-size: 32rpx;
font-weight: 500;
color: #232338;
margin-bottom: 8rpx;
}
.field-desc {
display: block;
font-size: 24rpx;
color: #65686f;
line-height: 1.5;
}
}
.field-input-container {
.field-input {
width: 100%;
padding: 16rpx 20rpx;
height: 80rpx;
background: #f5f7fb;
border-radius: 12rpx;
border: 1rpx solid #eeeeee;
font-size: 28rpx;
color: #232338;
box-sizing: border-box;
&:focus {
border-color: #336cff;
background: #ffffff;
}
&::placeholder {
color: #9d9ea3;
}
}
.field-textarea {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
background: #f5f7fb;
border-radius: 12rpx;
border: 1rpx solid #eeeeee;
font-size: 26rpx;
color: #232338;
line-height: 1.6;
box-sizing: border-box;
&:focus {
border-color: #336cff;
background: #ffffff;
}
&.json-textarea {
min-height: 300rpx;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
&::placeholder {
color: #9d9ea3;
}
}
}
.array-field,
.json-field {
.field-hint {
display: block;
font-size: 24rpx;
color: #65686f;
margin-bottom: 16rpx;
}
}
.switch-field {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
.switch-info {
flex: 1;
.switch-label {
display: block;
font-size: 28rpx;
color: #232338;
margin-bottom: 8rpx;
}
.switch-desc {
display: block;
font-size: 24rpx;
color: #65686f;
}
}
}
</style>
@@ -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 { useAgentStore } from '@/store'
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 agentStore = useAgentStore()
// 获取当前智能体信息
const currentAgent = computed(() => {
return agentStore.agentList.find(agent => agent.id === agentId.value)
})
// 聊天数据
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 = import.meta.env.VITE_SERVER_BASEURL
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,342 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "聊天记录"
}
}
</route>
<script lang="ts" setup>
import type { ChatSession } from '@/api/chat-history/types'
import { computed, onMounted, ref } from 'vue'
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { getChatSessions } from '@/api/chat-history/chat-history'
import { useAgentStore } from '@/store'
defineOptions({
name: 'ChatHistory',
})
// 获取屏幕边界到安全区域距离
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 pagingRef = ref()
useZPaging(pagingRef)
// 智能体管理
const agentStore = useAgentStore()
const showAgentPicker = ref(false)
// 获取当前选中的智能体ID
const currentAgentId = computed(() => {
return agentStore.currentAgentId
})
// 获取智能体选项
const agentOptions = computed(() => {
return agentStore.getAgentOptions
})
// 显示智能体选择器
function showAgentSelector() {
showAgentPicker.value = true
}
// 关闭智能体选择器
function closeAgentSelector() {
showAgentPicker.value = false
}
// 处理智能体切换
function handleAgentSwitch({ item }: { item: any }) {
const selectedAgentId = item.value
if (selectedAgentId !== currentAgentId.value) {
// 设置当前智能体
agentStore.setCurrentAgent(selectedAgentId)
// 刷新会话列表
pagingRef.value.reload()
}
closeAgentSelector()
}
// z-paging查询列表数据
async function queryList(pageNo: number, pageSize: number) {
try {
console.log('z-paging获取聊天会话列表')
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
pagingRef.value.complete([])
return
}
const response = await getChatSessions(currentAgentId.value, {
page: pageNo,
limit: pageSize,
})
// 使用z-paging的分页机制
if (pageNo === 1) {
pagingRef.value.complete(response.list, response.total)
}
else {
pagingRef.value.addData(response.list)
}
}
catch (error) {
console.error('获取聊天会话列表失败:', error)
pagingRef.value.complete(false)
}
}
// 格式化时间
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)}小时前`
if (diff < 604800000)
return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString()
}
// 进入聊天详情
function goToChatDetail(session: ChatSession) {
uni.navigateTo({
url: `/pages/chat-history/detail?sessionId=${session.sessionId}&agentId=${currentAgentId.value}`,
})
}
onMounted(async () => {
// 确保智能体列表已加载
if (!agentStore.isLoaded) {
await agentStore.loadAgentList()
}
})
</script>
<template>
<z-paging
ref="pagingRef"
v-model="sessionList"
:refresher-enabled="true"
:auto-show-back-to-top="true"
:loading-more-enabled="true"
:show-loading-more="true"
: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="navbar-section">
<!-- 状态栏背景 -->
<view class="status-bar" :style="{ height: `${safeAreaInsets?.top}px` }" />
<wd-navbar :title="agentStore.currentAgent?.agentName || '聊天记录'">
<template #right>
<wd-icon name="filter1" size="18" @click="showAgentSelector" />
</template>
</wd-navbar>
</view>
</template>
<!-- 聊天会话列表 -->
<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>
<!-- 自定义空状态 -->
<template #empty>
<view class="empty-state">
<wd-icon name="chat" custom-class="empty-icon" />
<text class="empty-text">
暂无聊天记录
</text>
<text class="empty-desc">
与智能体的对话记录会显示在这里
</text>
</view>
</template>
<!-- 智能体选择器 -->
<wd-action-sheet
v-model="showAgentPicker"
:actions="agentOptions"
title="选择智能体"
@close="closeAgentSelector"
@select="handleAgentSwitch"
/>
</z-paging>
</template>
<style lang="scss" scoped>
// z-paging内容区域样式
:deep(.z-paging-content) {
background: #f5f7fb;
}
.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,154 @@
<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)
}
function goBack() {
uni.navigateBack()
}
</script>
<template>
<view class="min-h-screen bg-[#f5f7fb]">
<wd-navbar title="设备配网" safe-area-inset-top>
<template #left>
<wd-icon name="arrow-left" size="18" @click="goBack" />
</template>
</wd-navbar>
<view class="box-border px-[20rpx]">
<!-- 配网方式选择 -->
<view class="pb-[20rpx] first:pt-[20rpx]">
<text class="text-[36rpx] 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-[36rpx] 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,367 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "设备管理"
}
}
</route>
<script lang="ts" setup>
import type { Device, FirmwareType } from '@/api/device'
import { computed, onMounted, ref } from 'vue'
import { useMessage } from 'wot-design-uni'
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { useAgentStore } from '@/store'
import { toast } from '@/utils/toast'
defineOptions({
name: 'DeviceManage',
})
// 获取屏幕边界到安全区域距离
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 pagingRef = ref()
useZPaging(pagingRef)
// 消息组件
const message = useMessage()
// 智能体管理
const agentStore = useAgentStore()
const showAgentPicker = ref(false)
// 获取当前选中的智能体ID
const currentAgentId = computed(() => {
return agentStore.currentAgentId
})
// 获取智能体选项
const agentOptions = computed(() => {
return agentStore.getAgentOptions
})
// 显示智能体选择器
function showAgentSelector() {
showAgentPicker.value = true
}
// 关闭智能体选择器
function closeAgentSelector() {
showAgentPicker.value = false
}
// 处理智能体切换
function handleAgentSwitch({ item }: { item: any }) {
const selectedAgentId = item.value
if (selectedAgentId !== currentAgentId.value) {
// 设置当前智能体
agentStore.setCurrentAgent(selectedAgentId)
// 刷新设备列表
pagingRef.value.reload()
}
closeAgentSelector()
}
// 获取设备列表
async function queryList() {
try {
console.log('获取设备列表')
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
pagingRef.value.complete([])
return
}
const response = await getBindDevices(currentAgentId.value)
pagingRef.value.complete(response)
}
catch (error) {
console.error('获取设备列表失败:', error)
pagingRef.value.complete(false)
}
}
// 获取设备类型名称
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)
pagingRef.value.reload()
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())
pagingRef.value.reload()
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 () => {
// 确保智能体列表已加载
if (!agentStore.isLoaded) {
await agentStore.loadAgentList()
}
loadFirmwareTypes()
})
onShow(() => {
if (pagingRef.value) {
pagingRef.value.reload()
}
})
</script>
<template>
<z-paging
ref="pagingRef" v-model="deviceList" :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="bg-white">
<!-- 状态栏背景 -->
<wd-navbar :title="agentStore.currentAgent?.agentName || '设备管理'" safe-area-inset-top>
<template #right>
<wd-icon name="filter1" size="18" @click="showAgentSelector" />
</template>
</wd-navbar>
</view>
</template>
<!-- 设备卡片列表 -->
<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>
<!-- 自定义空状态 -->
<template #empty>
<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>
</template>
<!-- FAB 绑定设备按钮 -->
<wd-fab type="primary" size="small" icon="add" :draggable="true" :expandable="false" @click="openBindDialog" />
<!-- MessageBox 组件 -->
<wd-message-box />
<!-- 智能体选择器 -->
<wd-action-sheet
v-model="showAgentPicker"
:actions="agentOptions"
title="选择智能体"
@close="closeAgentSelector"
@select="handleAgentSwitch"
/>
</z-paging>
</template>
<style>
:deep(.z-paging-content) {
background: #f5f7fb;
}
: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,599 @@
<!-- 使用 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 { useAgentStore } from '@/store'
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 agentStore = useAgentStore()
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()
// 将数据存入 Pinia store
agentStore.agentList = response
agentStore.isLoaded = true
// 如果没有当前选中的智能体,且列表不为空,选择第一个
console.log(!agentStore.currentAgentId && response.length > 0, agentStore.currentAgentId)
if (!agentStore.currentAgentId && response.length > 0) {
agentStore.setCurrentAgent(response[0].id)
}
// 直接返回全部数据,不需要分页处理
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) {
// 设置当前编辑的智能体
agentStore.setCurrentAgent(agent.id)
uni.navigateTo({
url: `/pages/agent/edit?id=${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)}天前`
}
function goToDeviceConfig() {
uni.navigateTo({
url: '/pages/device-config/index',
})
}
// 页面显示时刷新列表
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">
<wd-icon
name="setting1" size="40rpx" class="absolute right-0 top-[-50rpx] text-white"
@click="goToDeviceConfig"
/>
<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,777 @@
<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 { 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 = `${import.meta.env.VITE_SERVER_BASEURL}/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,877 @@
<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 { 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 = `${import.meta.env.VITE_SERVER_BASEURL}/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,544 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "声纹管理"
}
}
</route>
<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 useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
import { useAgentStore } from '@/store'
defineOptions({
name: 'VoicePrintManage',
})
// 获取屏幕边界到安全区域距离
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 pagingRef = ref()
useZPaging(pagingRef)
// 智能体管理
const agentStore = useAgentStore()
const showAgentPicker = ref(false)
// 获取当前选中的智能体ID
const currentAgentId = computed(() => {
return agentStore.currentAgentId
})
// 获取智能体选项
const agentOptions = computed(() => {
return agentStore.getAgentOptions
})
// 显示智能体选择器
function showAgentSelector() {
showAgentPicker.value = true
}
// 关闭智能体选择器
function closeAgentSelector() {
showAgentPicker.value = false
}
// 处理智能体切换
function handleAgentSwitch({ item }: { item: any }) {
const selectedAgentId = item.value
if (selectedAgentId !== currentAgentId.value) {
// 设置当前智能体
agentStore.setCurrentAgent(selectedAgentId)
// 刷新声纹列表
pagingRef.value.reload()
}
closeAgentSelector()
}
// 弹窗相关
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: '',
})
// z-paging查询列表数据
async function queryList(pageNo: number, pageSize: number) {
try {
console.log('z-paging获取声纹列表')
// 检查是否有当前选中的智能体
if (!currentAgentId.value) {
console.warn('没有选中的智能体')
pagingRef.value.complete([])
return
}
const data = await getVoicePrintList(currentAgentId.value)
// 初始化滑动状态
const list = data || []
list.forEach((item) => {
if (!swipeStates.value[item.id]) {
swipeStates.value[item.id] = 'close'
}
})
// 直接返回全部数据,不需要分页处理
pagingRef.value.complete(list)
}
catch (error) {
console.error('获取声纹列表失败:', error)
// 告知z-paging数据加载失败
pagingRef.value.complete(false)
}
}
// 获取声纹列表(兼容旧代码)
async function loadVoicePrintList() {
pagingRef.value?.reload()
}
// 获取语音对话记录
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
pagingRef.value.reload()
}
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
pagingRef.value.reload()
}
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('删除成功')
pagingRef.value.reload()
}).catch(() => {
console.log('点击了取消按钮')
})
}
onMounted(async () => {
// 确保智能体列表已加载
if (!agentStore.isLoaded) {
await agentStore.loadAgentList()
}
})
onShow(() => {
if (pagingRef.value) {
pagingRef.value.reload()
}
})
</script>
<template>
<z-paging
ref="pagingRef" v-model="voicePrintList" :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="bg-white">
<!-- 状态栏背景 -->
<wd-navbar :title="agentStore.currentAgent?.agentName || '声纹管理'" safe-area-inset-top>
<template #right>
<wd-icon name="filter1" size="18" @click="showAgentSelector" />
</template>
</wd-navbar>
</view>
</template>
<!-- 声纹卡片列表 -->
<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>
<!-- 自定义空状态 -->
<template #empty>
<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>
</template>
<!-- 浮动操作按钮 -->
<wd-fab type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
<wd-icon name="add" />
</wd-fab>
</z-paging>
<!-- 添加说话人弹窗 -->
<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"
/>
<!-- 智能体选择器 -->
<wd-action-sheet
v-model="showAgentPicker" :actions="agentOptions" title="选择智能体" @close="closeAgentSelector"
@select="handleAgentSwitch"
/>
</template>
<style>
:deep(.z-paging-content) {
background: #f5f7fb;
}
: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

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 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.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,122 @@
import type { Agent } from '@/api/agent/types'
import { defineStore } from 'pinia'
import { getAgentList } from '@/api/agent/agent'
export const useAgentStore = defineStore('agent', {
state: () => ({
// 智能体列表
agentList: [] as Agent[],
// 当前选中的智能体ID
currentAgentId: '',
// 当前选中的智能体信息
currentAgent: null as Agent | null,
// 是否已加载智能体列表
isLoaded: false,
}),
getters: {
// 获取当前智能体
getCurrentAgent: (state) => {
return state.agentList.find(agent => agent.id === state.currentAgentId) || null
},
// 获取智能体列表用于选择器
getAgentOptions: (state) => {
return state.agentList.map(agent => ({
name: agent.agentName,
value: agent.id,
agent,
}))
},
// 是否有智能体列表
hasAgents: (state) => {
return state.agentList.length > 0
},
},
actions: {
// 加载智能体列表
async loadAgentList() {
try {
const list = await getAgentList()
this.agentList = list
this.isLoaded = true
// 如果没有当前选中的智能体,且列表不为空,选择第一个
if (!this.currentAgentId && list.length > 0) {
this.setCurrentAgent(list[0].id)
}
return list
}
catch (error) {
console.error('加载智能体列表失败:', error)
throw error
}
},
// 设置当前智能体
setCurrentAgent(agentId: string) {
this.currentAgentId = agentId
console.log(this.agentList.find(agent => agent.id === agentId), '执行')
this.currentAgent = this.agentList.find(agent => agent.id === agentId) || null
},
// 添加智能体到列表
addAgent(agent: Agent) {
this.agentList.push(agent)
// 如果是第一个智能体,设置为当前智能体
if (this.agentList.length === 1) {
this.setCurrentAgent(agent.id)
}
},
// 从列表中移除智能体
removeAgent(agentId: string) {
const index = this.agentList.findIndex(agent => agent.id === agentId)
if (index > -1) {
this.agentList.splice(index, 1)
// 如果删除的是当前选中的智能体,重新选择
if (this.currentAgentId === agentId) {
if (this.agentList.length > 0) {
this.setCurrentAgent(this.agentList[0].id)
}
else {
this.currentAgentId = ''
this.currentAgent = null
}
}
}
},
// 更新智能体信息
updateAgent(agent: Agent) {
const index = this.agentList.findIndex(item => item.id === agent.id)
if (index > -1) {
this.agentList[index] = agent
// 如果是当前智能体,更新当前智能体信息
if (this.currentAgentId === agent.id) {
this.currentAgent = agent
}
}
},
// 清空状态
clearState() {
this.agentList = []
this.currentAgentId = ''
this.currentAgent = null
this.isLoaded = false
},
},
persist: {
// 持久化当前选中的智能体ID
// paths: ['currentAgentId'],
},
})
@@ -0,0 +1,65 @@
import type { PublicConfig } from '@/api/auth'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getPublicConfig } from '@/api/auth'
// 初始化状态
const initialConfigState: PublicConfig = {
enableMobileRegister: false,
version: '',
year: '',
allowUserRegister: false,
mobileAreaList: [],
beianIcpNum: '',
beianGaNum: '',
name: '小智智能助手',
}
export const useConfigStore = defineStore(
'config',
() => {
// 定义全局配置
const config = ref<PublicConfig>({ ...initialConfigState })
// 设置配置信息
const setConfig = (val: PublicConfig) => {
config.value = val
}
// 获取公共配置
const fetchPublicConfig = async () => {
try {
const configData = await getPublicConfig()
console.log(configData)
setConfig(configData)
return configData
}
catch (error) {
console.error('获取公共配置失败:', error)
throw error
}
}
// 重置配置
const resetConfig = () => {
config.value = { ...initialConfigState }
}
return {
config,
setConfig,
fetchPublicConfig,
resetConfig,
}
},
{
persist: {
key: 'config',
serializer: {
serialize: state => JSON.stringify(state.config),
deserialize: value => ({ config: JSON.parse(value) }),
},
},
},
)
@@ -0,0 +1,20 @@
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate' // 数据持久化
const store = createPinia()
store.use(
createPersistedState({
storage: {
getItem: uni.getStorageSync,
setItem: uni.setStorageSync,
},
}),
)
export default store
export * from './agent'
export * from './config'
export * from './plugin'
// 模块统一导出
export * from './user'
@@ -0,0 +1,58 @@
import type { AgentFunction, PluginDefinition } from '@/api/agent/types'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const usePluginStore = defineStore(
'plugin',
() => {
// 所有可用插件
const allFunctions = ref<PluginDefinition[]>([])
// 当前智能体的插件配置
const currentFunctions = ref<AgentFunction[]>([])
// 当前编辑的智能体ID
const currentAgentId = ref('')
// 设置所有可用插件
const setAllFunctions = (functions: PluginDefinition[]) => {
allFunctions.value = functions
}
// 设置当前智能体的插件配置
const setCurrentFunctions = (functions: AgentFunction[]) => {
currentFunctions.value = functions
}
// 设置当前智能体ID
const setCurrentAgentId = (agentId: string) => {
currentAgentId.value = agentId
}
// 更新插件配置(用于保存时调用)
const updateFunctions = (functions: AgentFunction[]) => {
currentFunctions.value = functions
}
// 清空数据
const clear = () => {
allFunctions.value = []
currentFunctions.value = []
currentAgentId.value = ''
}
return {
allFunctions,
currentFunctions,
currentAgentId,
setAllFunctions,
setCurrentFunctions,
setCurrentAgentId,
updateFunctions,
clear,
}
},
{
persist: false, // 不持久化,每次进入页面重新加载
},
)
@@ -0,0 +1,83 @@
import type { UserInfo } from '@/api/auth'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
getUserInfo as _getUserInfo,
} from '@/api/auth'
// 初始化状态
const userInfoState: UserInfo & { avatar?: string, token?: string } = {
id: 0,
username: '',
realName: '',
email: '',
mobile: '',
status: 0,
superAdmin: 0,
avatar: '/static/images/default-avatar.png',
token: '',
}
export const useUserStore = defineStore(
'user',
() => {
// 定义用户信息
const userInfo = ref<UserInfo & { avatar?: string, token?: string }>({ ...userInfoState })
// 设置用户信息
const setUserInfo = (val: UserInfo & { avatar?: string, token?: string }) => {
console.log('设置用户信息', val)
// 若头像为空 则使用默认头像
if (!val.avatar) {
val.avatar = userInfoState.avatar
}
else {
val.avatar = 'https://oss.laf.run/ukw0y1-site/avatar.jpg?feige'
}
userInfo.value = val
}
const setUserAvatar = (avatar: string) => {
userInfo.value.avatar = avatar
console.log('设置用户头像', avatar)
console.log('userInfo', userInfo.value)
}
// 删除用户信息
const removeUserInfo = () => {
userInfo.value = { ...userInfoState }
uni.removeStorageSync('userInfo')
uni.removeStorageSync('token')
}
/**
* 获取用户信息
*/
const getUserInfo = async () => {
const userData = await _getUserInfo()
const userInfoWithExtras = {
...userData,
avatar: userInfoState.avatar,
token: uni.getStorageSync('token') || '',
}
setUserInfo(userInfoWithExtras)
uni.setStorageSync('userInfo', userInfoWithExtras)
// TODO 这里可以增加获取用户路由的方法 根据用户的角色动态生成路由
return userInfoWithExtras
}
/**
* 退出登录 并 删除用户信息
*/
const logout = async () => {
removeUserInfo()
}
return {
userInfo,
getUserInfo,
setUserInfo,
setUserAvatar,
logout,
removeUserInfo,
}
},
{
persist: true,
},
)
@@ -0,0 +1,28 @@
@font-face {
font-family: 'iconfont'; /* Project id 4543091 */
src:
url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAOwAAsAAAAAB9AAAANjAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHFQGYACDHAqDBIJqATYCJAMQCwoABCAFhGcHPRvnBsgusG3kMyE15/44PsBX09waBHv0REDt97oHAQDFrOIyPirRiULQ+TJcXV0hCYTuVFcBC915/2vX/32Q80hkZ5PZGZ9snvwruVLloidKqYN6iKC53bOtbKwVLSIi3W6zCWZbs3VbER3j9JpGX3ySYcc94IQRTK5s4epS/jSqIgvg37qlY2/jwQN7D9ADpfRCmIknQByTscVZPTBr+hnnCKg2o4bjakvXEPjuY65DJGeJNtBUhn1JxOBuB2UZmUpBOXdsFp4oxOv4GHgs3h/+wRDcicqSZJG1q9kK1z/Af9NpqxjpC2QaAdpHlCFh4spcYXs5sMWpSk5wUj31G2dLQKVKkZ/w7f/8/i/A3JVUSZK9f7xIKJeU14IFpBI/Qfkkz46GT/CuaGREfCtKJUougWeQWHvVC5Lcz2BGS+SePR99vj3yjJx7h574tp7uWcOh4yfaTjS/245TT/vkQrN+a7RLkK8+Vd+bz+FSGh+9srDQKPeJ2s29z7ah4+efdoxefRbbGwfy7ht+SuIWukzsu1b6ePP+6kN1aamb47qsPim1Ia3xdEpDcl1dckPKGYnneI23+57r2W1Mmkqs6ajrChRCs5qyQ66rTVWhgZaG7toOeHm5cxn0sSQuNDEgcUTdNTSupKI1JRZih/JssAUKezPeOJJzbNozF6zWJuuVavVU5Tgtkop/SDzHa7ytvnCTq0PhkEfi4xLLtb0PuwyOAYqmrYQApFJyoJjTnfz+ve94vvv2f/yWgxl8Jd8Di2DRDPuob59mU/+VfDCROQyR8xSnmP9fXm7liagmN39OlmbvjqG0sMsJKrU0EFXogaRSH5bNY1CmxhyUq7QC1cY1T67RwuQk5CoM2RUQNLoEUb03kDS6h2XzcyjT7iOUa/QXqq1Hn6/GUBAaGcGcWJFlGUmCoVOp8kLvABHnVczGYiOE2SVEUH5OXj/TSnTCDjHAviAWcE4RZYaGWszNiKoayGSGTASeY+PcrMjNpVMvyREMDRoxBMYRVojFMkQiMOhohubdzxtAiOapMMbERpKMnQT9SL4ceQysVdJZVa9kEbsFogIcRyEUE2kN0mL7CDVIGhBzupWMEHA5bDvipgq5hKJcKef8ivbx1kC15KgcYkghhzLxYNntxoKCReJ82jAHAAA=')
format('woff2'),
url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.woff?t=1715485842402') format('woff'),
url('//at.alicdn.com/t/c/font_4543091_njpo5b95nl.ttf?t=1715485842402') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-my:before {
content: '\e78c';
}
.icon-package:before {
content: '\e9c2';
}
.icon-chat:before {
content: '\e600';
}
@@ -0,0 +1,19 @@
// 测试用的 iconfont,可生效
// @import './iconfont.css';
.test {
// 可以通过 @apply 多个样式封装整体样式
@apply mt-4 ml-4;
padding-top: 4px;
color: red;
}
:root,
page {
// 修改按主题色
// --wot-color-theme: #37c2bc;
// 修改按钮背景色
// --wot-button-primary-bg-color: green;
}
+28
View File
@@ -0,0 +1,28 @@
// 全局要用的类型放到这里
declare global {
interface IResData<T> {
code: number
msg: string
data: T
}
// uni.uploadFile文件上传参数
interface IUniUploadFileOptions {
file?: File
files?: UniApp.UploadFileOptionFiles[]
filePath?: string
name?: string
formData?: any
}
interface IUserInfo {
nickname?: string
avatar?: string
/** 微信的 openid,非微信没有这个字段 */
openid?: string
token?: string
}
}
export {} // 防止模块污染
+15
View File
@@ -0,0 +1,15 @@
// 枚举定义
export enum TestEnum {
A = '1',
B = '2',
}
// uni.uploadFile文件上传参数
export interface IUniUploadFileOptions {
file?: File
files?: UniApp.UploadFileOptionFiles[]
filePath?: string
name?: string
formData?: any
}
+77
View File
@@ -0,0 +1,77 @@
/* stylelint-disable comment-empty-line-before */
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color: #333; // 基本色
$uni-text-color-inverse: #fff; // 反色
$uni-text-color-grey: #999; // 辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable: #c0c0c0;
/* 背景颜色 */
$uni-bg-color: #fff;
$uni-bg-color-grey: #f8f8f8;
$uni-bg-color-hover: #f1f1f1; // 点击状态颜色
$uni-bg-color-mask: rgb(0 0 0 / 40%); // 遮罩颜色
/* 边框颜色 */
$uni-border-color: #c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm: 12px;
$uni-font-size-base: 14px;
$uni-font-size-lg: 16;
/* 图片尺寸 */
$uni-img-size-sm: 20px;
$uni-img-size-base: 26px;
$uni-img-size-lg: 40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2c405a; // 文章标题颜色
$uni-font-size-title: 20px;
$uni-color-subtitle: #555; // 二级标题颜色
$uni-font-size-subtitle: 18px;
$uni-color-paragraph: #3f536e; // 文章段落颜色
$uni-font-size-paragraph: 15px;
@@ -0,0 +1,172 @@
import { pages, subPackages } from '@/pages.json'
import { isMpWeixin } from './platform'
export function getLastPage() {
// getCurrentPages() 至少有1个元素,所以不再额外判断
// const lastPage = getCurrentPages().at(-1)
// 上面那个在低版本安卓中打包会报错,所以改用下面这个【虽然我加了 src/interceptions/prototype.ts,但依然报错】
const pages = getCurrentPages()
return pages[pages.length - 1]
}
/**
* 获取当前页面路由的 path 路径和 redirectPath 路径
* path 如 '/pages/login/index'
* redirectPath 如 '/pages/demo/base/route-interceptor'
*/
export function currRoute() {
const lastPage = getLastPage()
const currRoute = (lastPage as any).$page
// console.log('lastPage.$page:', currRoute)
// console.log('lastPage.$page.fullpath:', currRoute.fullPath)
// console.log('lastPage.$page.options:', currRoute.options)
// console.log('lastPage.options:', (lastPage as any).options)
// 经过多端测试,只有 fullPath 靠谱,其他都不靠谱
const { fullPath } = currRoute as { fullPath: string }
// console.log(fullPath)
// eg: /pages/login/index?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor (小程序)
// eg: /pages/login/index?redirect=%2Fpages%2Froute-interceptor%2Findex%3Fname%3Dfeige%26age%3D30(h5)
return getUrlObj(fullPath)
}
function ensureDecodeURIComponent(url: string) {
if (url.startsWith('%')) {
return ensureDecodeURIComponent(decodeURIComponent(url))
}
return url
}
/**
* 解析 url 得到 path 和 query
* 比如输入url: /pages/login/index?redirect=%2Fpages%2Fdemo%2Fbase%2Froute-interceptor
* 输出: {path: /pages/login/index, query: {redirect: /pages/demo/base/route-interceptor}}
*/
export function getUrlObj(url: string) {
const [path, queryStr] = url.split('?')
// console.log(path, queryStr)
if (!queryStr) {
return {
path,
query: {},
}
}
const query: Record<string, string> = {}
queryStr.split('&').forEach((item) => {
const [key, value] = item.split('=')
// console.log(key, value)
query[key] = ensureDecodeURIComponent(value) // 这里需要统一 decodeURIComponent 一下,可以兼容h5和微信y
})
return { path, query }
}
/**
* 得到所有的需要登录的 pages,包括主包和分包的
* 这里设计得通用一点,可以传递 key 作为判断依据,默认是 needLogin, 与 route-block 配对使用
* 如果没有传 key,则表示所有的 pages,如果传递了 key, 则表示通过 key 过滤
*/
export function getAllPages(key = 'needLogin') {
// 这里处理主包
const mainPages = pages
.filter(page => !key || page[key])
.map(page => ({
...page,
path: `/${page.path}`,
}))
// 这里处理分包
const subPages: any[] = []
subPackages.forEach((subPageObj) => {
// console.log(subPageObj)
const { root } = subPageObj
subPageObj.pages
.filter(page => !key || page[key])
.forEach((page: { path: string } & Record<string, any>) => {
subPages.push({
...page,
path: `/${root}/${page.path}`,
})
})
})
const result = [...mainPages, ...subPages]
// console.log(`getAllPages by ${key} result: `, result)
return result
}
/**
* 得到所有的需要登录的 pages,包括主包和分包的
* 只得到 path 数组
*/
export const getNeedLoginPages = (): string[] => getAllPages('needLogin').map(page => page.path)
/**
* 得到所有的需要登录的 pages,包括主包和分包的
* 只得到 path 数组
*/
export const needLoginPages: string[] = getAllPages('needLogin').map(page => page.path)
/**
* 根据微信小程序当前环境,判断应该获取的 baseUrl
*/
export function getEnvBaseUrl() {
// 请求基准地址
let baseUrl = import.meta.env.VITE_SERVER_BASEURL
// # 有些同学可能需要在微信小程序里面根据 develop、trial、release 分别设置上传地址,参考代码如下。
const VITE_SERVER_BASEURL__WEIXIN_DEVELOP = 'https://ukw0y1.laf.run'
const VITE_SERVER_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run'
const VITE_SERVER_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run'
// 微信小程序端环境区分
if (isMpWeixin) {
const {
miniProgram: { envVersion },
} = uni.getAccountInfoSync()
switch (envVersion) {
case 'develop':
baseUrl = VITE_SERVER_BASEURL__WEIXIN_DEVELOP || baseUrl
break
case 'trial':
baseUrl = VITE_SERVER_BASEURL__WEIXIN_TRIAL || baseUrl
break
case 'release':
baseUrl = VITE_SERVER_BASEURL__WEIXIN_RELEASE || baseUrl
break
}
}
return baseUrl
}
/**
* 根据微信小程序当前环境,判断应该获取的 UPLOAD_BASEURL
*/
export function getEnvBaseUploadUrl() {
// 请求基准地址
let baseUploadUrl = import.meta.env.VITE_UPLOAD_BASEURL
const VITE_UPLOAD_BASEURL__WEIXIN_DEVELOP = 'https://ukw0y1.laf.run/upload'
const VITE_UPLOAD_BASEURL__WEIXIN_TRIAL = 'https://ukw0y1.laf.run/upload'
const VITE_UPLOAD_BASEURL__WEIXIN_RELEASE = 'https://ukw0y1.laf.run/upload'
// 微信小程序端环境区分
if (isMpWeixin) {
const {
miniProgram: { envVersion },
} = uni.getAccountInfoSync()
switch (envVersion) {
case 'develop':
baseUploadUrl = VITE_UPLOAD_BASEURL__WEIXIN_DEVELOP || baseUploadUrl
break
case 'trial':
baseUploadUrl = VITE_UPLOAD_BASEURL__WEIXIN_TRIAL || baseUploadUrl
break
case 'release':
baseUploadUrl = VITE_UPLOAD_BASEURL__WEIXIN_RELEASE || baseUploadUrl
break
}
}
return baseUploadUrl
}
@@ -0,0 +1,24 @@
/*
* @Author: 菲鸽
* @Date: 2024-03-28 19:13:55
* @Last Modified by: 菲鸽
* @Last Modified time: 2024-03-28 19:24:55
*/
export const platform = __UNI_PLATFORM__
export const isH5 = __UNI_PLATFORM__ === 'h5'
export const isApp = __UNI_PLATFORM__ === 'app'
export const isMp = __UNI_PLATFORM__.startsWith('mp-')
export const isMpWeixin = __UNI_PLATFORM__.startsWith('mp-weixin')
export const isMpAplipay = __UNI_PLATFORM__.startsWith('mp-alipay')
export const isMpToutiao = __UNI_PLATFORM__.startsWith('mp-toutiao')
const PLATFORM = {
platform,
isH5,
isApp,
isMp,
isMpWeixin,
isMpAplipay,
isMpToutiao,
}
export default PLATFORM
@@ -0,0 +1,78 @@
import type { CustomRequestOptions } from '@/http/interceptor'
/**
* 请求方法: 主要是对 uni.request 的封装,去适配 openapi-ts-request 的 request 方法
* @param options 请求参数
* @returns 返回 Promise 对象
*/
function http<T>(options: CustomRequestOptions) {
// 1. 返回 Promise 对象
return new Promise<T>((resolve, reject) => {
uni.request({
...options,
dataType: 'json',
// #ifndef MP-WEIXIN
responseType: 'json',
// #endif
// 响应成功
success(res) {
// 状态码 2xx,参考 axios 的设计
if (res.statusCode >= 200 && res.statusCode < 300) {
// 2.1 提取核心数据 res.data
resolve(res.data as T)
}
else if (res.statusCode === 401) {
// 401错误 -> 清理用户信息,跳转到登录页
// userStore.clearUserInfo()
// uni.navigateTo({ url: '/pages/login/login' })
reject(res)
}
else {
// 其他错误 -> 根据后端错误信息轻提示
!options.hideErrorToast
&& uni.showToast({
icon: 'none',
title: (res.data as T & { msg?: string })?.msg || '请求错误',
})
reject(res)
}
},
// 响应失败
fail(err) {
uni.showToast({
icon: 'none',
title: '网络错误,换个网络试试',
})
reject(err)
},
})
})
}
/*
* openapi-ts-request 工具的 request 跨客户端适配方法
*/
export default function request<T = unknown>(
url: string,
options: Omit<CustomRequestOptions, 'url'> & {
params?: Record<string, unknown>
headers?: Record<string, unknown>
},
) {
const requestOptions = {
url,
...options,
}
if (options.params) {
requestOptions.query = requestOptions.params
delete requestOptions.params
}
if (options.headers) {
requestOptions.header = options.headers
delete requestOptions.headers
}
return http<T>(requestOptions)
}
@@ -0,0 +1,65 @@
/**
* toast 弹窗组件
* 支持 success/error/warning/info 四种状态
* 可配置 duration, position 等参数
*/
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface ToastOptions {
type?: ToastType
duration?: number
position?: 'top' | 'middle' | 'bottom'
icon?: 'success' | 'error' | 'none' | 'loading' | 'fail' | 'exception'
message: string
}
export function showToast(options: ToastOptions | string) {
const defaultOptions: ToastOptions = {
type: 'info',
duration: 2000,
position: 'middle',
message: '',
}
const mergedOptions
= typeof options === 'string'
? { ...defaultOptions, message: options }
: { ...defaultOptions, ...options }
// 映射position到uniapp支持的格式
const positionMap: Record<ToastOptions['position'], 'top' | 'bottom' | 'center'> = {
top: 'top',
middle: 'center',
bottom: 'bottom',
}
// 映射图标类型
const iconMap: Record<
ToastType,
'success' | 'error' | 'none' | 'loading' | 'fail' | 'exception'
> = {
success: 'success',
error: 'error',
warning: 'fail',
info: 'none',
}
// 调用uni.showToast显示提示
uni.showToast({
title: mergedOptions.message,
duration: mergedOptions.duration,
position: positionMap[mergedOptions.position],
icon: mergedOptions.icon || iconMap[mergedOptions.type],
mask: true,
})
}
export const toast = {
success: (message: string, options?: Omit<ToastOptions, 'type'>) =>
showToast({ ...options, type: 'success', message }),
error: (message: string, options?: Omit<ToastOptions, 'type'>) =>
showToast({ ...options, type: 'error', message }),
warning: (message: string, options?: Omit<ToastOptions, 'type'>) =>
showToast({ ...options, type: 'warning', message }),
info: (message: string, options?: Omit<ToastOptions, 'type'>) =>
showToast({ ...options, type: 'info', message }),
}
@@ -0,0 +1,324 @@
import { toast } from './toast'
/**
* 文件上传钩子函数使用示例
* @example
* const { loading, error, data, progress, run } = useUpload<IUploadResult>(
* uploadUrl,
* {},
* {
* maxSize: 5, // 最大5MB
* sourceType: ['album'], // 仅支持从相册选择
* onProgress: (p) => console.log(`上传进度:${p}%`),
* onSuccess: (res) => console.log('上传成功', res),
* onError: (err) => console.error('上传失败', err),
* },
* )
*/
/**
* 上传文件的URL配置
*/
export const uploadFileUrl = {
/** 用户头像上传地址 */
USER_AVATAR: `${import.meta.env.VITE_SERVER_BASEURL}/user/avatar`,
}
/**
* 通用文件上传函数(支持直接传入文件路径)
* @param url 上传地址
* @param filePath 本地文件路径
* @param formData 额外表单数据
* @param options 上传选项
*/
export function useFileUpload<T = string>(url: string, filePath: string, formData: Record<string, any> = {}, options: Omit<UploadOptions, 'sourceType' | 'sizeType' | 'count'> = {}) {
return useUpload<T>(
url,
formData,
{
...options,
sourceType: ['album'],
sizeType: ['original'],
},
filePath,
)
}
export interface UploadOptions {
/** 最大可选择的图片数量,默认为1 */
count?: number
/** 所选的图片的尺寸,original-原图,compressed-压缩图 */
sizeType?: Array<'original' | 'compressed'>
/** 选择图片的来源,album-相册,camera-相机 */
sourceType?: Array<'album' | 'camera'>
/** 文件大小限制,单位:MB */
maxSize?: number //
/** 上传进度回调函数 */
onProgress?: (progress: number) => void
/** 上传成功回调函数 */
onSuccess?: (res: Record<string, any>) => void
/** 上传失败回调函数 */
onError?: (err: Error | UniApp.GeneralCallbackResult) => void
/** 上传完成回调函数(无论成功失败) */
onComplete?: () => void
}
/**
* 文件上传钩子函数
* @template T 上传成功后返回的数据类型
* @param url 上传地址
* @param formData 额外的表单数据
* @param options 上传选项
* @returns 上传状态和控制对象
*/
export function useUpload<T = string>(url: string, formData: Record<string, any> = {}, options: UploadOptions = {},
/** 直接传入文件路径,跳过选择器 */
directFilePath?: string) {
/** 上传中状态 */
const loading = ref(false)
/** 上传错误状态 */
const error = ref(false)
/** 上传成功后的响应数据 */
const data = ref<T>()
/** 上传进度(0-100 */
const progress = ref(0)
/** 解构上传选项,设置默认值 */
const {
/** 最大可选择的图片数量 */
count = 1,
/** 所选的图片的尺寸 */
sizeType = ['original', 'compressed'],
/** 选择图片的来源 */
sourceType = ['album', 'camera'],
/** 文件大小限制(MB */
maxSize = 10,
/** 进度回调 */
onProgress,
/** 成功回调 */
onSuccess,
/** 失败回调 */
onError,
/** 完成回调 */
onComplete,
} = options
/**
* 检查文件大小是否超过限制
* @param size 文件大小(字节)
* @returns 是否通过检查
*/
const checkFileSize = (size: number) => {
const sizeInMB = size / 1024 / 1024
if (sizeInMB > maxSize) {
toast.warning(`文件大小不能超过${maxSize}MB`)
return false
}
return true
}
/**
* 触发文件选择和上传
* 根据平台使用不同的选择器:
* - 微信小程序使用 chooseMedia
* - 其他平台使用 chooseImage
*/
const run = () => {
if (directFilePath) {
// 直接使用传入的文件路径
loading.value = true
progress.value = 0
uploadFile<T>({
url,
tempFilePath: directFilePath,
formData,
data,
error,
loading,
progress,
onProgress,
onSuccess,
onError,
onComplete,
})
return
}
// #ifdef MP-WEIXIN
// 微信小程序环境下使用 chooseMedia API
uni.chooseMedia({
count,
mediaType: ['image'], // 仅支持图片类型
sourceType,
success: (res) => {
const file = res.tempFiles[0]
// 检查文件大小是否符合限制
if (!checkFileSize(file.size))
return
// 开始上传
loading.value = true
progress.value = 0
uploadFile<T>({
url,
tempFilePath: file.tempFilePath,
formData,
data,
error,
loading,
progress,
onProgress,
onSuccess,
onError,
onComplete,
})
},
fail: (err) => {
console.error('选择媒体文件失败:', err)
error.value = true
onError?.(err)
},
})
// #endif
// #ifndef MP-WEIXIN
// 非微信小程序环境下使用 chooseImage API
uni.chooseImage({
count,
sizeType,
sourceType,
success: (res) => {
console.log('选择图片成功:', res)
// 开始上传
loading.value = true
progress.value = 0
uploadFile<T>({
url,
tempFilePath: res.tempFilePaths[0],
formData,
data,
error,
loading,
progress,
onProgress,
onSuccess,
onError,
onComplete,
})
},
fail: (err) => {
console.error('选择图片失败:', err)
error.value = true
onError?.(err)
},
})
// #endif
}
return { loading, error, data, progress, run }
}
/**
* 文件上传选项接口
* @template T 上传成功后返回的数据类型
*/
interface UploadFileOptions<T> {
/** 上传地址 */
url: string
/** 临时文件路径 */
tempFilePath: string
/** 额外的表单数据 */
formData: Record<string, any>
/** 上传成功后的响应数据 */
data: Ref<T | undefined>
/** 上传错误状态 */
error: Ref<boolean>
/** 上传中状态 */
loading: Ref<boolean>
/** 上传进度(0-100 */
progress: Ref<number>
/** 上传进度回调 */
onProgress?: (progress: number) => void
/** 上传成功回调 */
onSuccess?: (res: Record<string, any>) => void
/** 上传失败回调 */
onError?: (err: Error | UniApp.GeneralCallbackResult) => void
/** 上传完成回调 */
onComplete?: () => void
}
/**
* 执行文件上传
* @template T 上传成功后返回的数据类型
* @param options 上传选项
*/
function uploadFile<T>({
url,
tempFilePath,
formData,
data,
error,
loading,
progress,
onProgress,
onSuccess,
onError,
onComplete,
}: UploadFileOptions<T>) {
try {
// 创建上传任务
const uploadTask = uni.uploadFile({
url,
filePath: tempFilePath,
name: 'file', // 文件对应的 key
formData,
header: {
// H5环境下不需要手动设置Content-Type,让浏览器自动处理multipart格式
// #ifndef H5
'Content-Type': 'multipart/form-data',
// #endif
},
// 确保文件名称合法
success: (uploadFileRes) => {
console.log('上传文件成功:', uploadFileRes)
try {
// 解析响应数据
const { data: _data } = JSON.parse(uploadFileRes.data)
// 上传成功
data.value = _data as T
onSuccess?.(_data)
}
catch (err) {
// 响应解析错误
console.error('解析上传响应失败:', err)
error.value = true
onError?.(new Error('上传响应解析失败'))
}
},
fail: (err) => {
// 上传请求失败
console.error('上传文件失败:', err)
error.value = true
onError?.(err)
},
complete: () => {
// 无论成功失败都执行
loading.value = false
onComplete?.()
},
})
// 监听上传进度
uploadTask.onProgressUpdate((res) => {
progress.value = res.progress
onProgress?.(res.progress)
})
}
catch (err) {
// 创建上传任务失败
console.error('创建上传任务失败:', err)
error.value = true
loading.value = false
onError?.(new Error('创建上传任务失败'))
}
}