mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 06:03:55 +08:00
update:调整文件夹
This commit is contained in:
@@ -0,0 +1,701 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { getAgentDetail, getModelOptions, getPluginFunctions, getRoleTemplates, getTTSVoices, updateAgent } from '@/api/agent/agent'
|
||||
import { usePluginStore } from '@/store'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentEdit',
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: '',
|
||||
})
|
||||
|
||||
// 组件参数
|
||||
interface Props {
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
const agentId = computed(() => props.agentId)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<Partial<AgentDetail>>({
|
||||
agentName: '',
|
||||
systemPrompt: '',
|
||||
summaryMemory: '',
|
||||
vadModelId: '',
|
||||
asrModelId: '',
|
||||
llmModelId: '',
|
||||
vllmModelId: '',
|
||||
intentModelId: '',
|
||||
memModelId: '',
|
||||
ttsModelId: '',
|
||||
ttsVoiceId: '',
|
||||
})
|
||||
|
||||
// 显示名称数据
|
||||
const displayNames = ref({
|
||||
vad: '请选择',
|
||||
asr: '请选择',
|
||||
llm: '请选择',
|
||||
vllm: '请选择',
|
||||
intent: '请选择',
|
||||
memory: '请选择',
|
||||
tts: '请选择',
|
||||
voiceprint: '请选择',
|
||||
})
|
||||
|
||||
// 角色模板数据
|
||||
const roleTemplates = ref<RoleTemplate[]>([])
|
||||
const selectedTemplateId = ref('')
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 模型选项数据
|
||||
const modelOptions = ref<{
|
||||
[key: string]: ModelOption[]
|
||||
}>({
|
||||
VAD: [],
|
||||
ASR: [],
|
||||
LLM: [],
|
||||
VLLM: [],
|
||||
Intent: [],
|
||||
Memory: [],
|
||||
TTS: [],
|
||||
})
|
||||
|
||||
// 音色选项数据
|
||||
const voiceOptions = ref<{ id: string, name: string }[]>([])
|
||||
|
||||
// 选择器显示状态
|
||||
const pickerShow = ref<{
|
||||
[key: string]: boolean
|
||||
}>({
|
||||
vad: false,
|
||||
asr: false,
|
||||
llm: false,
|
||||
vllm: false,
|
||||
intent: false,
|
||||
memory: false,
|
||||
tts: false,
|
||||
voiceprint: false,
|
||||
})
|
||||
|
||||
const allFunctions = ref<PluginDefinition[]>([])
|
||||
|
||||
// 使用插件store
|
||||
const pluginStore = usePluginStore()
|
||||
|
||||
// tabs
|
||||
const tabList = [
|
||||
{
|
||||
label: '角色配置',
|
||||
value: 'home',
|
||||
icon: '/static/tabbar/robot.png',
|
||||
activeIcon: '/static/tabbar/robot_activate.png',
|
||||
},
|
||||
{
|
||||
label: '设备管理',
|
||||
value: 'category',
|
||||
icon: '/static/tabbar/device.png',
|
||||
activeIcon: '/static/tabbar/device_activate.png',
|
||||
},
|
||||
{
|
||||
label: '聊天记录',
|
||||
value: 'settings',
|
||||
icon: '/static/tabbar/chat.png',
|
||||
activeIcon: '/static/tabbar/chat_activate.png',
|
||||
},
|
||||
{
|
||||
label: '声纹管理',
|
||||
value: 'profile',
|
||||
icon: '/static/tabbar/voiceprint.png',
|
||||
activeIcon: '/static/tabbar/voiceprint_activate.png',
|
||||
},
|
||||
]
|
||||
|
||||
// 加载智能体详情
|
||||
async function loadAgentDetail() {
|
||||
if (!agentId.value)
|
||||
return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const detail = await getAgentDetail(agentId.value)
|
||||
formData.value = { ...detail }
|
||||
|
||||
// 更新插件store
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(detail.functions || [])
|
||||
|
||||
// 如果有TTS模型,加载对应的音色选项
|
||||
if (detail.ttsModelId) {
|
||||
await loadVoiceOptions(detail.ttsModelId)
|
||||
}
|
||||
|
||||
// 等待模型选项加载完成后再更新显示名称
|
||||
await nextTick()
|
||||
updateDisplayNames()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载智能体详情失败:', error)
|
||||
toast.error('加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取音色显示名称
|
||||
function getVoiceDisplayName(ttsVoiceId: string) {
|
||||
if (!ttsVoiceId)
|
||||
return '请选择'
|
||||
|
||||
console.log('=== 音色映射调试 ===')
|
||||
console.log('当前音色ID:', ttsVoiceId)
|
||||
console.log('当前TTS模型:', formData.value.ttsModelId)
|
||||
console.log('可用音色选项:', voiceOptions.value)
|
||||
|
||||
// 首先尝试直接从音色选项中匹配ID
|
||||
const voice = voiceOptions.value.find(v => v.id === ttsVoiceId)
|
||||
if (voice) {
|
||||
console.log('直接匹配成功:', voice)
|
||||
return voice.name
|
||||
}
|
||||
|
||||
// 如果没找到,尝试兼容性映射
|
||||
if (voiceOptions.value.length > 0) {
|
||||
console.log('直接匹配失败,尝试兼容性映射')
|
||||
|
||||
// 创建索引映射:voice1 → 第1个音色,voice2 → 第2个音色
|
||||
const indexMap = {
|
||||
voice1: 0,
|
||||
voice2: 1,
|
||||
voice3: 2,
|
||||
voice4: 3,
|
||||
voice5: 4,
|
||||
}
|
||||
|
||||
const index = indexMap[ttsVoiceId]
|
||||
if (index !== undefined && voiceOptions.value[index]) {
|
||||
const mappedVoice = voiceOptions.value[index]
|
||||
console.log(`索引映射: ${ttsVoiceId} → index ${index} → ${mappedVoice.name}`)
|
||||
return mappedVoice.name
|
||||
}
|
||||
}
|
||||
|
||||
console.log('所有映射方式都失败,返回原始ID:', ttsVoiceId)
|
||||
return ttsVoiceId
|
||||
}
|
||||
|
||||
// 更新显示名称
|
||||
function updateDisplayNames() {
|
||||
if (!formData.value)
|
||||
return
|
||||
|
||||
displayNames.value.vad = getModelDisplayName('VAD', formData.value.vadModelId)
|
||||
displayNames.value.asr = getModelDisplayName('ASR', formData.value.asrModelId)
|
||||
displayNames.value.llm = getModelDisplayName('LLM', formData.value.llmModelId)
|
||||
displayNames.value.vllm = getModelDisplayName('VLLM', formData.value.vllmModelId)
|
||||
displayNames.value.intent = getModelDisplayName('Intent', formData.value.intentModelId)
|
||||
displayNames.value.memory = getModelDisplayName('Memory', formData.value.memModelId)
|
||||
displayNames.value.tts = getModelDisplayName('TTS', formData.value.ttsModelId)
|
||||
|
||||
// 角色音色特殊处理
|
||||
displayNames.value.voiceprint = getVoiceDisplayName(formData.value.ttsVoiceId || '')
|
||||
|
||||
console.log('最终音色显示名称:', displayNames.value.voiceprint)
|
||||
}
|
||||
|
||||
// 加载角色模板
|
||||
async function loadRoleTemplates() {
|
||||
try {
|
||||
const templates = await getRoleTemplates()
|
||||
roleTemplates.value = templates
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载角色模板失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载模型选项
|
||||
async function loadModelOptions() {
|
||||
const modelTypes = ['VAD', 'ASR', 'LLM', 'VLLM', 'Intent', 'Memory', 'TTS']
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
modelTypes?.map(async (type) => {
|
||||
console.log(`加载模型类型: ${type}`)
|
||||
const options = await getModelOptions(type)
|
||||
modelOptions.value[type] = options
|
||||
console.log(`${type} 选项:`, options)
|
||||
}) || [],
|
||||
)
|
||||
console.log('所有模型选项加载完成:', modelOptions.value)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载模型选项失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载TTS音色选项
|
||||
async function loadVoiceOptions(ttsModelId?: string) {
|
||||
if (!ttsModelId)
|
||||
return
|
||||
|
||||
try {
|
||||
console.log(`加载音色选项: ${ttsModelId}`)
|
||||
const voices = await getTTSVoices(ttsModelId)
|
||||
voiceOptions.value = voices
|
||||
console.log('音色选项:', voices)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('加载音色选项失败:', error)
|
||||
voiceOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 选择角色模板
|
||||
function selectRoleTemplate(templateId: string) {
|
||||
if (selectedTemplateId.value === templateId) {
|
||||
selectedTemplateId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
selectedTemplateId.value = templateId
|
||||
const template = roleTemplates.value.find(t => t.id === templateId)
|
||||
if (template) {
|
||||
formData.value.systemPrompt = template.systemPrompt
|
||||
formData.value.vadModelId = template.vadModelId
|
||||
formData.value.asrModelId = template.asrModelId
|
||||
formData.value.llmModelId = template.llmModelId
|
||||
formData.value.vllmModelId = template.vllmModelId
|
||||
formData.value.intentModelId = template.intentModelId
|
||||
formData.value.memModelId = template.memModelId
|
||||
formData.value.ttsModelId = template.ttsModelId
|
||||
formData.value.ttsVoiceId = template.ttsVoiceId
|
||||
}
|
||||
}
|
||||
|
||||
// 打开选择器
|
||||
function openPicker(type: string) {
|
||||
pickerShow.value[type] = true
|
||||
}
|
||||
|
||||
// 选择器确认
|
||||
async function onPickerConfirm(type: string, value: any, name: string) {
|
||||
console.log('选择器确认:', type, value, name)
|
||||
|
||||
// 保存显示名称
|
||||
displayNames.value[type] = name
|
||||
|
||||
switch (type) {
|
||||
case 'vad':
|
||||
formData.value.vadModelId = value
|
||||
break
|
||||
case 'asr':
|
||||
formData.value.asrModelId = value
|
||||
break
|
||||
case 'llm':
|
||||
formData.value.llmModelId = value
|
||||
break
|
||||
case 'vllm':
|
||||
formData.value.vllmModelId = value
|
||||
break
|
||||
case 'intent':
|
||||
formData.value.intentModelId = value
|
||||
displayNames.value.intent = name // 确保显示名称正确更新
|
||||
break
|
||||
case 'memory':
|
||||
formData.value.memModelId = value
|
||||
displayNames.value.memory = name // 确保显示名称正确更新
|
||||
break
|
||||
case 'tts':
|
||||
formData.value.ttsModelId = value
|
||||
// 当选择TTS模型时,自动加载对应的音色选项
|
||||
await loadVoiceOptions(value)
|
||||
// 重置音色选择
|
||||
formData.value.ttsVoiceId = ''
|
||||
displayNames.value.voiceprint = '请选择'
|
||||
break
|
||||
case 'voiceprint':
|
||||
formData.value.ttsVoiceId = value
|
||||
displayNames.value.voiceprint = name // 确保显示名称正确更新
|
||||
break
|
||||
}
|
||||
|
||||
pickerShow.value[type] = false
|
||||
}
|
||||
|
||||
// 选择器取消
|
||||
function onPickerCancel(type: string) {
|
||||
pickerShow.value[type] = false
|
||||
}
|
||||
|
||||
// 获取模型显示名称
|
||||
function getModelDisplayName(modelType: string, modelId: string) {
|
||||
if (!modelId)
|
||||
return '请选择'
|
||||
|
||||
// 直接从API配置数据中查找匹配的ID
|
||||
const options = modelOptions.value[modelType]
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return modelId
|
||||
}
|
||||
|
||||
const option = options.find(opt => opt.id === modelId)
|
||||
if (option) {
|
||||
return option.modelName
|
||||
}
|
||||
return modelId
|
||||
}
|
||||
|
||||
// 保存智能体
|
||||
async function saveAgent() {
|
||||
if (!formData.value.agentName?.trim()) {
|
||||
toast.warning('请输入助手昵称')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.systemPrompt?.trim()) {
|
||||
toast.warning('请输入角色介绍')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
saving.value = true
|
||||
await updateAgent(agentId.value, formData.value)
|
||||
|
||||
toast.success('保存成功')
|
||||
}
|
||||
catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
toast.error('保存失败')
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadPluginFunctions() {
|
||||
getPluginFunctions().then((res) => {
|
||||
const processedFunctions = res?.map((item) => {
|
||||
const meta = JSON.parse(item.fields || '[]')
|
||||
const params = meta.reduce((m: any, f: any) => {
|
||||
m[f.key] = f.default
|
||||
return m
|
||||
}, {})
|
||||
return { ...item, fieldsMeta: meta, params }
|
||||
}) || []
|
||||
|
||||
allFunctions.value = processedFunctions
|
||||
// 同时更新到store
|
||||
pluginStore.setAllFunctions(processedFunctions)
|
||||
})
|
||||
}
|
||||
|
||||
function handleTools() {
|
||||
console.log('当前插件配置:', formData.value.functions)
|
||||
|
||||
// 确保store中有最新数据
|
||||
pluginStore.setCurrentAgentId(agentId.value)
|
||||
pluginStore.setCurrentFunctions(formData.value.functions || [])
|
||||
pluginStore.setAllFunctions(allFunctions.value)
|
||||
|
||||
uni.navigateTo({
|
||||
url: '/pages/agent/tools',
|
||||
})
|
||||
}
|
||||
|
||||
// 监听插件配置更新
|
||||
function watchPluginUpdates() {
|
||||
// 监听store中的插件配置变化
|
||||
watch(() => pluginStore.currentFunctions, (newFunctions) => {
|
||||
console.log('插件配置已更新:', newFunctions)
|
||||
formData.value.functions = newFunctions
|
||||
}, { deep: true })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 初始化插件配置监听
|
||||
watchPluginUpdates()
|
||||
|
||||
// 先加载模型选项和角色模板
|
||||
await Promise.all([
|
||||
loadRoleTemplates(),
|
||||
loadModelOptions(),
|
||||
loadPluginFunctions(),
|
||||
])
|
||||
|
||||
// 然后加载智能体详情,这样可以正确映射显示名称
|
||||
if (agentId.value) {
|
||||
await loadAgentDetail()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="bg-[#f5f7fb] px-[20rpx]">
|
||||
<!-- 基础信息标题 -->
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
基础信息
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 基础信息卡片 -->
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="mb-[24rpx] last:mb-0">
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
助手昵称
|
||||
</text>
|
||||
<input
|
||||
v-model="formData.agentName"
|
||||
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="text"
|
||||
placeholder="请输入助手昵称"
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] last:mb-0">
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
角色模式
|
||||
</text>
|
||||
<view class="mt-0 flex flex-wrap gap-[12rpx]">
|
||||
<view
|
||||
v-for="template in roleTemplates"
|
||||
:key="template.id"
|
||||
class="cursor-pointer rounded-[20rpx] px-[24rpx] py-[12rpx] text-[24rpx] transition-all duration-300"
|
||||
:class="selectedTemplateId === template.id
|
||||
? 'bg-[#336cff] text-white border border-[#336cff]'
|
||||
: 'bg-[rgba(51,108,255,0.1)] text-[#336cff] border border-[rgba(51,108,255,0.2)]'"
|
||||
@click="selectRoleTemplate(template.id)"
|
||||
>
|
||||
{{ template.agentName }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] last:mb-0">
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
角色介绍
|
||||
</text>
|
||||
<textarea
|
||||
v-model="formData.systemPrompt"
|
||||
:maxlength="2000"
|
||||
placeholder="请输入角色介绍"
|
||||
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
/>
|
||||
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||
{{ (formData.systemPrompt || '').length }}/2000
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模型配置标题 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
模型配置
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 模型配置卡片 -->
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="flex flex-col gap-[16rpx]">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vad')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
语音活动检测
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.vad }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('asr')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
语音识别
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.asr }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('llm')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
大语言模型
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.llm }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('vllm')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
视觉大模型
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.vllm }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('intent')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
意图识别
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.intent }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('memory')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
记忆
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.memory }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 语音设置标题 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
语音设置
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 语音设置卡片 -->
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="flex flex-col gap-[16rpx]">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('tts')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
语音合成
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.tts }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]" @click="openPicker('voiceprint')">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
角色音色
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ displayNames.voiceprint }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
|
||||
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx]">
|
||||
<view class="text-[28rpx] text-[#232338] font-medium">
|
||||
插件
|
||||
</view>
|
||||
<view class="cursor-pointer rounded-[20rpx] bg-[rgba(51,108,255,0.1)] px-[24rpx] py-[12rpx] text-[24rpx] text-[#336cff] transition-all duration-300 active:bg-[#336cff] active:text-white" @click="handleTools">
|
||||
<text>编辑功能</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 记忆历史标题 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
历史记忆
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 记忆历史卡片 -->
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="mb-[24rpx] last:mb-0">
|
||||
<textarea
|
||||
v-model="formData.summaryMemory"
|
||||
placeholder="记忆内容"
|
||||
disabled
|
||||
class="box-border h-[500rpx] w-full resize-none break-words break-all border border-[#eeeeee] rounded-[12rpx] bg-[#f0f0f0] p-[20rpx] text-[26rpx] text-[#65686f] leading-[1.6] opacity-80 outline-none"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<view class="mt-[40rpx] p-0">
|
||||
<wd-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="saving"
|
||||
custom-class="w-full h-[80rpx] rounded-[16rpx] text-[30rpx] font-semibold bg-[#336cff] active:bg-[#2d5bd1]"
|
||||
@click="saveAgent"
|
||||
>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
<!-- 模型选择器 -->
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.vad"
|
||||
:actions="modelOptions.VAD && modelOptions.VAD.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('vad')"
|
||||
@select="({ item }) => onPickerConfirm('vad', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.asr"
|
||||
:actions="modelOptions.ASR && modelOptions.ASR.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('asr')"
|
||||
@select="({ item }) => onPickerConfirm('asr', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.llm"
|
||||
:actions="modelOptions.LLM && modelOptions.LLM.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('llm')"
|
||||
@select="({ item }) => onPickerConfirm('llm', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.vllm"
|
||||
:actions="modelOptions.VLLM && modelOptions.VLLM.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('vllm')"
|
||||
@select="({ item }) => onPickerConfirm('vllm', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.intent"
|
||||
:actions="modelOptions.Intent && modelOptions.Intent.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('intent')"
|
||||
@select="({ item }) => onPickerConfirm('intent', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.memory"
|
||||
:actions="modelOptions.Memory && modelOptions.Memory.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('memory')"
|
||||
@select="({ item }) => onPickerConfirm('memory', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.tts"
|
||||
:actions="modelOptions.TTS && modelOptions.TTS.map(item => ({ name: item.modelName, value: item.id }))"
|
||||
@close="onPickerCancel('tts')"
|
||||
@select="({ item }) => onPickerConfirm('tts', item.value, item.name)"
|
||||
/>
|
||||
|
||||
<wd-action-sheet
|
||||
v-model="pickerShow.voiceprint"
|
||||
:actions="voiceOptions && voiceOptions.map(item => ({ name: item.name, value: item.id }))"
|
||||
@close="onPickerCancel('voiceprint')"
|
||||
@select="({ item }) => onPickerConfirm('voiceprint', item.value, item.name)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,214 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "智能体",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import CustomTabs from '@/components/custom-tabs/index.vue'
|
||||
import ChatHistory from '@/pages/chat-history/index.vue'
|
||||
import DeviceManagement from '@/pages/device/index.vue'
|
||||
import VoiceprintManagement from '@/pages/voiceprint/index.vue'
|
||||
import AgentEdit from './edit.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'AgentIndex',
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
|
||||
// 智能体ID
|
||||
const currentAgentId = ref('default')
|
||||
|
||||
// 当前 tab
|
||||
const currentTab = ref('agent-config')
|
||||
|
||||
// 刷新和加载状态
|
||||
const refreshing = ref(false)
|
||||
|
||||
// 计算是否启用下拉刷新(角色编辑页面不启用)
|
||||
const refresherEnabled = computed(() => {
|
||||
return currentTab.value !== 'agent-config'
|
||||
})
|
||||
|
||||
// 子组件引用
|
||||
const deviceRef = ref()
|
||||
const chatRef = ref()
|
||||
const voiceprintRef = ref()
|
||||
|
||||
// Tab 配置
|
||||
const tabList = [
|
||||
{
|
||||
label: '角色配置',
|
||||
value: 'agent-config',
|
||||
icon: '/static/tabbar/robot.png',
|
||||
activeIcon: '/static/tabbar/robot_activate.png',
|
||||
},
|
||||
{
|
||||
label: '设备管理',
|
||||
value: 'device-management',
|
||||
icon: '/static/tabbar/device.png',
|
||||
activeIcon: '/static/tabbar/device_activate.png',
|
||||
},
|
||||
{
|
||||
label: '聊天记录',
|
||||
value: 'chat-history',
|
||||
icon: '/static/tabbar/chat.png',
|
||||
activeIcon: '/static/tabbar/chat_activate.png',
|
||||
},
|
||||
{
|
||||
label: '声纹管理',
|
||||
value: 'voiceprint-management',
|
||||
icon: '/static/tabbar/microphone.png',
|
||||
activeIcon: '/static/tabbar/microphone_activate.png',
|
||||
},
|
||||
]
|
||||
|
||||
// 返回上一页
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 处理 tab 切换
|
||||
function handleTabChange(item: any) {
|
||||
console.log('Tab changed:', item)
|
||||
}
|
||||
|
||||
// 下拉刷新
|
||||
async function onRefresh() {
|
||||
// 角色编辑页面不需要刷新
|
||||
if (currentTab.value === 'agent-config') {
|
||||
return
|
||||
}
|
||||
|
||||
refreshing.value = true
|
||||
|
||||
try {
|
||||
switch (currentTab.value) {
|
||||
case 'device-management':
|
||||
if (deviceRef.value?.refresh) {
|
||||
await deviceRef.value.refresh()
|
||||
}
|
||||
break
|
||||
case 'chat-history':
|
||||
if (chatRef.value?.refresh) {
|
||||
await chatRef.value.refresh()
|
||||
}
|
||||
break
|
||||
case 'voiceprint-management':
|
||||
if (voiceprintRef.value?.refresh) {
|
||||
await voiceprintRef.value.refresh()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('刷新失败:', error)
|
||||
}
|
||||
finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 触底加载更多
|
||||
async function onLoadMore() {
|
||||
// 只有聊天记录需要加载更多
|
||||
if (currentTab.value === 'chat-history' && chatRef.value?.loadMore) {
|
||||
await chatRef.value.loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
// 接收页面参数
|
||||
onLoad((options) => {
|
||||
if (options?.agentId) {
|
||||
currentAgentId.value = options.agentId
|
||||
console.log('接收到智能体ID:', options.agentId)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// 页面初始化
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||
<!-- 导航栏 -->
|
||||
<wd-navbar title="智能体" safe-area-inset-top>
|
||||
<template #left>
|
||||
<wd-icon name="arrow-left" size="18" @click="goBack" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
|
||||
<!-- 自定义 Tabs -->
|
||||
<CustomTabs
|
||||
v-model="currentTab"
|
||||
:tab-list="tabList"
|
||||
@change="handleTabChange"
|
||||
/>
|
||||
|
||||
<!-- 主内容滚动区域 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 180rpx)` }"
|
||||
class="box-border flex-1 bg-[#f5f7fb]"
|
||||
enable-back-to-top
|
||||
:refresher-enabled="refresherEnabled"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
@scrolltolower="onLoadMore"
|
||||
>
|
||||
<!-- Tab 内容 -->
|
||||
<view class="flex-1">
|
||||
<AgentEdit
|
||||
v-if="currentTab === 'agent-config'"
|
||||
:agent-id="currentAgentId"
|
||||
/>
|
||||
<DeviceManagement
|
||||
v-else-if="currentTab === 'device-management'"
|
||||
ref="deviceRef"
|
||||
:agent-id="currentAgentId"
|
||||
/>
|
||||
<ChatHistory
|
||||
v-else-if="currentTab === 'chat-history'"
|
||||
ref="chatRef"
|
||||
:agent-id="currentAgentId"
|
||||
/>
|
||||
<VoiceprintManagement
|
||||
v-else-if="currentTab === 'voiceprint-management'"
|
||||
ref="voiceprintRef"
|
||||
:agent-id="currentAgentId"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
@@ -0,0 +1,568 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "编辑功能",
|
||||
"navigationStyle": "custom",
|
||||
},
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { getMcpAddress, getMcpTools } from '@/api/agent/agent'
|
||||
import { usePluginStore } from '@/store'
|
||||
|
||||
const message = useMessage()
|
||||
const pluginStore = usePluginStore()
|
||||
|
||||
const segmentedList = ref<string[]>(['未选', '已选'])
|
||||
const currentSegmented = ref('未选')
|
||||
const notSelectedList = ref<any[]>([])
|
||||
const selectedList = ref<any[]>([])
|
||||
|
||||
// 使用计算属性从store获取数据
|
||||
const allFunctions = computed(() => pluginStore.allFunctions)
|
||||
const functions = computed(() => pluginStore.currentFunctions)
|
||||
const agentId = computed(() => pluginStore.currentAgentId)
|
||||
const mcpAddress = ref('')
|
||||
const mcpTools = ref<string[]>([])
|
||||
|
||||
// 参数编辑相关
|
||||
const showParamDialog = ref(false)
|
||||
const currentFunction = ref<any>(null)
|
||||
const tempParams = ref<Record<string, any>>({})
|
||||
const arrayTextCache = ref<Record<string, string>>({})
|
||||
const jsonTextCache = ref<Record<string, string>>({})
|
||||
|
||||
async function mergeFunctions() {
|
||||
selectedList.value = functions.value.map((mapping) => {
|
||||
const meta = allFunctions.value.find(f => f.id === mapping.pluginId)
|
||||
if (!meta) {
|
||||
return { id: mapping.pluginId, name: mapping.pluginId, params: {} }
|
||||
}
|
||||
|
||||
return {
|
||||
id: mapping.pluginId,
|
||||
name: meta.name,
|
||||
params: mapping.paramInfo || { ...meta.params },
|
||||
fieldsMeta: meta.fieldsMeta,
|
||||
}
|
||||
})
|
||||
|
||||
// 未选的插件
|
||||
notSelectedList.value = allFunctions.value.filter(
|
||||
item => !selectedList.value.some(f => f.id === item.id),
|
||||
)
|
||||
|
||||
if (agentId.value) {
|
||||
const [address, tools] = await Promise.all([
|
||||
getMcpAddress(agentId.value),
|
||||
getMcpTools(agentId.value),
|
||||
])
|
||||
mcpAddress.value = address
|
||||
mcpTools.value = tools || []
|
||||
}
|
||||
}
|
||||
|
||||
// 添加插件到已选
|
||||
function selectFunction(func: any) {
|
||||
// 添加到已选列表
|
||||
selectedList.value.push({
|
||||
id: func.id,
|
||||
name: func.name,
|
||||
params: { ...func.params },
|
||||
fieldsMeta: func.fieldsMeta,
|
||||
})
|
||||
|
||||
// 从未选列表中移除
|
||||
notSelectedList.value = notSelectedList.value.filter(
|
||||
item => item.id !== func.id,
|
||||
)
|
||||
}
|
||||
|
||||
// 从已选中移除插件
|
||||
function removeFunction(func: any) {
|
||||
// 从已选列表中移除
|
||||
selectedList.value = selectedList.value.filter(item => item.id !== func.id)
|
||||
|
||||
// 添加回未选列表
|
||||
const originalFunc = allFunctions.value.find(f => f.id === func.id)
|
||||
if (originalFunc) {
|
||||
notSelectedList.value.push(originalFunc)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑插件参数
|
||||
function editFunction(func: any) {
|
||||
currentFunction.value = func
|
||||
|
||||
// 直接使用当前函数的参数
|
||||
tempParams.value = { ...func.params }
|
||||
|
||||
// 初始化文本缓存
|
||||
if (func.fieldsMeta) {
|
||||
func.fieldsMeta.forEach((field: any) => {
|
||||
if (field.type === 'array') {
|
||||
const value = tempParams.value[field.key]
|
||||
arrayTextCache.value[field.key] = Array.isArray(value)
|
||||
? value.join('\n')
|
||||
: value || ''
|
||||
}
|
||||
else if (field.type === 'json') {
|
||||
const value = tempParams.value[field.key]
|
||||
try {
|
||||
jsonTextCache.value[field.key] = JSON.stringify(value || {}, null, 2)
|
||||
}
|
||||
catch {
|
||||
jsonTextCache.value[field.key] = '{}'
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
showParamDialog.value = true
|
||||
}
|
||||
|
||||
// 处理参数变化 - 实时保存
|
||||
function handleParamChange(key: string, value: any, field: any) {
|
||||
tempParams.value[key] = value
|
||||
|
||||
// 实时更新到 selectedList
|
||||
if (currentFunction.value) {
|
||||
const index = selectedList.value.findIndex(
|
||||
f => f.id === currentFunction.value.id,
|
||||
)
|
||||
if (index >= 0) {
|
||||
selectedList.value[index].params = { ...tempParams.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理数组类型参数变化 - 实时保存
|
||||
function handleArrayChange(key: string, value: string, field: any) {
|
||||
arrayTextCache.value[key] = value
|
||||
// 转换为数组存储
|
||||
const arrayValue = value.split('\n').filter(Boolean)
|
||||
tempParams.value[key] = arrayValue
|
||||
|
||||
// 实时更新到 selectedList
|
||||
if (currentFunction.value) {
|
||||
const index = selectedList.value.findIndex(
|
||||
f => f.id === currentFunction.value.id,
|
||||
)
|
||||
if (index >= 0) {
|
||||
selectedList.value[index].params = { ...tempParams.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理JSON类型参数变化 - 实时保存
|
||||
function handleJsonChange(key: string, value: string, field: any) {
|
||||
jsonTextCache.value[key] = value
|
||||
try {
|
||||
const jsonValue = JSON.parse(value)
|
||||
tempParams.value[key] = jsonValue
|
||||
|
||||
// 实时更新到 selectedList
|
||||
if (currentFunction.value) {
|
||||
const index = selectedList.value.findIndex(
|
||||
f => f.id === currentFunction.value.id,
|
||||
)
|
||||
if (index >= 0) {
|
||||
selectedList.value[index].params = { ...tempParams.value }
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
message.alert('JSON格式错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭参数编辑弹窗
|
||||
function closeParamEdit() {
|
||||
showParamDialog.value = false
|
||||
tempParams.value = {}
|
||||
arrayTextCache.value = {}
|
||||
jsonTextCache.value = {}
|
||||
}
|
||||
|
||||
// 返回上一页并更新配置
|
||||
function goBack() {
|
||||
const finalFunctions = selectedList.value.map(f => ({
|
||||
pluginId: f.id,
|
||||
paramInfo: f.params,
|
||||
}))
|
||||
|
||||
// 更新到store中
|
||||
pluginStore.updateFunctions(finalFunctions)
|
||||
|
||||
// 直接返回
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 复制MCP地址
|
||||
function copyMcpAddress() {
|
||||
if (!mcpAddress.value) {
|
||||
message.alert('暂无MCP地址可复制')
|
||||
return
|
||||
}
|
||||
|
||||
uni.setClipboardData({
|
||||
data: mcpAddress.value,
|
||||
showToast: false,
|
||||
success: () => {
|
||||
message.alert('MCP地址已复制到剪贴板')
|
||||
},
|
||||
fail: () => {
|
||||
message.alert('复制失败,请重试')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 渲染参数字段的辅助函数
|
||||
function getFieldDisplayValue(field: any, value: any) {
|
||||
if (field.type === 'array') {
|
||||
return Array.isArray(value) ? value.join('\n') : value || ''
|
||||
}
|
||||
return value || ''
|
||||
}
|
||||
|
||||
// 字段说明
|
||||
function getFieldRemark(field: any) {
|
||||
let description = field.label || ''
|
||||
if (field.default) {
|
||||
description += `(默认值:${field.default})`
|
||||
}
|
||||
return description
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 直接从store获取数据并合并
|
||||
await mergeFunctions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||
<!-- 头部导航 -->
|
||||
<wd-navbar
|
||||
title=""
|
||||
safe-area-inset-top
|
||||
left-arrow
|
||||
:bordered="false"
|
||||
@click-left="goBack"
|
||||
>
|
||||
<template #left>
|
||||
<wd-icon name="arrow-left" size="18" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="box-border flex-1 bg-transparent px-[20rpx]"
|
||||
:style="{ height: 'calc(100vh - 120rpx)' }"
|
||||
:scroll-with-animation="true"
|
||||
>
|
||||
<!-- 内置插件区域 -->
|
||||
<view class="mt-[20rpx] flex flex-1 flex-col">
|
||||
<view class="text-[32rpx] text-[#333] font-medium">
|
||||
内置插件
|
||||
</view>
|
||||
<view
|
||||
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
|
||||
>
|
||||
<!-- 分段控制器 -->
|
||||
<wd-segmented
|
||||
v-model:value="currentSegmented"
|
||||
:options="segmentedList"
|
||||
/>
|
||||
|
||||
<!-- 插件列表 -->
|
||||
<view class="mt-[20rpx] flex-1 overflow-hidden">
|
||||
<!-- 未选插件 -->
|
||||
<scroll-view
|
||||
v-if="currentSegmented === '未选'"
|
||||
class="max-h-[600rpx] bg-transparent"
|
||||
scroll-y
|
||||
>
|
||||
<view
|
||||
v-if="notSelectedList.length === 0"
|
||||
class="h-[400rpx] flex items-center justify-center"
|
||||
>
|
||||
<wd-status-tip image="content" tip="暂无更多插件" />
|
||||
</view>
|
||||
<view v-else class="p-[20rpx] space-y-[20rpx]">
|
||||
<view
|
||||
v-for="func in notSelectedList"
|
||||
:key="func.id"
|
||||
class="flex items-center justify-between border border-[#e9ecef] rounded-[10rpx] bg-[#f8f9fa] p-[20rpx]"
|
||||
@click="selectFunction(func)"
|
||||
>
|
||||
<view class="flex-1">
|
||||
<view
|
||||
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
|
||||
>
|
||||
{{ func.name }}
|
||||
</view>
|
||||
<view class="text-[24rpx] text-[#666]">
|
||||
{{ func.providerCode }}
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
|
||||
>
|
||||
<text class="text-[36rpx] text-white">
|
||||
+
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 已选插件 -->
|
||||
<scroll-view v-else class="max-h-[600rpx] bg-transparent" scroll-y>
|
||||
<view
|
||||
v-if="selectedList.length === 0"
|
||||
class="h-[400rpx] flex items-center justify-center"
|
||||
>
|
||||
<wd-status-tip image="content" tip="请选择插件功能" />
|
||||
</view>
|
||||
<view v-else class="p-[20rpx] space-y-[20rpx]">
|
||||
<view
|
||||
v-for="func in selectedList"
|
||||
:key="func.id"
|
||||
class="border border-[#d4edff] rounded-[10rpx] bg-[#f0f7ff] p-[20rpx]"
|
||||
>
|
||||
<view class="flex items-center justify-between">
|
||||
<view class="flex-1" @click="editFunction(func)">
|
||||
<view
|
||||
class="mb-[10rpx] text-[30rpx] text-[#333] font-medium"
|
||||
>
|
||||
{{ func.name }}
|
||||
</view>
|
||||
<view class="text-[24rpx] text-[#1677ff]">
|
||||
点击配置参数
|
||||
</view>
|
||||
</view>
|
||||
<view class="flex space-x-[20rpx]">
|
||||
<!-- 配置按钮 -->
|
||||
<view
|
||||
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#1677ff]"
|
||||
@click="editFunction(func)"
|
||||
>
|
||||
<text class="text-[24rpx] text-white">
|
||||
⚙
|
||||
</text>
|
||||
</view>
|
||||
<!-- 移除按钮 -->
|
||||
<view
|
||||
class="h-[60rpx] w-[60rpx] flex items-center justify-center rounded-full bg-[#ff4757]"
|
||||
@click="removeFunction(func)"
|
||||
>
|
||||
<text class="text-[32rpx] text-white">
|
||||
×
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- MCP接入点区域 -->
|
||||
<view class="mt-[20rpx] flex flex-1 flex-col">
|
||||
<view class="text-[32rpx] text-[#333] font-medium">
|
||||
mcp接入点
|
||||
</view>
|
||||
<view
|
||||
class="mt-[20rpx] box-border flex flex-1 flex-col rounded-[10rpx] bg-white p-[20rpx]"
|
||||
>
|
||||
<view class="flex items-center justify-between text-[24rpx]">
|
||||
<input
|
||||
v-model="mcpAddress"
|
||||
type="text"
|
||||
disabled
|
||||
class="flex-1 rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
|
||||
>
|
||||
<view
|
||||
class="ml-[20rpx] h-[70rpx] flex items-center justify-center rounded-[10rpx] bg-[#1677ff] px-[20rpx] text-[24rpx] text-white"
|
||||
@click="copyMcpAddress"
|
||||
>
|
||||
复制
|
||||
</view>
|
||||
</view>
|
||||
<!-- 工具列表 -->
|
||||
<view class="mt-[20rpx] flex-1 overflow-hidden">
|
||||
<scroll-view class="max-h-[600rpx] bg-transparent" scroll-y>
|
||||
<view
|
||||
v-if="mcpTools && mcpTools.length === 0"
|
||||
class="h-[400rpx] flex items-center justify-center"
|
||||
>
|
||||
<wd-status-tip image="content" tip="暂无工具" />
|
||||
</view>
|
||||
<view v-else class="p-[20rpx]">
|
||||
<view class="flex flex-wrap">
|
||||
<view
|
||||
v-for="tool in mcpTools"
|
||||
:key="tool"
|
||||
class="mb-[20rpx] mr-[20rpx] rounded-[10rpx] bg-[#f5f7fb] p-[20rpx]"
|
||||
>
|
||||
{{ tool }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 参数编辑弹窗 -->
|
||||
<wd-action-sheet
|
||||
v-model="showParamDialog"
|
||||
:title="`参数配置 - ${currentFunction?.name || ''}`"
|
||||
custom-header-class="h-[75vh]"
|
||||
@close="closeParamEdit"
|
||||
>
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="bg-[#f5f7fb]"
|
||||
:style="{ height: 'calc(75vh - 60rpx)' }"
|
||||
>
|
||||
<view class="p-[30rpx] pb-[40rpx]">
|
||||
<!-- 无参数提示 -->
|
||||
<view
|
||||
v-if="
|
||||
!currentFunction?.fieldsMeta
|
||||
|| currentFunction.fieldsMeta.length === 0
|
||||
"
|
||||
class="h-[400rpx] flex items-center justify-center"
|
||||
>
|
||||
<text class="text-[28rpx] text-[#999]">
|
||||
{{ currentFunction?.name }} 无需配置参数
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 参数表单 - 卡片式布局 -->
|
||||
<view v-else class="flex flex-col gap-[24rpx]">
|
||||
<view
|
||||
v-for="field in currentFunction.fieldsMeta"
|
||||
:key="field.key"
|
||||
class="border border-[#eeeeee] rounded-[20rpx] bg-white p-[30rpx]"
|
||||
style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);"
|
||||
>
|
||||
<!-- 字段信息 -->
|
||||
<view class="mb-[24rpx]">
|
||||
<text class="mb-[8rpx] block text-[32rpx] text-[#232338] font-medium">
|
||||
{{ field.label }}
|
||||
</text>
|
||||
<text v-if="getFieldRemark(field)" class="block text-[24rpx] text-[#65686f] leading-[1.5]">
|
||||
{{ getFieldRemark(field) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 输入控件 -->
|
||||
<view>
|
||||
<!-- 字符串类型 -->
|
||||
<input
|
||||
v-if="field.type === 'string'"
|
||||
v-model="tempParams[field.key]"
|
||||
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="text"
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="
|
||||
handleParamChange(field.key, $event.detail.value, field)
|
||||
"
|
||||
>
|
||||
|
||||
<!-- 数组类型 -->
|
||||
<view v-else-if="field.type === 'array'">
|
||||
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
|
||||
每行输入一个项目
|
||||
</text>
|
||||
<textarea
|
||||
v-model="arrayTextCache[field.key]"
|
||||
class="box-border min-h-[200rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
:placeholder="`请输入${field.label},每行一个`"
|
||||
@input="
|
||||
handleArrayChange(field.key, $event.detail.value, field)
|
||||
"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- JSON类型 -->
|
||||
<view v-else-if="field.type === 'json'">
|
||||
<text class="mb-[16rpx] block text-[24rpx] text-[#65686f]">
|
||||
请输入有效的JSON格式
|
||||
</text>
|
||||
<textarea
|
||||
v-model="jsonTextCache[field.key]"
|
||||
class="box-border min-h-[300rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] font-mono focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
placeholder="请输入合法的JSON格式"
|
||||
@blur="
|
||||
handleJsonChange(field.key, $event.detail.value, field)
|
||||
"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 数字类型 -->
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="tempParams[field.key]"
|
||||
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="number"
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="
|
||||
handleParamChange(
|
||||
field.key,
|
||||
Number($event.detail.value),
|
||||
field,
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
<!-- 布尔类型 -->
|
||||
<view
|
||||
v-else-if="field.type === 'boolean' || field.type === 'bool'"
|
||||
class="flex items-center justify-between py-[20rpx]"
|
||||
>
|
||||
<view class="flex-1">
|
||||
<text class="mb-[8rpx] block text-[28rpx] text-[#232338]">
|
||||
启用功能
|
||||
</text>
|
||||
<text class="block text-[24rpx] text-[#65686f]">
|
||||
开启或关闭此功能
|
||||
</text>
|
||||
</view>
|
||||
<switch
|
||||
:checked="tempParams[field.key]"
|
||||
@change="
|
||||
handleParamChange(field.key, $event.detail.value, field)
|
||||
"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 默认字符串类型 -->
|
||||
<input
|
||||
v-else
|
||||
v-model="tempParams[field.key]"
|
||||
class="box-border h-[80rpx] w-full border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="text"
|
||||
:placeholder="`请输入${field.label}`"
|
||||
@input="
|
||||
handleParamChange(field.key, $event.detail.value, field)
|
||||
"
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</wd-action-sheet>
|
||||
</view>
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "聊天详情"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ChatMessage, UserMessageContent } from '@/api/chat-history/types'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getAudioId, getChatHistory } from '@/api/chat-history/chat-history'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChatDetail',
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 页面参数
|
||||
const sessionId = ref('')
|
||||
const agentId = ref('')
|
||||
|
||||
// 智能体信息(简化)
|
||||
const currentAgent = computed(() => {
|
||||
return {
|
||||
id: agentId.value,
|
||||
agentName: '智能助手',
|
||||
}
|
||||
})
|
||||
|
||||
// 聊天数据
|
||||
const messageList = ref<ChatMessage[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 音频播放相关
|
||||
const audioContext = ref<UniApp.InnerAudioContext | null>(null)
|
||||
const playingAudioId = ref<string | null>(null)
|
||||
|
||||
// 返回上一页
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 加载聊天记录
|
||||
async function loadChatHistory() {
|
||||
if (!sessionId.value || !agentId.value) {
|
||||
console.error('缺少必要参数')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await getChatHistory(agentId.value, sessionId.value)
|
||||
messageList.value = response
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取聊天记录失败:', error)
|
||||
toast.error('获取聊天记录失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 解析用户消息内容
|
||||
function parseUserMessage(content: string): UserMessageContent | null {
|
||||
try {
|
||||
return JSON.parse(content)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取消息显示内容
|
||||
function getMessageContent(message: ChatMessage): string {
|
||||
if (message.chatType === 1) {
|
||||
// 用户消息,需要解析JSON
|
||||
const parsed = parseUserMessage(message.content)
|
||||
return parsed ? parsed.content : message.content
|
||||
}
|
||||
else {
|
||||
// AI消息,直接显示
|
||||
return message.content
|
||||
}
|
||||
}
|
||||
|
||||
// 获取说话人名称
|
||||
function getSpeakerName(message: ChatMessage): string {
|
||||
if (message.chatType === 1) {
|
||||
const parsed = parseUserMessage(message.content)
|
||||
return parsed ? parsed.speaker : '用户'
|
||||
}
|
||||
else {
|
||||
return currentAgent.value?.agentName || 'AI助手'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
const date = new Date(timeStr)
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
async function playAudio(audioId: string) {
|
||||
if (!audioId) {
|
||||
toast.error('音频ID无效')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果正在播放其他音频,先停止
|
||||
if (audioContext.value) {
|
||||
audioContext.value.stop()
|
||||
audioContext.value.destroy()
|
||||
audioContext.value = null
|
||||
}
|
||||
|
||||
// 获取音频下载ID
|
||||
const downloadId = await getAudioId(audioId)
|
||||
|
||||
// 构造音频播放地址
|
||||
const baseUrl = getEnvBaseUrl()
|
||||
const audioUrl = `${baseUrl}/agent/play/${downloadId}`
|
||||
|
||||
// 创建音频上下文
|
||||
audioContext.value = uni.createInnerAudioContext()
|
||||
audioContext.value.src = audioUrl
|
||||
|
||||
// 设置播放状态
|
||||
playingAudioId.value = audioId
|
||||
|
||||
// 监听播放完成
|
||||
audioContext.value.onEnded(() => {
|
||||
playingAudioId.value = null
|
||||
if (audioContext.value) {
|
||||
audioContext.value.destroy()
|
||||
audioContext.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// 监听播放错误
|
||||
audioContext.value.onError((error) => {
|
||||
console.error('音频播放失败:', error)
|
||||
toast.error('音频播放失败')
|
||||
playingAudioId.value = null
|
||||
if (audioContext.value) {
|
||||
audioContext.value.destroy()
|
||||
audioContext.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// 开始播放
|
||||
audioContext.value.play()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('播放音频失败:', error)
|
||||
toast.error('播放音频失败')
|
||||
playingAudioId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
if (options?.sessionId && options?.agentId) {
|
||||
sessionId.value = options.sessionId
|
||||
agentId.value = options.agentId
|
||||
loadChatHistory()
|
||||
}
|
||||
else {
|
||||
console.error('缺少必要参数')
|
||||
toast.error('页面参数错误')
|
||||
}
|
||||
})
|
||||
|
||||
// 页面销毁时清理音频资源
|
||||
onUnload(() => {
|
||||
if (audioContext.value) {
|
||||
audioContext.value.stop()
|
||||
audioContext.value.destroy()
|
||||
audioContext.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="h-screen flex flex-col bg-[#f5f7fb]">
|
||||
<!-- 状态栏背景 -->
|
||||
<view class="w-full bg-white" :style="{ height: `${safeAreaInsets?.top}px` }" />
|
||||
|
||||
<!-- 导航栏 -->
|
||||
<wd-navbar title="聊天详情">
|
||||
<template #left>
|
||||
<wd-icon name="arrow-left" size="18" @click="goBack" />
|
||||
</template>
|
||||
</wd-navbar>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<scroll-view
|
||||
scroll-y
|
||||
:style="{ height: `calc(100vh - ${safeAreaInsets?.top || 0}px - 120rpx)` }"
|
||||
class="box-border flex-1 bg-[#f5f7fb] p-[20rpx]"
|
||||
:scroll-into-view="`message-${messageList.length - 1}`"
|
||||
>
|
||||
<view v-if="loading" class="flex flex-col items-center justify-center gap-[20rpx] p-[100rpx_0]">
|
||||
<wd-loading />
|
||||
<text class="text-[28rpx] text-[#65686f]">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="flex flex-col gap-[20rpx]">
|
||||
<view
|
||||
v-for="(message, index) in messageList"
|
||||
:id="`message-${index}`"
|
||||
:key="index"
|
||||
class="w-full flex"
|
||||
:class="{
|
||||
'justify-end': message.chatType === 1,
|
||||
'justify-start': message.chatType === 2,
|
||||
}"
|
||||
>
|
||||
<view
|
||||
class="max-w-[80%] flex flex-col gap-[8rpx]"
|
||||
:class="{
|
||||
'items-end': message.chatType === 1,
|
||||
'items-start': message.chatType === 2,
|
||||
}"
|
||||
>
|
||||
<!-- 消息气泡 -->
|
||||
<view
|
||||
class="shadow-message break-words rounded-[20rpx] p-[24rpx] leading-[1.4]"
|
||||
:class="{
|
||||
'bg-[#336cff] text-white': message.chatType === 1,
|
||||
'bg-white text-[#232338] border border-[#eeeeee]': message.chatType === 2,
|
||||
}"
|
||||
>
|
||||
<!-- 内容区域 - 使用flex布局让图标和文本对齐 -->
|
||||
<view class="flex items-center gap-[12rpx]">
|
||||
<!-- 音频播放图标 -->
|
||||
<view
|
||||
v-if="message.audioId"
|
||||
class="flex-shrink-0 cursor-pointer transition-transform duration-200 active:scale-90"
|
||||
:class="{
|
||||
'text-white animate-pulse-audio': message.chatType === 1 && playingAudioId === message.audioId,
|
||||
'text-[#ffd700]': message.chatType === 1 && playingAudioId === message.audioId && playingAudioId,
|
||||
'text-[#336cff] animate-pulse-audio': message.chatType === 2 && playingAudioId === message.audioId,
|
||||
'text-[#ff6b35]': message.chatType === 2 && playingAudioId === message.audioId && playingAudioId,
|
||||
'text-white': message.chatType === 1 && playingAudioId !== message.audioId,
|
||||
'text-[#336cff]': message.chatType === 2 && playingAudioId !== message.audioId,
|
||||
}"
|
||||
@click="playAudio(message.audioId)"
|
||||
>
|
||||
<wd-icon
|
||||
:name="playingAudioId === message.audioId ? 'pause-circle-filled' : 'play-circle-filled'"
|
||||
size="20"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 消息内容容器 -->
|
||||
<view class="min-w-0 flex-1">
|
||||
<!-- 消息内容 -->
|
||||
<text class="block text-[28rpx]">
|
||||
{{ getMessageContent(message) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 说话人信息 -->
|
||||
<text
|
||||
class="mx-[12rpx] text-[22rpx] text-[#9d9ea3]"
|
||||
:class="{
|
||||
'text-right': message.chatType === 1,
|
||||
'text-left': message.chatType === 2,
|
||||
}"
|
||||
>
|
||||
{{ formatTime(message.createdAt) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 自定义阴影和动画效果,无法用UnoCSS表示的样式 */
|
||||
.shadow-message {
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@keyframes pulse-audio {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-audio {
|
||||
animation: pulse-audio 1.5s infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,399 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatSession } from '@/api/chat-history/types'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getChatSessions } from '@/api/chat-history/chat-history'
|
||||
|
||||
defineOptions({
|
||||
name: 'ChatHistory',
|
||||
})
|
||||
|
||||
// 接收props
|
||||
interface Props {
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: 'default'
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 聊天会话数据
|
||||
const sessionList = ref<ChatSession[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = ref(true)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = 10
|
||||
|
||||
// 使用传入的智能体ID
|
||||
const currentAgentId = computed(() => {
|
||||
return props.agentId
|
||||
})
|
||||
|
||||
// 加载聊天会话列表
|
||||
async function loadChatSessions(page = 1, isRefresh = false) {
|
||||
try {
|
||||
console.log('获取聊天会话列表', { page, isRefresh })
|
||||
|
||||
// 检查是否有当前选中的智能体
|
||||
if (!currentAgentId.value) {
|
||||
console.warn('没有选中的智能体')
|
||||
sessionList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (page === 1) {
|
||||
loading.value = true
|
||||
}
|
||||
else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
|
||||
const response = await getChatSessions(currentAgentId.value, {
|
||||
page,
|
||||
limit: pageSize,
|
||||
})
|
||||
|
||||
if (page === 1) {
|
||||
sessionList.value = response.list || []
|
||||
}
|
||||
else {
|
||||
sessionList.value.push(...(response.list || []))
|
||||
}
|
||||
|
||||
// 更新分页信息
|
||||
hasMore.value = (response.list?.length || 0) === pageSize
|
||||
currentPage.value = page
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取聊天会话列表失败:', error)
|
||||
if (page === 1) {
|
||||
sessionList.value = []
|
||||
}
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的刷新方法
|
||||
async function refresh() {
|
||||
currentPage.value = 1
|
||||
hasMore.value = true
|
||||
await loadChatSessions(1, true)
|
||||
}
|
||||
|
||||
// 暴露给父组件的加载更多方法
|
||||
async function loadMore() {
|
||||
if (!hasMore.value || loadingMore.value) {
|
||||
return
|
||||
}
|
||||
await loadChatSessions(currentPage.value + 1)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr)
|
||||
return '未知时间'
|
||||
|
||||
// 处理时间字符串,确保格式正确
|
||||
const date = new Date(timeStr.replace(' ', 'T')) // 转换为ISO格式
|
||||
const now = new Date()
|
||||
|
||||
// 检查日期是否有效
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return timeStr // 如果解析失败,直接返回原字符串
|
||||
}
|
||||
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 小于1分钟
|
||||
if (diff < 60000)
|
||||
return '刚刚'
|
||||
|
||||
// 小于1小时
|
||||
if (diff < 3600000)
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
|
||||
// 小于1天(24小时)
|
||||
if (diff < 86400000)
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
|
||||
// 小于7天
|
||||
if (diff < 604800000) {
|
||||
const days = Math.floor(diff / 86400000)
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
// 超过7天,显示具体日期
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const currentYear = now.getFullYear()
|
||||
|
||||
// 如果是当前年份,不显示年份
|
||||
if (year === currentYear) {
|
||||
return `${month}-${day}`
|
||||
}
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 进入聊天详情
|
||||
function goToChatDetail(session: ChatSession) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/chat-history/detail?sessionId=${session.sessionId}&agentId=${currentAgentId.value}`,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 智能体已简化为默认
|
||||
|
||||
loadChatSessions(1)
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh,
|
||||
loadMore,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="chat-history-container" style="background: #f5f7fb; min-height: 100%;">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading && sessionList.length === 0" class="loading-container">
|
||||
<wd-loading color="#336cff" />
|
||||
<text class="loading-text">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<view v-else-if="sessionList.length > 0" class="session-container">
|
||||
<!-- 聊天会话列表 -->
|
||||
<view class="session-list">
|
||||
<view
|
||||
v-for="session in sessionList"
|
||||
:key="session.sessionId"
|
||||
class="session-item"
|
||||
@click="goToChatDetail(session)"
|
||||
>
|
||||
<view class="session-card">
|
||||
<view class="session-info">
|
||||
<view class="session-header">
|
||||
<text class="session-title">
|
||||
对话记录 {{ session.sessionId.substring(0, 8) }}...
|
||||
</text>
|
||||
<text class="session-time">
|
||||
{{ formatTime(session.createdAt) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="session-meta">
|
||||
<text class="chat-count">
|
||||
共 {{ session.chatCount }} 条对话
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多状态 -->
|
||||
<view v-if="loadingMore" class="loading-more">
|
||||
<wd-loading color="#336cff" size="24" />
|
||||
<text class="loading-more-text">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 没有更多数据 -->
|
||||
<view v-else-if="!hasMore && sessionList.length > 0" class="no-more">
|
||||
<text class="no-more-text">
|
||||
没有更多数据了
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="!loading" class="empty-state">
|
||||
<wd-icon name="chat" custom-class="empty-icon" />
|
||||
<text class="empty-text">
|
||||
暂无聊天记录
|
||||
</text>
|
||||
<text class="empty-desc">
|
||||
与智能体的对话记录会显示在这里
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-history-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx;
|
||||
gap: 16rpx;
|
||||
|
||||
.loading-more-text {
|
||||
font-size: 26rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
|
||||
.no-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx;
|
||||
|
||||
.no-more-text {
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-section {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #ffffff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
background: #fbfbfb;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
border: 1rpx solid #eeeeee;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx;
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
.session-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
max-width: 70%;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.session-time {
|
||||
font-size: 24rpx;
|
||||
color: #9d9ea3;
|
||||
}
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
.chat-count {
|
||||
font-size: 28rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arrow-icon) {
|
||||
font-size: 24rpx;
|
||||
color: #c7c7cc;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
text-align: center;
|
||||
|
||||
:deep(.empty-icon) {
|
||||
font-size: 120rpx;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 16rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,684 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
|
||||
// 类型定义
|
||||
interface WiFiNetwork {
|
||||
ssid: string
|
||||
rssi: number
|
||||
authmode: number
|
||||
channel: number
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
selectedNetwork: WiFiNetwork | null
|
||||
password: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Toast 实例
|
||||
const toast = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const generating = ref(false)
|
||||
const playing = ref(false)
|
||||
const audioGenerated = ref(false)
|
||||
const autoLoop = ref(true)
|
||||
const audioFilePath = ref('')
|
||||
const audioContext = ref<any>(null)
|
||||
|
||||
// AFSK调制参数 - 参考HTML文件
|
||||
const MARK = 1800 // 二进制1的频率 (Hz)
|
||||
const SPACE = 1500 // 二进制0的频率 (Hz)
|
||||
const SAMPLE_RATE = 44100 // 采样率
|
||||
const BIT_RATE = 100 // 比特率 (bps)
|
||||
const START_BYTES = [0x01, 0x02] // 起始标记
|
||||
const END_BYTES = [0x03, 0x04] // 结束标记
|
||||
|
||||
// 计算属性
|
||||
const canGenerate = computed(() => {
|
||||
if (!props.selectedNetwork)
|
||||
return false
|
||||
if (props.selectedNetwork.authmode > 0 && !props.password)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
const audioLengthText = computed(() => {
|
||||
if (!props.selectedNetwork)
|
||||
return '0秒'
|
||||
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||
const textBytes = stringToBytes(dataStr)
|
||||
const totalBits = (START_BYTES.length + textBytes.length + 1 + END_BYTES.length) * 8
|
||||
const duration = Math.ceil(totalBits / BIT_RATE)
|
||||
return `约${duration}秒`
|
||||
})
|
||||
|
||||
// 字符串转字节数组 - uniapp兼容版本
|
||||
function stringToBytes(str: string): number[] {
|
||||
const bytes: number[] = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i)
|
||||
if (code < 0x80) {
|
||||
bytes.push(code)
|
||||
}
|
||||
else if (code < 0x800) {
|
||||
bytes.push(0xC0 | (code >> 6))
|
||||
bytes.push(0x80 | (code & 0x3F))
|
||||
}
|
||||
else if (code < 0xD800 || code >= 0xE000) {
|
||||
bytes.push(0xE0 | (code >> 12))
|
||||
bytes.push(0x80 | ((code >> 6) & 0x3F))
|
||||
bytes.push(0x80 | (code & 0x3F))
|
||||
}
|
||||
else {
|
||||
// 代理对处理
|
||||
i++
|
||||
const hi = code
|
||||
const lo = str.charCodeAt(i)
|
||||
const codePoint = 0x10000 + (((hi & 0x3FF) << 10) | (lo & 0x3FF))
|
||||
bytes.push(0xF0 | (codePoint >> 18))
|
||||
bytes.push(0x80 | ((codePoint >> 12) & 0x3F))
|
||||
bytes.push(0x80 | ((codePoint >> 6) & 0x3F))
|
||||
bytes.push(0x80 | (codePoint & 0x3F))
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
// 校验和计算 - 参考HTML文件
|
||||
function checksum(data: number[]): number {
|
||||
return data.reduce((sum, b) => (sum + b) & 0xFF, 0)
|
||||
}
|
||||
|
||||
// 字节转比特位 - 参考HTML文件
|
||||
function toBits(byte: number): number[] {
|
||||
const bits: number[] = []
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
bits.push((byte >> i) & 1)
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
// AFSK调制 - 参考HTML文件算法
|
||||
function afskModulate(bits: number[]): Float32Array {
|
||||
const samplesPerBit = SAMPLE_RATE / BIT_RATE
|
||||
const totalSamples = Math.floor(bits.length * samplesPerBit)
|
||||
const buffer = new Float32Array(totalSamples)
|
||||
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
const freq = bits[i] ? MARK : SPACE
|
||||
for (let j = 0; j < samplesPerBit; j++) {
|
||||
const t = (i * samplesPerBit + j) / SAMPLE_RATE
|
||||
buffer[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
// 浮点转16位PCM - 参考HTML文件
|
||||
function floatTo16BitPCM(floatSamples: Float32Array): Uint8Array {
|
||||
const buffer = new Uint8Array(floatSamples.length * 2)
|
||||
for (let i = 0; i < floatSamples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, floatSamples[i]))
|
||||
const val = s < 0 ? s * 0x8000 : s * 0x7FFF
|
||||
buffer[i * 2] = val & 0xFF
|
||||
buffer[i * 2 + 1] = (val >> 8) & 0xFF
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
// base64编码表
|
||||
const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
|
||||
// 兼容的base64编码实现
|
||||
function base64Encode(bytes: Uint8Array): string {
|
||||
let result = ''
|
||||
let i = 0
|
||||
|
||||
while (i < bytes.length) {
|
||||
const a = bytes[i++]
|
||||
const b = i < bytes.length ? bytes[i++] : 0
|
||||
const c = i < bytes.length ? bytes[i++] : 0
|
||||
|
||||
const bitmap = (a << 16) | (b << 8) | c
|
||||
|
||||
result += base64Chars.charAt((bitmap >> 18) & 63)
|
||||
result += base64Chars.charAt((bitmap >> 12) & 63)
|
||||
result += i - 2 < bytes.length ? base64Chars.charAt((bitmap >> 6) & 63) : '='
|
||||
result += i - 1 < bytes.length ? base64Chars.charAt(bitmap & 63) : '='
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 数组转base64编码 - 兼容版本
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
|
||||
// 尝试使用原生btoa,如果不存在则使用自定义实现
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
else {
|
||||
return base64Encode(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建WAV文件 - 返回ArrayBuffer而不是Blob
|
||||
function buildWav(pcm: Uint8Array): ArrayBuffer {
|
||||
const wavHeader = new Uint8Array(44)
|
||||
const dataLen = pcm.length
|
||||
const fileLen = 36 + dataLen
|
||||
|
||||
const writeStr = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
wavHeader[offset + i] = str.charCodeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
const write32 = (offset: number, value: number) => {
|
||||
wavHeader[offset] = value & 0xFF
|
||||
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||
wavHeader[offset + 2] = (value >> 16) & 0xFF
|
||||
wavHeader[offset + 3] = (value >> 24) & 0xFF
|
||||
}
|
||||
|
||||
const write16 = (offset: number, value: number) => {
|
||||
wavHeader[offset] = value & 0xFF
|
||||
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||
}
|
||||
|
||||
writeStr(0, 'RIFF')
|
||||
write32(4, fileLen)
|
||||
writeStr(8, 'WAVE')
|
||||
writeStr(12, 'fmt ')
|
||||
write32(16, 16)
|
||||
write16(20, 1)
|
||||
write16(22, 1)
|
||||
write32(24, SAMPLE_RATE)
|
||||
write32(28, SAMPLE_RATE * 2)
|
||||
write16(32, 2)
|
||||
write16(34, 16)
|
||||
writeStr(36, 'data')
|
||||
write32(40, dataLen)
|
||||
|
||||
// 合并header和数据
|
||||
const result = new ArrayBuffer(44 + dataLen)
|
||||
const resultView = new Uint8Array(result)
|
||||
resultView.set(wavHeader)
|
||||
resultView.set(pcm, 44)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 生成并播放声波 - 主要功能函数
|
||||
async function generateAndPlay() {
|
||||
if (!canGenerate.value || !props.selectedNetwork)
|
||||
return
|
||||
|
||||
generating.value = true
|
||||
|
||||
try {
|
||||
console.log('生成超声波配网音频...')
|
||||
|
||||
// 准备配网数据 - 参考HTML文件格式
|
||||
const dataStr = `${props.selectedNetwork.ssid}\n${props.password}`
|
||||
const textBytes = stringToBytes(dataStr)
|
||||
const fullBytes = [...START_BYTES, ...textBytes, checksum(textBytes), ...END_BYTES]
|
||||
|
||||
console.log('配网数据:', { ssid: props.selectedNetwork.ssid, password: props.password })
|
||||
console.log('数据字节长度:', textBytes.length)
|
||||
|
||||
// 转换为比特流
|
||||
let bits: number[] = []
|
||||
fullBytes.forEach((b) => {
|
||||
bits = bits.concat(toBits(b))
|
||||
})
|
||||
|
||||
console.log('比特流长度:', bits.length)
|
||||
|
||||
// AFSK调制 - 减少采样率降低文件大小
|
||||
const reducedSampleRate = 22050 // 降低采样率
|
||||
const samplesPerBit = reducedSampleRate / BIT_RATE
|
||||
const totalSamples = Math.floor(bits.length * samplesPerBit)
|
||||
const floatBuf = new Float32Array(totalSamples)
|
||||
|
||||
for (let i = 0; i < bits.length; i++) {
|
||||
const freq = bits[i] ? MARK : SPACE
|
||||
for (let j = 0; j < samplesPerBit; j++) {
|
||||
const t = (i * samplesPerBit + j) / reducedSampleRate
|
||||
floatBuf[i * samplesPerBit + j] = Math.sin(2 * Math.PI * freq * t) * 0.5 // 降低音量
|
||||
}
|
||||
}
|
||||
|
||||
const pcmBuf = floatTo16BitPCM(floatBuf)
|
||||
|
||||
// 生成WAV文件 - 使用降低的采样率
|
||||
const wavBuffer = buildWavOptimized(pcmBuf, reducedSampleRate)
|
||||
const base64 = arrayBufferToBase64(wavBuffer)
|
||||
const dataUri = `data:audio/wav;base64,${base64}`
|
||||
|
||||
console.log('base64长度:', base64.length, '约', Math.round(base64.length / 1024), 'KB')
|
||||
|
||||
// 检查数据大小
|
||||
if (base64.length > 1024 * 1024) { // 超过1MB
|
||||
throw new Error('音频文件过大,请缩短SSID或密码长度')
|
||||
}
|
||||
|
||||
audioFilePath.value = dataUri
|
||||
audioGenerated.value = true
|
||||
|
||||
console.log('音频生成成功,比特流长度:', bits.length, '采样点数:', floatBuf.length)
|
||||
|
||||
toast.success('声波生成成功')
|
||||
|
||||
// 延迟播放
|
||||
setTimeout(async () => {
|
||||
await playAudio()
|
||||
}, 800) // 增加延迟时间
|
||||
}
|
||||
catch (error) {
|
||||
console.error('音频生成失败:', error)
|
||||
toast.error(`声波生成失败: ${error.message || error}`)
|
||||
}
|
||||
finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 优化的WAV构建函数
|
||||
function buildWavOptimized(pcm: Uint8Array, sampleRate: number): ArrayBuffer {
|
||||
const wavHeader = new Uint8Array(44)
|
||||
const dataLen = pcm.length
|
||||
const fileLen = 36 + dataLen
|
||||
|
||||
const writeStr = (offset: number, str: string) => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
wavHeader[offset + i] = str.charCodeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
const write32 = (offset: number, value: number) => {
|
||||
wavHeader[offset] = value & 0xFF
|
||||
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||
wavHeader[offset + 2] = (value >> 16) & 0xFF
|
||||
wavHeader[offset + 3] = (value >> 24) & 0xFF
|
||||
}
|
||||
|
||||
const write16 = (offset: number, value: number) => {
|
||||
wavHeader[offset] = value & 0xFF
|
||||
wavHeader[offset + 1] = (value >> 8) & 0xFF
|
||||
}
|
||||
|
||||
writeStr(0, 'RIFF')
|
||||
write32(4, fileLen)
|
||||
writeStr(8, 'WAVE')
|
||||
writeStr(12, 'fmt ')
|
||||
write32(16, 16)
|
||||
write16(20, 1)
|
||||
write16(22, 1)
|
||||
write32(24, sampleRate) // 使用传入的采样率
|
||||
write32(28, sampleRate * 2)
|
||||
write16(32, 2)
|
||||
write16(34, 16)
|
||||
writeStr(36, 'data')
|
||||
write32(40, dataLen)
|
||||
|
||||
// 合并header和数据
|
||||
const result = new ArrayBuffer(44 + dataLen)
|
||||
const resultView = new Uint8Array(result)
|
||||
resultView.set(wavHeader)
|
||||
resultView.set(pcm, 44)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// 播放音频
|
||||
async function playAudio() {
|
||||
if (!audioFilePath.value) {
|
||||
toast.error('请先生成音频')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 强制清理所有旧的音频实例
|
||||
await cleanupAudio()
|
||||
|
||||
// 等待一下确保清理完成
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
playing.value = true
|
||||
console.log('开始播放超声波配网音频')
|
||||
|
||||
// 创建新的音频上下文
|
||||
const innerAudioContext = uni.createInnerAudioContext()
|
||||
audioContext.value = innerAudioContext
|
||||
|
||||
// 最简化的音频设置
|
||||
innerAudioContext.src = audioFilePath.value
|
||||
innerAudioContext.loop = autoLoop.value
|
||||
innerAudioContext.volume = 0.8
|
||||
innerAudioContext.autoplay = false
|
||||
|
||||
// 简化的事件监听
|
||||
innerAudioContext.onPlay(() => {
|
||||
console.log('超声波音频开始播放')
|
||||
toast.success('开始播放配网声波')
|
||||
})
|
||||
|
||||
innerAudioContext.onEnded(() => {
|
||||
console.log('超声波音频播放结束')
|
||||
if (!autoLoop.value) {
|
||||
playing.value = false
|
||||
cleanupAudio()
|
||||
}
|
||||
})
|
||||
|
||||
innerAudioContext.onError((error) => {
|
||||
console.error('音频播放失败:', error)
|
||||
playing.value = false
|
||||
|
||||
let errorMsg = '音频播放失败'
|
||||
if (error.errCode === -99) {
|
||||
errorMsg = '音频资源繁忙,请稍后重试'
|
||||
}
|
||||
else if (error.errCode === 10004) {
|
||||
errorMsg = '音频格式不支持,可能是data URI问题'
|
||||
}
|
||||
else if (error.errCode === 10003) {
|
||||
errorMsg = '音频文件错误'
|
||||
}
|
||||
|
||||
toast.error(errorMsg)
|
||||
|
||||
cleanupAudio()
|
||||
})
|
||||
|
||||
innerAudioContext.onStop(() => {
|
||||
console.log('音频播放停止')
|
||||
playing.value = false
|
||||
})
|
||||
|
||||
// 延迟播放
|
||||
setTimeout(() => {
|
||||
if (audioContext.value) {
|
||||
console.log('尝试播放音频,src长度:', audioFilePath.value.length)
|
||||
audioContext.value.play()
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('播放音频异常:', error)
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
toast.error(`播放失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理音频资源
|
||||
async function cleanupAudio() {
|
||||
if (audioContext.value) {
|
||||
try {
|
||||
audioContext.value.pause()
|
||||
audioContext.value.destroy()
|
||||
console.log('清理音频上下文')
|
||||
}
|
||||
catch (e) {
|
||||
console.log('清理音频上下文失败:', e)
|
||||
}
|
||||
finally {
|
||||
audioContext.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 停止播放
|
||||
async function stopAudio() {
|
||||
playing.value = false
|
||||
await cleanupAudio()
|
||||
|
||||
console.log('停止播放超声波音频')
|
||||
toast.success('已停止播放')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="ultrasonic-config">
|
||||
<!-- 选中的网络信息 -->
|
||||
<view v-if="props.selectedNetwork" class="selected-network">
|
||||
<view class="network-info">
|
||||
<view class="network-name">
|
||||
选中网络: {{ props.selectedNetwork.ssid }}
|
||||
</view>
|
||||
<view class="network-details">
|
||||
<text class="network-signal">
|
||||
信号: {{ props.selectedNetwork.rssi }}dBm
|
||||
</text>
|
||||
<text class="network-security">
|
||||
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="props.password" class="network-password">
|
||||
密码: {{ '*'.repeat(props.password.length) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 超声波配网操作 -->
|
||||
<view class="submit-section">
|
||||
<wd-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="generating"
|
||||
:disabled="!canGenerate"
|
||||
@click="generateAndPlay"
|
||||
>
|
||||
{{ generating ? '生成中...' : '🎵 生成并播放声波' }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="audioGenerated"
|
||||
type="success"
|
||||
size="large"
|
||||
block
|
||||
:loading="playing"
|
||||
@click="playAudio"
|
||||
>
|
||||
{{ playing ? '播放中...' : '🔊 播放声波' }}
|
||||
</wd-button>
|
||||
|
||||
<wd-button
|
||||
v-if="playing"
|
||||
type="warning"
|
||||
size="large"
|
||||
block
|
||||
@click="stopAudio"
|
||||
>
|
||||
⏹️ 停止播放
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 音频控制选项 -->
|
||||
<view v-if="audioGenerated" class="audio-options">
|
||||
<view class="option-item">
|
||||
<wd-checkbox v-model="autoLoop">
|
||||
自动循环播放声波
|
||||
</wd-checkbox>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 音频播放器 -->
|
||||
<view v-if="audioGenerated" class="audio-player">
|
||||
<view class="player-info">
|
||||
<text class="audio-title">
|
||||
配网音频文件
|
||||
</text>
|
||||
<text class="audio-duration">
|
||||
时长: {{ audioLengthText }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<view class="help-section">
|
||||
<view class="help-title">
|
||||
超声波配网说明
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. 确保已选择WiFi网络并输入密码
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. 点击生成并播放声波,系统会将配网信息编码为音频
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. 将手机靠近xiaozhi设备(距离1-2米)
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. 音频播放时,xiaozhi会接收并解码配网信息
|
||||
</text>
|
||||
<text class="help-item">
|
||||
5. 配网成功后设备会自动连接WiFi网络
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
使用AFSK调制技术,通过1800Hz和1500Hz频率传输数据
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
请确保手机音量适中,避免环境噪音干扰
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ultrasonic-config {
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.selected-network {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.network-info {
|
||||
padding: 24rpx;
|
||||
background-color: #f0f6ff;
|
||||
border: 1rpx solid #336cff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.network-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.network-details {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.network-signal,
|
||||
.network-security {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
|
||||
.network-password {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.submit-section .wd-button {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.submit-section .wd-button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.audio-options {
|
||||
margin-bottom: 32rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
margin-bottom: 32rpx;
|
||||
padding: 24rpx;
|
||||
background-color: #f0f6ff;
|
||||
border: 1rpx solid #336cff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.player-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.audio-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
}
|
||||
|
||||
.audio-duration {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-tip {
|
||||
font-size: 24rpx;
|
||||
color: #336cff;
|
||||
font-weight: 500;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
|
||||
// 类型定义
|
||||
interface WiFiNetwork {
|
||||
ssid: string
|
||||
rssi: number
|
||||
authmode: number
|
||||
channel: number
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
selectedNetwork: WiFiNetwork | null
|
||||
password: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Toast 实例
|
||||
const toast = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const configuring = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const canSubmit = computed(() => {
|
||||
if (!props.selectedNetwork)
|
||||
return false
|
||||
if (props.selectedNetwork.authmode > 0 && !props.password)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
// ESP32连接检查
|
||||
async function checkESP32Connection() {
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: 'http://192.168.4.1/scan',
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
})
|
||||
return response.statusCode === 200
|
||||
}
|
||||
catch (error) {
|
||||
console.log('ESP32连接检查失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交配网
|
||||
async function submitConfig() {
|
||||
if (!props.selectedNetwork)
|
||||
return
|
||||
|
||||
// 检查ESP32连接
|
||||
const connected = await checkESP32Connection()
|
||||
if (!connected) {
|
||||
toast.error('请先连接xiaozhi热点')
|
||||
return
|
||||
}
|
||||
|
||||
configuring.value = true
|
||||
console.log('开始WiFi配网:', props.selectedNetwork.ssid)
|
||||
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: 'http://192.168.4.1/submit',
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
ssid: props.selectedNetwork.ssid,
|
||||
password: props.selectedNetwork.authmode > 0 ? props.password : '',
|
||||
},
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
console.log('WiFi配网响应:', response)
|
||||
|
||||
if (response.statusCode === 200 && (response.data as any)?.success) {
|
||||
toast.success(`配网成功!设备将连接到 ${props.selectedNetwork.ssid},设备会自动重启。请断开xiaozhi热点连接。`)
|
||||
}
|
||||
else {
|
||||
const errorMsg = (response.data as any)?.error || '配网失败'
|
||||
toast.error(errorMsg)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('WiFi配网失败:', error)
|
||||
toast.error('配网失败,请检查网络连接')
|
||||
}
|
||||
finally {
|
||||
configuring.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="wifi-config">
|
||||
<!-- 选中的网络信息 -->
|
||||
<view v-if="props.selectedNetwork" class="selected-network">
|
||||
<view class="network-info">
|
||||
<view class="network-name">
|
||||
选中网络: {{ props.selectedNetwork.ssid }}
|
||||
</view>
|
||||
<view class="network-details">
|
||||
<text class="network-signal">
|
||||
信号: {{ props.selectedNetwork.rssi }}dBm
|
||||
</text>
|
||||
<text class="network-security">
|
||||
{{ props.selectedNetwork.authmode === 0 ? '开放网络' : '加密网络' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 配网按钮 -->
|
||||
<view class="submit-section">
|
||||
<wd-button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
:loading="configuring"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitConfig"
|
||||
>
|
||||
{{ configuring ? '配网中...' : '开始WiFi配网' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
|
||||
<!-- 使用说明 -->
|
||||
<view class="help-section">
|
||||
<view class="help-title">
|
||||
WiFi配网说明
|
||||
</view>
|
||||
<view class="help-content">
|
||||
<text class="help-item">
|
||||
1. 手机连接xiaozhi热点 (xiaozhi-XXXXXX)
|
||||
</text>
|
||||
<text class="help-item">
|
||||
2. 选择要配网的目标WiFi网络
|
||||
</text>
|
||||
<text class="help-item">
|
||||
3. 输入WiFi密码(如果需要)
|
||||
</text>
|
||||
<text class="help-item">
|
||||
4. 点击开始配网,等待设备连接
|
||||
</text>
|
||||
<text class="help-tip">
|
||||
配网成功后设备会自动重启并连接目标WiFi
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wifi-config {
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.selected-network {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.network-info {
|
||||
padding: 24rpx;
|
||||
background-color: #f0f6ff;
|
||||
border: 1rpx solid #336cff;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.network-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.network-details {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.network-signal,
|
||||
.network-security {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
|
||||
.submit-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.help-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.help-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-tip {
|
||||
font-size: 24rpx;
|
||||
color: #336cff;
|
||||
font-weight: 500;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,556 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineEmits, defineExpose, onMounted, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
|
||||
// 类型定义
|
||||
interface WiFiNetwork {
|
||||
ssid: string
|
||||
rssi: number
|
||||
authmode: number
|
||||
channel: number
|
||||
}
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
autoConnect?: boolean // 是否自动检测ESP32连接
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
autoConnect: true,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
'network-selected': [network: WiFiNetwork | null, password: string]
|
||||
'connection-status': [connected: boolean]
|
||||
}>()
|
||||
|
||||
// Toast 实例
|
||||
const toast = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const isConnectedToESP32 = ref(false)
|
||||
const checkingConnection = ref(false)
|
||||
const scanning = ref(false)
|
||||
const wifiNetworks = ref<WiFiNetwork[]>([])
|
||||
const selectedNetwork = ref<WiFiNetwork | null>(null)
|
||||
const password = ref('')
|
||||
const selectorExpanded = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const networkDisplayText = computed(() => {
|
||||
if (!selectedNetwork.value)
|
||||
return '请选择WiFi网络'
|
||||
return selectedNetwork.value.ssid
|
||||
})
|
||||
|
||||
// 检查xiaozhi连接状态
|
||||
async function checkESP32Connection() {
|
||||
checkingConnection.value = true
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: 'http://192.168.4.1/scan',
|
||||
method: 'GET',
|
||||
timeout: 3000,
|
||||
})
|
||||
isConnectedToESP32.value = response.statusCode === 200
|
||||
emit('connection-status', isConnectedToESP32.value)
|
||||
console.log('xiaozhi连接状态:', isConnectedToESP32.value)
|
||||
}
|
||||
catch (error) {
|
||||
isConnectedToESP32.value = false
|
||||
emit('connection-status', false)
|
||||
console.log('xiaozhi连接检查失败:', error)
|
||||
}
|
||||
finally {
|
||||
checkingConnection.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描WiFi网络
|
||||
async function scanWifi() {
|
||||
if (!isConnectedToESP32.value) {
|
||||
toast.error('请先连接xiaozhi热点')
|
||||
return
|
||||
}
|
||||
|
||||
scanning.value = true
|
||||
console.log('开始扫描WiFi网络')
|
||||
|
||||
try {
|
||||
const response = await uni.request({
|
||||
url: 'http://192.168.4.1/scan',
|
||||
method: 'GET',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
console.log('WiFi扫描响应:', response)
|
||||
|
||||
if (response.statusCode === 200 && response.data) {
|
||||
const data = response.data as any
|
||||
if (data.success && Array.isArray(data.networks)) {
|
||||
wifiNetworks.value = data.networks
|
||||
console.log(`扫描成功,发现 ${data.networks.length} 个网络`)
|
||||
}
|
||||
else if (Array.isArray(response.data)) {
|
||||
// 兼容旧格式
|
||||
wifiNetworks.value = response.data.map((item: any) => ({
|
||||
ssid: item.ssid,
|
||||
rssi: item.rssi,
|
||||
authmode: item.authmode,
|
||||
channel: item.channel || 0,
|
||||
}))
|
||||
}
|
||||
else {
|
||||
throw new TypeError('扫描接口返回格式异常')
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`HTTP ${response.statusCode}`)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('WiFi扫描失败:', error)
|
||||
toast.error('扫描失败,请检查xiaozhi连接')
|
||||
}
|
||||
finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示网络选择器
|
||||
async function showNetworkSelector() {
|
||||
// 实时检测xiaozhi连接状态
|
||||
await checkESP32Connection()
|
||||
|
||||
if (!isConnectedToESP32.value) {
|
||||
toast.error('请先连接xiaozhi热点')
|
||||
return
|
||||
}
|
||||
|
||||
selectorExpanded.value = true
|
||||
|
||||
// 如果还没有网络列表,自动扫描
|
||||
if (wifiNetworks.value.length === 0) {
|
||||
scanWifi()
|
||||
}
|
||||
}
|
||||
|
||||
// 选择网络
|
||||
function selectNetwork(network: WiFiNetwork) {
|
||||
selectedNetwork.value = network
|
||||
password.value = ''
|
||||
selectorExpanded.value = false
|
||||
console.log('选择网络:', network.ssid)
|
||||
|
||||
// 通知父组件
|
||||
emit('network-selected', network, '')
|
||||
}
|
||||
|
||||
// 密码变化时通知父组件
|
||||
function onPasswordChange() {
|
||||
emit('network-selected', selectedNetwork.value, password.value)
|
||||
}
|
||||
|
||||
// 获取当前选择的网络和密码
|
||||
function getSelectedNetworkInfo() {
|
||||
return {
|
||||
network: selectedNetwork.value,
|
||||
password: password.value,
|
||||
}
|
||||
}
|
||||
|
||||
// 重置选择
|
||||
function reset() {
|
||||
selectedNetwork.value = null
|
||||
password.value = ''
|
||||
wifiNetworks.value = []
|
||||
selectorExpanded.value = false
|
||||
emit('network-selected', null, '')
|
||||
}
|
||||
|
||||
// 获取信号强度描述
|
||||
function getSignalStrength(rssi: number): string {
|
||||
if (rssi >= -50)
|
||||
return '信号强'
|
||||
if (rssi >= -60)
|
||||
return '信号良好'
|
||||
if (rssi >= -70)
|
||||
return '信号一般'
|
||||
return '信号弱'
|
||||
}
|
||||
|
||||
// 获取信号强度颜色
|
||||
function getSignalColor(rssi: number): string {
|
||||
if (rssi >= -50)
|
||||
return '#52c41a'
|
||||
if (rssi >= -60)
|
||||
return '#73d13d'
|
||||
if (rssi >= -70)
|
||||
return '#faad14'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
checkESP32Connection,
|
||||
scanWifi,
|
||||
getSelectedNetworkInfo,
|
||||
reset,
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
if (props.autoConnect) {
|
||||
checkESP32Connection()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="wifi-selector">
|
||||
<!-- Xiaozhi连接状态 -->
|
||||
<view v-if="props.autoConnect" class="connection-status">
|
||||
<view v-if="!isConnectedToESP32" class="status-warning">
|
||||
<view class="status-content">
|
||||
<text class="warning-text">
|
||||
请先连接xiaozhi热点 (xiaozhi-XXXXXX)
|
||||
</text>
|
||||
<wd-button
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="checkingConnection"
|
||||
@click="checkESP32Connection"
|
||||
>
|
||||
{{ checkingConnection ? '检测中...' : '重新检测' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="status-success">
|
||||
<view class="status-content">
|
||||
<text class="success-text">
|
||||
已连接xiaozhi热点
|
||||
</text>
|
||||
<wd-button
|
||||
size="small"
|
||||
:loading="checkingConnection"
|
||||
@click="checkESP32Connection"
|
||||
>
|
||||
{{ checkingConnection ? '检测中...' : '刷新状态' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- WiFi网络选择器 -->
|
||||
<view class="network-selector">
|
||||
<view class="selector-item" @click="showNetworkSelector">
|
||||
<text class="selector-label">
|
||||
WiFi网络
|
||||
</text>
|
||||
<text class="selector-value">
|
||||
{{ networkDisplayText }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 展开的网络列表 -->
|
||||
<view v-if="selectorExpanded" class="network-list-overlay">
|
||||
<view class="network-list-container">
|
||||
<view class="list-header">
|
||||
<text class="list-title">
|
||||
选择WiFi网络
|
||||
</text>
|
||||
<view class="list-actions">
|
||||
<wd-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="scanning"
|
||||
@click="scanWifi"
|
||||
>
|
||||
{{ scanning ? '扫描中...' : '刷新扫描' }}
|
||||
</wd-button>
|
||||
<wd-button
|
||||
size="small"
|
||||
@click="selectorExpanded = false"
|
||||
>
|
||||
取消
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="network-list">
|
||||
<view v-if="wifiNetworks.length === 0 && !scanning" class="empty-state">
|
||||
<text class="empty-text">
|
||||
暂无WiFi网络
|
||||
</text>
|
||||
<text class="empty-tip">
|
||||
请点击刷新扫描
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="wifi-list">
|
||||
<view
|
||||
v-for="network in wifiNetworks"
|
||||
:key="network.ssid"
|
||||
class="wifi-item"
|
||||
@click="selectNetwork(network)"
|
||||
>
|
||||
<view class="wifi-info">
|
||||
<view class="wifi-name">
|
||||
{{ network.ssid }}
|
||||
</view>
|
||||
<view class="wifi-details">
|
||||
<text class="wifi-signal">
|
||||
信号: {{ network.rssi }}dBm
|
||||
</text>
|
||||
<text class="wifi-channel">
|
||||
频道: {{ network.channel }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="wifi-security">
|
||||
<text class="security-icon">
|
||||
{{ network.authmode === 0 ? '开放' : '加密' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 密码输入 -->
|
||||
<view v-if="selectedNetwork && selectedNetwork.authmode > 0" class="password-section">
|
||||
<view class="password-item">
|
||||
<text class="password-label">
|
||||
网络密码
|
||||
</text>
|
||||
<wd-input
|
||||
v-model="password"
|
||||
placeholder="请输入WiFi密码"
|
||||
show-password
|
||||
clearable
|
||||
@input="onPasswordChange"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wifi-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
padding: 24rpx;
|
||||
background-color: #fff3cd;
|
||||
border: 1rpx solid #ffeaa7;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
padding: 24rpx;
|
||||
background-color: #d4edda;
|
||||
border: 1rpx solid #c3e6cb;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #856404;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #155724;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.network-selector {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.selector-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx;
|
||||
background: #f5f7fb;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.selector-item:active {
|
||||
background: #eef3ff;
|
||||
border-color: #336cff;
|
||||
}
|
||||
|
||||
.selector-label {
|
||||
font-size: 28rpx;
|
||||
color: #232338;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selector-value {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: #65686f;
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
|
||||
:deep(.arrow-icon) {
|
||||
font-size: 20rpx;
|
||||
color: #9d9ea3;
|
||||
}
|
||||
|
||||
.network-list-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.network-list-container {
|
||||
width: 100%;
|
||||
max-height: 70vh;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
padding: 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
padding-bottom: 16rpx;
|
||||
border-bottom: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.network-list {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 20rpx;
|
||||
background-color: #fbfbfb;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
color: #65686f;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
font-size: 24rpx;
|
||||
color: #9d9ea3;
|
||||
}
|
||||
|
||||
.wifi-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.wifi-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 32rpx 24rpx;
|
||||
background-color: #fbfbfb;
|
||||
border: 2rpx solid #eeeeee;
|
||||
border-radius: 16rpx;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wifi-item:active {
|
||||
transform: scale(0.98);
|
||||
background-color: #f0f6ff;
|
||||
border-color: #336cff;
|
||||
}
|
||||
|
||||
.wifi-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wifi-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #232338;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.wifi-details {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.wifi-signal,
|
||||
.wifi-channel {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
}
|
||||
|
||||
.security-icon {
|
||||
font-size: 24rpx;
|
||||
color: #65686f;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.password-section {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.password-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.password-label {
|
||||
font-size: 28rpx;
|
||||
color: #232338;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import UltrasonicConfig from './components/ultrasonic-config.vue'
|
||||
import WifiConfig from './components/wifi-config.vue'
|
||||
import WifiSelector from './components/wifi-selector.vue'
|
||||
|
||||
// 类型定义
|
||||
interface WiFiNetwork {
|
||||
ssid: string
|
||||
rssi: number
|
||||
authmode: number
|
||||
channel: number
|
||||
}
|
||||
|
||||
// 配网类型
|
||||
const configType = ref<'wifi' | 'ultrasonic'>('wifi')
|
||||
|
||||
// 配网模式选择器状态
|
||||
const configTypeSelectorShow = ref(false)
|
||||
|
||||
// WiFi选择器引用
|
||||
const wifiSelectorRef = ref<InstanceType<typeof WifiSelector>>()
|
||||
|
||||
// 选择的WiFi网络信息
|
||||
const selectedWifiInfo = ref<{
|
||||
network: WiFiNetwork | null
|
||||
password: string
|
||||
}>({
|
||||
network: null,
|
||||
password: '',
|
||||
})
|
||||
|
||||
// 配网模式选项
|
||||
const configTypeOptions = [
|
||||
{
|
||||
name: 'WiFi配网',
|
||||
value: 'wifi' as const,
|
||||
},
|
||||
// {
|
||||
// name: '超声波配网',
|
||||
// value: 'ultrasonic' as const,
|
||||
// },
|
||||
]
|
||||
|
||||
// 显示配网模式选择器
|
||||
function showConfigTypeSelector() {
|
||||
configTypeSelectorShow.value = true
|
||||
}
|
||||
|
||||
// 配网模式选择器确认
|
||||
function onConfigTypeConfirm(item: { name: string, value: 'wifi' | 'ultrasonic' }) {
|
||||
configType.value = item.value
|
||||
configTypeSelectorShow.value = false
|
||||
}
|
||||
|
||||
// 配网模式选择器取消
|
||||
function onConfigTypeCancel() {
|
||||
configTypeSelectorShow.value = false
|
||||
}
|
||||
|
||||
// WiFi网络选择事件
|
||||
function onNetworkSelected(network: WiFiNetwork | null, password: string) {
|
||||
selectedWifiInfo.value = { network, password }
|
||||
}
|
||||
|
||||
// ESP32连接状态变化事件
|
||||
function onConnectionStatusChange(connected: boolean) {
|
||||
console.log('ESP32连接状态:', connected)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen bg-[#f5f7fb]">
|
||||
<wd-navbar title="设备配网" safe-area-inset-top />
|
||||
|
||||
<view class="box-border px-[20rpx]">
|
||||
<!-- 配网方式选择 -->
|
||||
<view class="pb-[20rpx] first:pt-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
配网方式
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<view class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:border-[#336cff] active:bg-[#eef3ff]" @click="showConfigTypeSelector">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
配网方式
|
||||
</text>
|
||||
<text class="mx-[16rpx] flex-1 text-right text-[26rpx] text-[#65686f]">
|
||||
{{ configType === 'wifi' ? 'WiFi配网' : '超声波配网' }}
|
||||
</text>
|
||||
<wd-icon name="arrow-right" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- WiFi网络选择 -->
|
||||
<view class="pb-[20rpx]">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
网络配置
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx] border border-[#eeeeee] rounded-[20rpx] bg-[#fbfbfb] p-[24rpx]" style="box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);">
|
||||
<wifi-selector
|
||||
ref="wifiSelectorRef"
|
||||
@network-selected="onNetworkSelected"
|
||||
@connection-status="onConnectionStatusChange"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 配网操作 -->
|
||||
<view v-if="selectedWifiInfo.network" class="flex-1">
|
||||
<!-- WiFi配网组件 -->
|
||||
<wifi-config
|
||||
v-if="configType === 'wifi'"
|
||||
:selected-network="selectedWifiInfo.network"
|
||||
:password="selectedWifiInfo.password"
|
||||
/>
|
||||
|
||||
<!-- 超声波配网组件 -->
|
||||
<ultrasonic-config
|
||||
v-else-if="configType === 'ultrasonic'"
|
||||
:selected-network="selectedWifiInfo.network"
|
||||
:password="selectedWifiInfo.password"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 配网模式选择器 -->
|
||||
<wd-action-sheet
|
||||
v-model="configTypeSelectorShow"
|
||||
:actions="configTypeOptions.map(item => ({ name: item.name, value: item.value }))"
|
||||
@close="onConfigTypeCancel"
|
||||
@select="({ item }) => onConfigTypeConfirm(item)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"style": {
|
||||
"navigationBarTitleText": "设备配网",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Device, FirmwareType } from '@/api/device'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { bindDevice, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
defineOptions({
|
||||
name: 'DeviceManage',
|
||||
})
|
||||
|
||||
// 接收props
|
||||
interface Props {
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: 'default'
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 设备数据
|
||||
const deviceList = ref<Device[]>([])
|
||||
const firmwareTypes = ref<FirmwareType[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 消息组件
|
||||
const message = useMessage()
|
||||
|
||||
// 使用传入的智能体ID
|
||||
const currentAgentId = computed(() => {
|
||||
return props.agentId
|
||||
})
|
||||
|
||||
// 获取设备列表
|
||||
async function loadDeviceList() {
|
||||
try {
|
||||
console.log('获取设备列表')
|
||||
|
||||
// 检查是否有当前选中的智能体
|
||||
if (!currentAgentId.value) {
|
||||
console.warn('没有选中的智能体')
|
||||
deviceList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const response = await getBindDevices(currentAgentId.value)
|
||||
deviceList.value = response || []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
deviceList.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的刷新方法
|
||||
async function refresh() {
|
||||
await loadDeviceList()
|
||||
}
|
||||
|
||||
// 获取设备类型名称
|
||||
function getDeviceTypeName(boardKey: string): string {
|
||||
const firmwareType = firmwareTypes.value.find(type => type.key === boardKey)
|
||||
return firmwareType?.name || boardKey
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
if (!timeStr)
|
||||
return '从未连接'
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000)
|
||||
return '刚刚'
|
||||
if (diff < 3600000)
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000)
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
if (diff < 604800000)
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
|
||||
// 切换OTA自动更新
|
||||
async function toggleAutoUpdate(device: Device) {
|
||||
try {
|
||||
const newStatus = device.autoUpdate === 1 ? 0 : 1
|
||||
await updateDeviceAutoUpdate(device.id, newStatus)
|
||||
device.autoUpdate = newStatus
|
||||
toast.success(newStatus === 1 ? 'OTA自动升级已开启' : 'OTA自动升级已关闭')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('更新设备OTA状态失败:', error)
|
||||
toast.error('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 解绑设备
|
||||
async function handleUnbindDevice(device: Device) {
|
||||
try {
|
||||
await unbindDevice(device.id)
|
||||
await loadDeviceList()
|
||||
toast.success('设备已解绑')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('解绑设备失败:', error)
|
||||
toast.error('解绑失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 确认解绑设备
|
||||
function confirmUnbindDevice(device: Device) {
|
||||
message.confirm({
|
||||
title: '解绑设备',
|
||||
msg: `确定要解绑设备 "${device.macAddress}" 吗?`,
|
||||
confirmButtonText: '确定解绑',
|
||||
cancelButtonText: '取消',
|
||||
}).then(() => {
|
||||
handleUnbindDevice(device)
|
||||
}).catch(() => {
|
||||
// 用户取消
|
||||
})
|
||||
}
|
||||
|
||||
// 绑定新设备
|
||||
async function handleBindDevice(code: string) {
|
||||
try {
|
||||
if (!currentAgentId.value) {
|
||||
toast.error('请先选择智能体')
|
||||
return
|
||||
}
|
||||
|
||||
await bindDevice(currentAgentId.value, code.trim())
|
||||
await loadDeviceList()
|
||||
toast.success('设备绑定成功!')
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('绑定设备失败:', error)
|
||||
const errorMessage = error?.message || '绑定失败,请检查验证码是否正确'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开绑定设备对话框
|
||||
function openBindDialog() {
|
||||
message
|
||||
.prompt({
|
||||
title: '绑定设备',
|
||||
inputPlaceholder: '请输入设备验证码',
|
||||
inputValue: '',
|
||||
inputPattern: /^\d{6}$/,
|
||||
confirmButtonText: '立即绑定',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
.then(async (result: any) => {
|
||||
if (result.value && String(result.value).trim()) {
|
||||
await handleBindDevice(String(result.value).trim())
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 用户取消操作
|
||||
})
|
||||
}
|
||||
|
||||
// 获取设备类型列表
|
||||
async function loadFirmwareTypes() {
|
||||
try {
|
||||
const response = await getFirmwareTypes()
|
||||
firmwareTypes.value = response
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取设备类型失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 智能体已简化为默认
|
||||
|
||||
loadFirmwareTypes()
|
||||
loadDeviceList()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-container" style="background: #f5f7fb; min-height: 100%;">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading && deviceList.length === 0" class="loading-container">
|
||||
<wd-loading color="#336cff" />
|
||||
<text class="loading-text">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<view v-else-if="deviceList.length > 0" class="device-list">
|
||||
<!-- 设备卡片列表 -->
|
||||
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
|
||||
<view v-for="device in deviceList" :key="device.id">
|
||||
<wd-swipe-action>
|
||||
<view class="cursor-pointer bg-[#fbfbfb] p-[32rpx] transition-all duration-200 active:bg-[#f8f9fa]">
|
||||
<view class="flex items-start justify-between">
|
||||
<view class="flex-1">
|
||||
<view class="mb-[16rpx] flex items-center justify-between">
|
||||
<text class="max-w-[60%] break-all text-[32rpx] text-[#232338] font-semibold">
|
||||
{{ getDeviceTypeName(device.board) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[20rpx]">
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
MAC地址:{{ device.macAddress }}
|
||||
</text>
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
固件版本:{{ device.appVersion }}
|
||||
</text>
|
||||
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
最近对话:{{ formatTime(device.lastConnectedAt) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="flex items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx]">
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
OTA升级
|
||||
</text>
|
||||
<wd-switch
|
||||
:model-value="device.autoUpdate === 1"
|
||||
size="24"
|
||||
@change="toggleAutoUpdate(device)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template #right>
|
||||
<view class="h-full flex">
|
||||
<view
|
||||
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
|
||||
@click.stop="confirmUnbindDevice(device)"
|
||||
>
|
||||
<wd-icon name="delete" />
|
||||
<text>解绑</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</wd-swipe-action>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="!loading" class="empty-container">
|
||||
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
|
||||
<wd-icon name="phone" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
|
||||
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
|
||||
暂无设备
|
||||
</text>
|
||||
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
|
||||
点击右下角 + 号绑定您的第一个设备
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- FAB 绑定设备按钮 -->
|
||||
<wd-fab type="primary" size="small" icon="add" :draggable="true" :expandable="false" @click="openBindDialog" />
|
||||
|
||||
<!-- MessageBox 组件 -->
|
||||
<wd-message-box />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
:deep(.wd-swipe-action) {
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
:deep(.wd-icon) {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,578 @@
|
||||
<!-- 使用 type="home" 属性设置首页,其他页面不需要设置,默认为page -->
|
||||
<route lang="jsonc" type="home">
|
||||
{
|
||||
"layout": "tabbar",
|
||||
"style": {
|
||||
// 'custom' 表示开启自定义导航栏,默认 'default'
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Agent } from '@/api/agent/types'
|
||||
import { ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import useZPaging from 'z-paging/components/z-paging/js/hooks/useZPaging.js'
|
||||
import { createAgent, deleteAgent, getAgentList } from '@/api/agent/agent'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
defineOptions({
|
||||
name: 'Home',
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 智能体数据
|
||||
const agentList = ref<Agent[]>([])
|
||||
const pagingRef = ref()
|
||||
useZPaging(pagingRef)
|
||||
// 消息组件
|
||||
const message = useMessage()
|
||||
|
||||
// z-paging查询列表数据
|
||||
async function queryList(pageNo: number, pageSize: number) {
|
||||
try {
|
||||
console.log('z-paging获取智能体列表')
|
||||
|
||||
const response = await getAgentList()
|
||||
|
||||
// 更新本地列表
|
||||
agentList.value = response
|
||||
|
||||
// 直接返回全部数据,不需要分页处理
|
||||
pagingRef.value.complete(response)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取智能体列表失败:', error)
|
||||
// 告知z-paging数据加载失败
|
||||
pagingRef.value.complete(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建智能体
|
||||
async function handleCreateAgent(agentName: string) {
|
||||
try {
|
||||
await createAgent({ agentName: agentName.trim() })
|
||||
// 创建成功后刷新列表
|
||||
pagingRef.value.reload()
|
||||
toast.success(`智能体"${agentName}"创建成功!`)
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('创建智能体失败:', error)
|
||||
const errorMessage = error?.message || '创建失败,请重试'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除智能体
|
||||
async function handleDeleteAgent(agent: Agent) {
|
||||
try {
|
||||
await deleteAgent(agent.id)
|
||||
// 删除成功后刷新列表
|
||||
pagingRef.value.reload()
|
||||
toast.success(`智能体"${agent.agentName}"已删除`)
|
||||
}
|
||||
catch (error: any) {
|
||||
console.error('删除智能体失败:', error)
|
||||
const errorMessage = error?.message || '删除失败,请重试'
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// 进入编辑页面
|
||||
function goToEditAgent(agent: Agent) {
|
||||
// 传递智能体ID到编辑页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/agent/index?agentId=${agent.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 点击卡片进入编辑
|
||||
function handleCardClick(agent: Agent) {
|
||||
goToEditAgent(agent)
|
||||
}
|
||||
|
||||
// 打开创建对话框
|
||||
function openCreateDialog() {
|
||||
message
|
||||
.prompt({
|
||||
title: '创建智能体',
|
||||
msg: '',
|
||||
inputPlaceholder: '例如:客服助手、语音助理、知识问答',
|
||||
inputValue: '',
|
||||
inputPattern: /^[\u4E00-\u9FA5a-z0-9\s]{1,50}$/i,
|
||||
confirmButtonText: '立即创建',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
.then(async (result: any) => {
|
||||
if (result.value && String(result.value).trim()) {
|
||||
await handleCreateAgent(String(result.value).trim())
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 用户取消操作
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeStr: string) {
|
||||
const date = new Date(timeStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000)
|
||||
return '刚刚'
|
||||
if (diff < 3600000)
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000)
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
}
|
||||
|
||||
// 页面显示时刷新列表
|
||||
onShow(() => {
|
||||
console.log('首页 onShow,刷新智能体列表')
|
||||
if (pagingRef.value) {
|
||||
pagingRef.value.reload()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<z-paging
|
||||
ref="pagingRef" v-model="agentList" :refresher-enabled="true" :auto-show-back-to-top="true"
|
||||
:loading-more-enabled="false" :show-loading-more="false" :hide-empty-view="false" empty-view-text="暂无智能体"
|
||||
empty-view-img="" :refresher-threshold="80" :back-to-top-style="{
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: '50%',
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
}" @query="queryList"
|
||||
>
|
||||
<!-- 固定在顶部的横幅区域 -->
|
||||
<template #top>
|
||||
<view class="banner-section" :style="{ paddingTop: `${safeAreaInsets?.top + 100}rpx` }">
|
||||
<view class="banner-content">
|
||||
<view class="welcome-info">
|
||||
<text class="greeting">
|
||||
你好,小智
|
||||
</text>
|
||||
<text class="subtitle">
|
||||
让我们度过 <text class="highlight">
|
||||
美好的一天!
|
||||
</text>
|
||||
</text>
|
||||
<text class="english-subtitle">
|
||||
Hello, Let's have a wonderful day!
|
||||
</text>
|
||||
</view>
|
||||
<view class="wave-decoration">
|
||||
<!-- 添加波浪装饰 -->
|
||||
<view class="wave" />
|
||||
<view class="wave wave-2" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容区域开始标识 -->
|
||||
<view class="content-section-header" />
|
||||
</template>
|
||||
|
||||
<!-- 智能体卡片列表 -->
|
||||
<view class="agent-list">
|
||||
<view v-for="agent in agentList" :key="agent.id" class="agent-item">
|
||||
<wd-swipe-action>
|
||||
<view class="simple-card" @click="handleCardClick(agent)">
|
||||
<view class="card-content">
|
||||
<view class="card-main">
|
||||
<view class="agent-title">
|
||||
<text class="agent-name">
|
||||
{{ agent.agentName }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="model-info">
|
||||
<text class="model-text">
|
||||
语言模型: {{ agent.llmModelName }}
|
||||
</text>
|
||||
<text class="model-text">
|
||||
音色模型: {{ agent.ttsModelName }} ({{ agent.ttsVoiceName }})
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="stats-row">
|
||||
<view class="stat-chip">
|
||||
<wd-icon name="phone" custom-class="chip-icon" />
|
||||
<text class="chip-text">
|
||||
设备管理({{ agent.deviceCount }})
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="agent.lastConnectedAt" class="stat-chip">
|
||||
<wd-icon name="time" custom-class="chip-icon" />
|
||||
<text class="chip-text">
|
||||
最近对话:{{ formatTime(agent.lastConnectedAt) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<wd-icon name="arrow-right" custom-class="arrow-icon" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template #right>
|
||||
<view class="swipe-actions">
|
||||
<view class="action-btn delete-btn" @click.stop="handleDeleteAgent(agent)">
|
||||
<wd-icon name="delete" />
|
||||
<text>删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</wd-swipe-action>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义空状态 -->
|
||||
<template #empty>
|
||||
<view class="empty-state">
|
||||
<wd-icon name="robot" custom-class="empty-icon" />
|
||||
<text class="empty-text">
|
||||
暂无智能体
|
||||
</text>
|
||||
<text class="empty-desc">
|
||||
点击右下角 + 号创建您的第一个智能体
|
||||
</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- FAB 新增按钮 -->
|
||||
<wd-fab type="primary" icon="add" :draggable="true" :expandable="false" @click="openCreateDialog" />
|
||||
|
||||
<!-- MessageBox 组件 -->
|
||||
<wd-message-box />
|
||||
</z-paging>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.banner-section {
|
||||
background: linear-gradient(145deg, #9ebbfc, #6baaff, #9ebbfc, #f5f8fd);
|
||||
position: relative;
|
||||
padding: 40rpx 40rpx 80rpx 40rpx;
|
||||
overflow: hidden;
|
||||
|
||||
.banner-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
position: absolute;
|
||||
top: -50rpx;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 32rpx;
|
||||
|
||||
.filter-icon,
|
||||
.setting-icon {
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-info {
|
||||
.greeting {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16rpx;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 12rpx;
|
||||
font-weight: 500;
|
||||
|
||||
.highlight {
|
||||
color: #ffd700;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.english-subtitle {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.wave-decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -100rpx;
|
||||
width: 400rpx;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
|
||||
.wave {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
|
||||
border-radius: 50%;
|
||||
animation: float 6s ease-in-out infinite;
|
||||
|
||||
&.wave-2 {
|
||||
top: 20%;
|
||||
right: 20%;
|
||||
animation-delay: -3s;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-30rpx) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域开始标识,创建白色背景过渡
|
||||
.content-section-header {
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
margin-top: -32rpx;
|
||||
height: 32rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// z-paging内容区域样式
|
||||
:deep(.z-paging-content) {
|
||||
background: #ffffff;
|
||||
padding: 0 0 40rpx 0;
|
||||
}
|
||||
|
||||
.agent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.agent-item {
|
||||
:deep(.wd-swipe-action) {
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
border: 1rpx solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.simple-card {
|
||||
background: #ffffff;
|
||||
padding: 24rpx;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.agent-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
.agent-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.model-info {
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.model-text {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 4rpx;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.stat-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6rpx 12rpx;
|
||||
background: #f8f9fa;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #eaeaea;
|
||||
|
||||
:deep(.chip-icon) {
|
||||
font-size: 20rpx;
|
||||
color: #666666;
|
||||
margin-right: 6rpx;
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
font-size: 22rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.arrow-icon) {
|
||||
font-size: 24rpx;
|
||||
color: #c7c7cc;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.swipe-actions {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
|
||||
.action-btn {
|
||||
width: 120rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.edit-btn {
|
||||
background: #1890ff;
|
||||
|
||||
&:active {
|
||||
background: #096dd9;
|
||||
}
|
||||
}
|
||||
|
||||
&.delete-btn {
|
||||
background: #ff4d4f;
|
||||
|
||||
&:active {
|
||||
background: #d9363e;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.wd-icon) {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
text-align: center;
|
||||
|
||||
:deep(.empty-icon) {
|
||||
font-size: 120rpx;
|
||||
color: #d9d9d9;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 32rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 16rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
padding: 32rpx;
|
||||
text-align: center;
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,778 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登陆"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { LoginData } from '@/api/auth'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { login } from '@/api/auth'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets
|
||||
let systemInfo
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
// 表单数据
|
||||
const formData = ref<LoginData>({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
areaCode: '+86',
|
||||
mobile: '',
|
||||
})
|
||||
|
||||
// 验证码图片
|
||||
const captchaImage = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 登录方式:'username' | 'mobile'
|
||||
const loginType = ref<'username' | 'mobile'>('username')
|
||||
|
||||
// 获取配置store
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 区号选择相关
|
||||
const showAreaCodeSheet = ref(false)
|
||||
const selectedAreaCode = ref('+86')
|
||||
const selectedAreaName = ref('中国大陆')
|
||||
|
||||
// 计算属性:是否启用手机号登录
|
||||
const enableMobileLogin = computed(() => {
|
||||
return configStore.config.enableMobileRegister
|
||||
})
|
||||
|
||||
// 计算属性:区号列表
|
||||
const areaCodeList = computed(() => {
|
||||
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
|
||||
})
|
||||
|
||||
// 切换登录方式
|
||||
function toggleLoginType() {
|
||||
loginType.value = loginType.value === 'username' ? 'mobile' : 'username'
|
||||
// 清空输入框
|
||||
formData.value.username = ''
|
||||
formData.value.mobile = ''
|
||||
}
|
||||
|
||||
// 打开区号选择弹窗
|
||||
function openAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = true
|
||||
}
|
||||
|
||||
// 选择区号
|
||||
function selectAreaCode(item: { name: string, key: string }) {
|
||||
selectedAreaCode.value = item.key
|
||||
selectedAreaName.value = item.name
|
||||
formData.value.areaCode = item.key
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 关闭区号选择弹窗
|
||||
function closeAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 跳转到注册页面
|
||||
function goToRegister() {
|
||||
uni.navigateTo({
|
||||
url: '/pages/register/index',
|
||||
})
|
||||
}
|
||||
|
||||
// 生成UUID
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 登录
|
||||
async function handleLogin() {
|
||||
// 表单验证
|
||||
if (loginType.value === 'username') {
|
||||
if (!formData.value.username) {
|
||||
toast.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!formData.value.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!formData.value.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建登录数据
|
||||
const loginData = { ...formData.value }
|
||||
|
||||
// 如果是手机号登录,将区号+手机号拼接到username字段
|
||||
if (loginType.value === 'mobile') {
|
||||
loginData.username = `${selectedAreaCode.value}${formData.value.mobile}`
|
||||
}
|
||||
|
||||
const response = await login(loginData)
|
||||
// 存储token
|
||||
uni.setStorageSync('token', response.token)
|
||||
uni.setStorageSync('expire', response.expire)
|
||||
|
||||
toast.success('登录成功')
|
||||
|
||||
// 跳转到主页
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index',
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 登录失败重新获取验证码
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时获取验证码
|
||||
onLoad(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
// 组件挂载时确保配置已加载
|
||||
onMounted(async () => {
|
||||
if (!configStore.config.name) {
|
||||
try {
|
||||
await configStore.fetchPublicConfig()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
|
||||
<view class="header">
|
||||
<view class="logo-section">
|
||||
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
|
||||
<text class="welcome-text">
|
||||
欢迎回来
|
||||
</text>
|
||||
<text class="subtitle">
|
||||
请登录您的账户
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-container">
|
||||
<view class="form">
|
||||
<!-- 手机号登录 -->
|
||||
<template v-if="loginType === 'mobile'">
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper mobile-wrapper">
|
||||
<view class="area-code-selector" @click="openAreaCodeSheet">
|
||||
<text class="area-code-text">
|
||||
{{ selectedAreaCode }}
|
||||
</text>
|
||||
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
|
||||
</view>
|
||||
<view class="mobile-input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.mobile"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入手机号码"
|
||||
type="number"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 用户名登录 -->
|
||||
<template v-else>
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.username"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入密码"
|
||||
clearable
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper captcha-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入验证码"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="forgot-password">
|
||||
<text class="forgot-text">
|
||||
忘记密码?
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="login-btn"
|
||||
@click="handleLogin"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</view>
|
||||
|
||||
<view class="register-hint">
|
||||
<text class="hint-text">
|
||||
还没有账户?
|
||||
</text>
|
||||
<text class="register-link" @click="goToRegister">
|
||||
立即注册
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<view v-if="enableMobileLogin" class="login-type-switch">
|
||||
<view class="switch-tabs">
|
||||
<view
|
||||
class="switch-tab"
|
||||
:class="{ active: loginType === 'username' }"
|
||||
@click="toggleLoginType"
|
||||
>
|
||||
<wd-icon name="user" />
|
||||
</view>
|
||||
<view
|
||||
class="switch-tab"
|
||||
:class="{ active: loginType === 'mobile' }"
|
||||
@click="toggleLoginType"
|
||||
>
|
||||
<wd-icon name="phone" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 区号选择弹窗 -->
|
||||
<wd-action-sheet
|
||||
v-model="showAreaCodeSheet"
|
||||
title="选择国家/地区"
|
||||
:close-on-click-modal="true"
|
||||
@close="closeAreaCodeSheet"
|
||||
>
|
||||
<view class="area-code-sheet">
|
||||
<scroll-view scroll-y class="area-code-list">
|
||||
<view
|
||||
v-for="item in areaCodeList"
|
||||
:key="item.key"
|
||||
class="area-code-item"
|
||||
:class="{ selected: selectedAreaCode === item.key }"
|
||||
@click="selectAreaCode(item)"
|
||||
>
|
||||
<view class="area-info">
|
||||
<text class="area-name">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text class="area-code">
|
||||
{{ item.key }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-icon
|
||||
v-if="selectedAreaCode === item.key"
|
||||
name="check"
|
||||
custom-class="check-icon"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="sheet-footer">
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-class="confirm-btn"
|
||||
@click="closeAreaCodeSheet"
|
||||
>
|
||||
确认
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-action-sheet>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-container {
|
||||
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 280rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15% 0 40rpx 0;
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
|
||||
.logo {
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
display: block;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
padding: 0 40rpx 40rpx 40rpx;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24rpx;
|
||||
padding: 40rpx 30rpx 30rpx 30rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||
max-height: calc(100vh - 350rpx);
|
||||
overflow-y: auto;
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&.captcha-wrapper {
|
||||
.captcha-image {
|
||||
margin-left: 20rpx;
|
||||
width: 120rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e9ecef;
|
||||
border: 1rpx solid #ddd;
|
||||
|
||||
.captcha-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.captcha-loading {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.mobile-wrapper {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
.area-code-selector {
|
||||
flex: 0 0 160rpx;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
height: 45rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.area-code-text {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.area-code-arrow) {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-input-wrapper {
|
||||
flex: 1;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.styled-input) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
flex: 1;
|
||||
|
||||
&::placeholder {
|
||||
color: #999999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
text-align: right;
|
||||
margin-bottom: 30rpx;
|
||||
|
||||
.forgot-text {
|
||||
color: #667eea;
|
||||
font-size: 26rpx;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
border: none;
|
||||
border-radius: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||
text-align: center;
|
||||
line-height: 80rpx;
|
||||
&:active {
|
||||
transform: translateY(2rpx);
|
||||
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.register-hint {
|
||||
text-align: center;
|
||||
|
||||
.hint-text {
|
||||
color: #666666;
|
||||
font-size: 26rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: #667eea;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
// text-decoration: underline;
|
||||
// &:hover {
|
||||
// text-decoration: underline;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.login-type-switch {
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
|
||||
.switch-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 60rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.switch-tab {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
background: #667eea;
|
||||
color: #ffffff;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
:deep(.wd-icon) {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-hint {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 区号选择弹窗样式
|
||||
.area-code-sheet {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 40rpx 40rpx 20rpx 40rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
.sheet-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
:deep(.close-icon) {
|
||||
font-size: 32rpx;
|
||||
color: #999999;
|
||||
cursor: pointer;
|
||||
padding: 10rpx;
|
||||
|
||||
&:hover {
|
||||
color: #333333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.area-code-list {
|
||||
max-height: 60vh;
|
||||
padding: 0 40rpx;
|
||||
|
||||
.area-code-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 0;
|
||||
border-bottom: 1rpx solid #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
|
||||
.area-name {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.area-code {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.area-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.area-name {
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.area-code {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.check-icon) {
|
||||
font-size: 32rpx;
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding: 30rpx 40rpx 40rpx 40rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
:deep(.confirm-btn) {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,878 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { register, sendSmsCode } from '@/api/auth'
|
||||
import { useConfigStore } from '@/store'
|
||||
import { getEnvBaseUrl } from '@/utils'
|
||||
import { toast } from '@/utils/toast'
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets
|
||||
let systemInfo
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信小程序使用新的API
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
// 其他平台继续使用uni API
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
// 注册表单数据
|
||||
interface RegisterData {
|
||||
username: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
captcha: string
|
||||
captchaId: string
|
||||
areaCode: string
|
||||
mobile: string
|
||||
mobileCaptcha: string
|
||||
}
|
||||
|
||||
const formData = ref<RegisterData>({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
captcha: '',
|
||||
captchaId: '',
|
||||
areaCode: '+86',
|
||||
mobile: '',
|
||||
mobileCaptcha: '',
|
||||
})
|
||||
|
||||
// 验证码图片
|
||||
const captchaImage = ref('')
|
||||
const loading = ref(false)
|
||||
const smsLoading = ref(false)
|
||||
const smsCountdown = ref(0)
|
||||
|
||||
// 注册方式:'username' | 'mobile'
|
||||
const registerType = ref<'username' | 'mobile'>('username')
|
||||
|
||||
// 获取配置store
|
||||
const configStore = useConfigStore()
|
||||
|
||||
// 区号选择相关
|
||||
const showAreaCodeSheet = ref(false)
|
||||
const selectedAreaCode = ref('+86')
|
||||
const selectedAreaName = ref('中国大陆')
|
||||
|
||||
// 计算属性:是否启用手机号注册
|
||||
const enableMobileRegister = computed(() => {
|
||||
return configStore.config.enableMobileRegister
|
||||
})
|
||||
|
||||
// 计算属性:区号列表
|
||||
const areaCodeList = computed(() => {
|
||||
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
|
||||
})
|
||||
|
||||
// 切换注册方式
|
||||
function toggleRegisterType() {
|
||||
registerType.value = registerType.value === 'username' ? 'mobile' : 'username'
|
||||
// 清空输入框
|
||||
formData.value.username = ''
|
||||
formData.value.mobile = ''
|
||||
formData.value.mobileCaptcha = ''
|
||||
}
|
||||
|
||||
// 打开区号选择弹窗
|
||||
function openAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = true
|
||||
}
|
||||
|
||||
// 选择区号
|
||||
function selectAreaCode(item: { name: string, key: string }) {
|
||||
selectedAreaCode.value = item.key
|
||||
selectedAreaName.value = item.name
|
||||
formData.value.areaCode = item.key
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 关闭区号选择弹窗
|
||||
function closeAreaCodeSheet() {
|
||||
showAreaCodeSheet.value = false
|
||||
}
|
||||
|
||||
// 生成UUID
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取验证码
|
||||
async function refreshCaptcha() {
|
||||
const uuid = generateUUID()
|
||||
formData.value.captchaId = uuid
|
||||
captchaImage.value = `${getEnvBaseUrl()}/user/captcha?uuid=${uuid}&t=${Date.now()}`
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
async function sendSmsVerification() {
|
||||
if (!formData.value.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning('请输入图形验证码')
|
||||
return
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
smsLoading.value = true
|
||||
await sendSmsCode({
|
||||
phone: `${selectedAreaCode.value}${formData.value.mobile}`,
|
||||
captcha: formData.value.captcha,
|
||||
captchaId: formData.value.captchaId,
|
||||
})
|
||||
|
||||
toast.success('验证码发送成功')
|
||||
|
||||
// 开始倒计时
|
||||
smsCountdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 发送失败重新获取图形验证码
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
smsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
async function handleRegister() {
|
||||
// 表单验证
|
||||
if (registerType.value === 'username') {
|
||||
if (!formData.value.username) {
|
||||
toast.warning('请输入用户名')
|
||||
return
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!formData.value.mobile) {
|
||||
toast.warning('请输入手机号')
|
||||
return
|
||||
}
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/
|
||||
if (!phoneRegex.test(formData.value.mobile)) {
|
||||
toast.warning('请输入正确的手机号')
|
||||
return
|
||||
}
|
||||
if (!formData.value.mobileCaptcha) {
|
||||
toast.warning('请输入短信验证码')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.value.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.confirmPassword) {
|
||||
toast.warning('请确认密码')
|
||||
return
|
||||
}
|
||||
|
||||
if (formData.value.password !== formData.value.confirmPassword) {
|
||||
toast.warning('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.value.captcha) {
|
||||
toast.warning('请输入验证码')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建注册数据
|
||||
const registerData = {
|
||||
username: registerType.value === 'mobile' ? `${selectedAreaCode.value}${formData.value.mobile}` : formData.value.username,
|
||||
password: formData.value.password,
|
||||
confirmPassword: formData.value.confirmPassword,
|
||||
captcha: formData.value.captcha,
|
||||
captchaId: formData.value.captchaId,
|
||||
areaCode: formData.value.areaCode,
|
||||
mobile: formData.value.mobile,
|
||||
mobileCaptcha: formData.value.mobileCaptcha,
|
||||
}
|
||||
|
||||
await register(registerData)
|
||||
toast.success('注册成功')
|
||||
|
||||
// 跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1000)
|
||||
}
|
||||
catch (error: any) {
|
||||
// 注册失败重新获取验证码
|
||||
refreshCaptcha()
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回登录
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 页面加载时获取验证码
|
||||
onLoad(() => {
|
||||
refreshCaptcha()
|
||||
})
|
||||
|
||||
// 组件挂载时确保配置已加载
|
||||
onMounted(async () => {
|
||||
if (!configStore.config.name) {
|
||||
try {
|
||||
await configStore.fetchPublicConfig()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取配置失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="app-container box-border h-screen w-full" :style="{ paddingTop: `${safeAreaInsets?.top}px` }">
|
||||
<view class="header">
|
||||
<view class="back-button" @click="goBack">
|
||||
<wd-icon name="arrow-left" custom-class="back-icon" />
|
||||
</view>
|
||||
<view class="logo-section">
|
||||
<wd-img :width="80" :height="80" round src="/static/logo.png" class="logo" />
|
||||
<text class="welcome-text">
|
||||
欢迎注册
|
||||
</text>
|
||||
<text class="subtitle">
|
||||
创建您的新账户
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-container">
|
||||
<view class="form">
|
||||
<!-- 手机号注册 -->
|
||||
<template v-if="registerType === 'mobile'">
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper mobile-wrapper">
|
||||
<view class="area-code-selector" @click="openAreaCodeSheet">
|
||||
<text class="area-code-text">
|
||||
{{ selectedAreaCode }}
|
||||
</text>
|
||||
<wd-icon name="arrow-down" custom-class="area-code-arrow" />
|
||||
</view>
|
||||
<view class="mobile-input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.mobile"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入手机号码"
|
||||
type="number"
|
||||
:maxlength="11"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 用户名注册 -->
|
||||
<template v-else>
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.username"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.password"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.confirmPassword"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请确认密码"
|
||||
show-password
|
||||
:maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-group">
|
||||
<view class="input-wrapper captcha-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.captcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入验证码"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<view class="captcha-image" @click="refreshCaptcha">
|
||||
<image :src="captchaImage" class="captcha-img" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 手机验证码输入框 -->
|
||||
<view v-if="registerType === 'mobile'" class="input-group">
|
||||
<view class="input-wrapper sms-wrapper">
|
||||
<wd-input
|
||||
v-model="formData.mobileCaptcha"
|
||||
custom-class="styled-input"
|
||||
no-border
|
||||
placeholder="请输入短信验证码"
|
||||
type="number"
|
||||
:maxlength="6"
|
||||
/>
|
||||
<wd-button
|
||||
:loading="smsLoading"
|
||||
:disabled="smsCountdown > 0"
|
||||
custom-class="sms-btn"
|
||||
@click="sendSmsVerification"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s` : '获取验证码' }}
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="register-btn"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</view>
|
||||
|
||||
<view class="login-hint">
|
||||
<text class="hint-text">
|
||||
已有账户?
|
||||
</text>
|
||||
<text class="login-link" @click="goBack">
|
||||
立即登录
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 注册方式切换 -->
|
||||
<view v-if="enableMobileRegister" class="register-type-switch">
|
||||
<view class="switch-tabs">
|
||||
<view
|
||||
class="switch-tab"
|
||||
:class="{ active: registerType === 'username' }"
|
||||
@click="toggleRegisterType"
|
||||
>
|
||||
<wd-icon name="user" />
|
||||
</view>
|
||||
<view
|
||||
class="switch-tab"
|
||||
:class="{ active: registerType === 'mobile' }"
|
||||
@click="toggleRegisterType"
|
||||
>
|
||||
<wd-icon name="phone" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 区号选择弹窗 -->
|
||||
<wd-action-sheet
|
||||
v-model="showAreaCodeSheet"
|
||||
title="选择国家/地区"
|
||||
:close-on-click-modal="true"
|
||||
@close="closeAreaCodeSheet"
|
||||
>
|
||||
<view class="area-code-sheet">
|
||||
<scroll-view scroll-y class="area-code-list">
|
||||
<view
|
||||
v-for="item in areaCodeList"
|
||||
:key="item.key"
|
||||
class="area-code-item"
|
||||
:class="{ selected: selectedAreaCode === item.key }"
|
||||
@click="selectAreaCode(item)"
|
||||
>
|
||||
<view class="area-info">
|
||||
<text class="area-name">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
<text class="area-code">
|
||||
{{ item.key }}
|
||||
</text>
|
||||
</view>
|
||||
<wd-icon
|
||||
v-if="selectedAreaCode === item.key"
|
||||
name="check"
|
||||
custom-class="check-icon"
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="sheet-footer">
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-class="confirm-btn"
|
||||
@click="closeAreaCodeSheet"
|
||||
>
|
||||
确认
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-action-sheet>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-container {
|
||||
background: linear-gradient(145deg, #f5f8fd, #6baaff, #9ebbfc, #f5f8fd);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px) rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
flex: 0 0 auto;
|
||||
min-height: 280rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15% 0 40rpx 0;
|
||||
position: relative;
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
left: 40rpx;
|
||||
top: 60rpx;
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:deep(.back-icon) {
|
||||
font-size: 32rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
text-align: center;
|
||||
|
||||
.logo {
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
display: block;
|
||||
color: #ffffff;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
padding: 0 40rpx 40rpx 40rpx;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 24rpx;
|
||||
padding: 40rpx 30rpx 30rpx 30rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.1);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.2);
|
||||
max-height: calc(100vh - 350rpx);
|
||||
overflow-y: auto;
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 24rpx;
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
&.captcha-wrapper {
|
||||
.captcha-image {
|
||||
margin-left: 20rpx;
|
||||
width: 120rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e9ecef;
|
||||
border: 1rpx solid #ddd;
|
||||
|
||||
.captcha-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.sms-wrapper {
|
||||
:deep(.sms-btn) {
|
||||
margin-left: 20rpx;
|
||||
padding: 0 20rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
white-space: nowrap;
|
||||
min-width: 140rpx;
|
||||
}
|
||||
}
|
||||
|
||||
&.mobile-wrapper {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
|
||||
.area-code-selector {
|
||||
flex: 0 0 160rpx;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
height: 45rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.area-code-text {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.area-code-arrow) {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-input-wrapper {
|
||||
flex: 1;
|
||||
background: #f8f9fa;
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx 16rpx;
|
||||
border: 2rpx solid #e9ecef;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: #667eea;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 6rpx rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.styled-input) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
flex: 1;
|
||||
|
||||
&::placeholder {
|
||||
color: #999999;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.register-btn) {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
border: none;
|
||||
border-radius: 16rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-bottom: 30rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--wot-button-primary-bg-color, var(--wot-color-theme, #4d80f0));
|
||||
text-align: center;
|
||||
line-height: 80rpx;
|
||||
&:active {
|
||||
transform: translateY(2rpx);
|
||||
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
text-align: center;
|
||||
margin-bottom: 30rpx;
|
||||
|
||||
.hint-text {
|
||||
color: #666666;
|
||||
font-size: 26rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: #667eea;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.register-type-switch {
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
|
||||
.switch-tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 60rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.switch-tab {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2rpx solid transparent;
|
||||
|
||||
&.active {
|
||||
background: #667eea;
|
||||
color: #ffffff;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
:deep(.wd-icon) {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-hint {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 区号选择弹窗样式
|
||||
.area-code-sheet {
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
overflow: hidden;
|
||||
|
||||
.area-code-list {
|
||||
max-height: 60vh;
|
||||
padding: 0 40rpx;
|
||||
|
||||
.area-code-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 0;
|
||||
border-bottom: 1rpx solid #f8f9fa;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: rgba(102, 126, 234, 0.05);
|
||||
|
||||
.area-name {
|
||||
color: #667eea;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.area-code {
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.area-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
|
||||
.area-name {
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.area-code {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.check-icon) {
|
||||
font-size: 32rpx;
|
||||
color: #667eea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding: 30rpx 40rpx 40rpx 40rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
|
||||
:deep(.confirm-btn) {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 16rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<route lang="jsonc" type="page">
|
||||
{
|
||||
"layout": "default",
|
||||
"style": {
|
||||
"navigationBarTitleText": "设置",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
</route>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useToast } from 'wot-design-uni'
|
||||
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
|
||||
|
||||
defineOptions({
|
||||
name: 'SettingsPage',
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// 缓存信息
|
||||
const cacheInfo = reactive({
|
||||
storageSize: '0MB',
|
||||
imageCache: '0MB',
|
||||
dataCache: '0MB',
|
||||
})
|
||||
|
||||
// 服务端地址设置
|
||||
const baseUrlInput = ref('')
|
||||
|
||||
// 系统信息(保留)
|
||||
const systemInfo = computed(() => {
|
||||
const info = uni.getSystemInfoSync()
|
||||
return `${info.platform} ${info.system}`
|
||||
})
|
||||
|
||||
// 读取本地覆盖地址
|
||||
function loadServerBaseUrl() {
|
||||
const override = getServerBaseUrlOverride()
|
||||
baseUrlInput.value = override || getEnvBaseUrl()
|
||||
}
|
||||
|
||||
// 获取缓存信息
|
||||
function getCacheInfo() {
|
||||
try {
|
||||
const info = uni.getStorageInfoSync()
|
||||
const totalSize = (info.currentSize || 0) / 1024 // KB to MB
|
||||
cacheInfo.storageSize = `${totalSize.toFixed(2)}MB`
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取缓存信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存服务端地址
|
||||
function saveServerBaseUrl() {
|
||||
if (!baseUrlInput.value || !/^https?:\/\//.test(baseUrlInput.value)) {
|
||||
toast.warning('请输入有效的服务端地址(以 http 或 https 开头)')
|
||||
return
|
||||
}
|
||||
setServerBaseUrlOverride(baseUrlInput.value)
|
||||
|
||||
// 切换请求地址后清空所有缓存
|
||||
clearAllCacheAfterUrlChange()
|
||||
|
||||
uni.showModal({
|
||||
title: '重启应用',
|
||||
content: '服务端地址已保存并清空缓存,是否立即重启生效?',
|
||||
confirmText: '立即重启',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
restartApp()
|
||||
}
|
||||
else {
|
||||
toast.success('已保存,可稍后手动重启应用')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 重置为 env 默认
|
||||
function resetServerBaseUrl() {
|
||||
clearServerBaseUrlOverride()
|
||||
baseUrlInput.value = getEnvBaseUrl()
|
||||
|
||||
// 切换请求地址后清空所有缓存
|
||||
clearAllCacheAfterUrlChange()
|
||||
|
||||
uni.showModal({
|
||||
title: '重启应用',
|
||||
content: '已重置为默认地址并清空缓存,是否立即重启生效?',
|
||||
confirmText: '立即重启',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
restartApp()
|
||||
}
|
||||
else {
|
||||
toast.success('已重置,可稍后手动重启应用')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 重启应用(App 原生重启;其他端回到首页)
|
||||
function restartApp() {
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.restart()
|
||||
// #endif
|
||||
// #ifndef APP-PLUS
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
// #endif
|
||||
}
|
||||
|
||||
// 切换地址后自动清空所有缓存
|
||||
function clearAllCacheAfterUrlChange() {
|
||||
try {
|
||||
// 完全清空所有缓存,包括token
|
||||
uni.clearStorageSync()
|
||||
|
||||
// 清空localStorage(H5环境)
|
||||
// #ifdef H5
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.clear()
|
||||
}
|
||||
// #endif
|
||||
|
||||
// 重新获取缓存信息
|
||||
getCacheInfo()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('清除缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
async function clearCache() {
|
||||
try {
|
||||
uni.showModal({
|
||||
title: '确认清除',
|
||||
content: '确定要清除所有缓存吗?这将删除所有数据包括登录状态,需要重新登录。',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
clearAllCacheAfterUrlChange()
|
||||
toast.success('缓存清除成功,即将跳转到登录页')
|
||||
|
||||
// 延迟跳转到登录页
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}, 1500)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('清除缓存失败:', error)
|
||||
toast.error('清除缓存失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 关于我们
|
||||
function showAbout() {
|
||||
uni.showModal({
|
||||
title: `关于${import.meta.env.VITE_APP_TITLE}`,
|
||||
content: `${import.meta.env.VITE_APP_TITLE}\n\n基于 Vue.js 3 + uni-app 构建的跨平台移动端管理应用,为小智ESP32智能硬件提供设备管理、智能体配置等功能。\n\n© 2025 Xiaozhi Team`,
|
||||
showCancel: false,
|
||||
confirmText: '确定',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadServerBaseUrl()
|
||||
getCacheInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="min-h-screen bg-[#f5f7fb]">
|
||||
<wd-navbar title="设置" placeholder safe-area-inset-top fixed />
|
||||
|
||||
<view class="p-[24rpx]">
|
||||
<!-- 网络设置 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<view class="mb-[24rpx] flex items-center">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
网络设置
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]" style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||
<view class="mb-[24rpx]">
|
||||
<text class="text-[28rpx] text-[#232338] font-semibold">
|
||||
服务端接口地址
|
||||
</text>
|
||||
<text class="mt-[8rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
修改后将自动清空缓存并重启应用
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="mb-[24rpx]">
|
||||
<input
|
||||
v-model="baseUrlInput"
|
||||
class="h-[88rpx] w-full border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] px-[24rpx] text-[28rpx] text-[#232338] transition-all focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3] focus:shadow-[0_0_0_4rpx_rgba(51,108,255,0.1)]"
|
||||
type="text"
|
||||
placeholder="输入服务端地址,如 https://example.com/api"
|
||||
>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-[16rpx]">
|
||||
<wd-button
|
||||
type="primary"
|
||||
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-[#336cff] border-none shadow-[0_4rpx_16rpx_rgba(51,108,255,0.3)] active:shadow-[0_2rpx_8rpx_rgba(51,108,255,0.4)] active:scale-98"
|
||||
@click="saveServerBaseUrl"
|
||||
>
|
||||
保存设置
|
||||
</wd-button>
|
||||
<wd-button
|
||||
type="default"
|
||||
custom-class="flex-1 h-[88rpx] rounded-[20rpx] text-[28rpx] font-semibold bg-white border-[#eeeeee] text-[#65686f] active:bg-[#f5f7fb]"
|
||||
@click="resetServerBaseUrl"
|
||||
>
|
||||
恢复默认
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 缓存管理 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<view class="mb-[24rpx] flex items-center">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
缓存管理
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]" style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||
<view class="space-y-[16rpx]">
|
||||
<!-- 缓存信息展示,参考插件样式 -->
|
||||
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]">
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
总缓存大小
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
应用数据总大小
|
||||
</text>
|
||||
</view>
|
||||
<text class="text-[28rpx] text-[#65686f] font-semibold">
|
||||
{{ cacheInfo.storageSize }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 清除缓存按钮,参考插件编辑按钮样式 -->
|
||||
<view class="flex items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx]">
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
缓存清理
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
清空所有缓存数据
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="cursor-pointer rounded-[24rpx] bg-[rgba(255,107,107,0.1)] px-[28rpx] py-[16rpx] text-[24rpx] text-[#ff6b6b] font-semibold transition-all duration-300 active:scale-95 active:bg-[#ff6b6b] active:text-white"
|
||||
@click="clearCache"
|
||||
>
|
||||
清除缓存
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 应用信息 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<view class="mb-[24rpx] flex items-center">
|
||||
<text class="text-[32rpx] text-[#232338] font-bold">
|
||||
应用信息
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="border border-[#eeeeee] rounded-[24rpx] bg-[#fbfbfb] p-[32rpx]" style="box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);">
|
||||
<view
|
||||
class="flex cursor-pointer items-center justify-between border border-[#eeeeee] rounded-[16rpx] bg-[#f5f7fb] p-[24rpx] transition-all active:bg-[#eef3ff]"
|
||||
@click="showAbout"
|
||||
>
|
||||
<view>
|
||||
<text class="text-[28rpx] text-[#232338] font-medium">
|
||||
关于我们
|
||||
</text>
|
||||
<text class="mt-[4rpx] block text-[24rpx] text-[#9d9ea3]">
|
||||
应用版本与团队信息
|
||||
</text>
|
||||
</view>
|
||||
<wd-icon name="arrow-right" custom-class="text-[32rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全距离 -->
|
||||
<view style="height: env(safe-area-inset-bottom);" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 保持与 edit.vue 一致的风格,样式主要通过类名控制
|
||||
</style>
|
||||
@@ -0,0 +1,512 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ChatHistory, CreateSpeakerData, VoicePrint } from '@/api/voiceprint'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useToast } from 'wot-design-uni/components/wd-toast'
|
||||
import { createVoicePrint, deleteVoicePrint, getChatHistory, getVoicePrintList, updateVoicePrint } from '@/api/voiceprint'
|
||||
|
||||
defineOptions({
|
||||
name: 'VoicePrintManage',
|
||||
})
|
||||
|
||||
// 接收props
|
||||
interface Props {
|
||||
agentId?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
agentId: 'default'
|
||||
})
|
||||
|
||||
// 获取屏幕边界到安全区域距离
|
||||
let safeAreaInsets: any
|
||||
let systemInfo: any
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
systemInfo = uni.getWindowInfo()
|
||||
safeAreaInsets = systemInfo.safeArea
|
||||
? {
|
||||
top: systemInfo.safeArea.top,
|
||||
right: systemInfo.windowWidth - systemInfo.safeArea.right,
|
||||
bottom: systemInfo.windowHeight - systemInfo.safeArea.bottom,
|
||||
left: systemInfo.safeArea.left,
|
||||
}
|
||||
: null
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
systemInfo = uni.getSystemInfoSync()
|
||||
safeAreaInsets = systemInfo.safeAreaInsets
|
||||
// #endif
|
||||
|
||||
const message = useMessage()
|
||||
const toast = useToast()
|
||||
|
||||
// 页面数据
|
||||
const voicePrintList = ref<VoicePrint[]>([])
|
||||
const chatHistoryList = ref<ChatHistory[]>([])
|
||||
const chatHistoryActions = ref<any[]>([])
|
||||
const swipeStates = ref<Record<string, 'left' | 'close' | 'right'>>({})
|
||||
const loading = ref(false)
|
||||
|
||||
// 使用传入的智能体ID
|
||||
const currentAgentId = computed(() => {
|
||||
return props.agentId
|
||||
})
|
||||
|
||||
// 智能体选择相关功能已移除
|
||||
|
||||
// 弹窗相关
|
||||
const showAddDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showChatHistoryDialog = ref(false)
|
||||
const addForm = ref<CreateSpeakerData>({
|
||||
agentId: '',
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: '',
|
||||
})
|
||||
const editForm = ref<VoicePrint>({
|
||||
id: '',
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: '',
|
||||
createDate: '',
|
||||
})
|
||||
|
||||
// 获取声纹列表
|
||||
async function loadVoicePrintList() {
|
||||
try {
|
||||
console.log('获取声纹列表')
|
||||
|
||||
// 检查是否有当前选中的智能体
|
||||
if (!currentAgentId.value) {
|
||||
console.warn('没有选中的智能体')
|
||||
voicePrintList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
const data = await getVoicePrintList(currentAgentId.value)
|
||||
|
||||
// 初始化滑动状态
|
||||
const list = data || []
|
||||
list.forEach((item) => {
|
||||
if (!swipeStates.value[item.id]) {
|
||||
swipeStates.value[item.id] = 'close'
|
||||
}
|
||||
})
|
||||
|
||||
voicePrintList.value = list
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取声纹列表失败:', error)
|
||||
voicePrintList.value = []
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的刷新方法
|
||||
async function refresh() {
|
||||
await loadVoicePrintList()
|
||||
}
|
||||
|
||||
// 获取语音对话记录
|
||||
async function loadChatHistory() {
|
||||
try {
|
||||
if (!currentAgentId.value) {
|
||||
toast.error('请先选择智能体')
|
||||
return
|
||||
}
|
||||
|
||||
const data = await getChatHistory(currentAgentId.value)
|
||||
chatHistoryList.value = data || []
|
||||
// 转换为ActionSheet格式
|
||||
chatHistoryActions.value = chatHistoryList.value.map((item, index) => ({
|
||||
name: item.content,
|
||||
audioId: item.audioId,
|
||||
index,
|
||||
}))
|
||||
showChatHistoryDialog.value = true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取对话记录失败:', error)
|
||||
toast.error('获取对话记录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加弹窗
|
||||
function openAddDialog() {
|
||||
if (!currentAgentId.value) {
|
||||
toast.error('请先选择智能体')
|
||||
return
|
||||
}
|
||||
|
||||
addForm.value = {
|
||||
agentId: currentAgentId.value,
|
||||
audioId: '',
|
||||
sourceName: '',
|
||||
introduce: '',
|
||||
}
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
// 打开编辑弹窗
|
||||
function openEditDialog(item: VoicePrint) {
|
||||
editForm.value = { ...item }
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
// 获取选中音频的显示内容
|
||||
function getSelectedAudioContent(audioId: string) {
|
||||
if (!audioId)
|
||||
return '点击选择声纹向量'
|
||||
const chatItem = chatHistoryList.value.find(item => item.audioId === audioId)
|
||||
return chatItem ? chatItem.content : `已选择: ${audioId.substring(0, 8)}...`
|
||||
}
|
||||
|
||||
// 选择声纹向量
|
||||
function selectAudioId({ item }: { item: any }) {
|
||||
if (showAddDialog.value) {
|
||||
addForm.value.audioId = item.audioId
|
||||
}
|
||||
else if (showEditDialog.value) {
|
||||
editForm.value.audioId = item.audioId
|
||||
}
|
||||
showChatHistoryDialog.value = false
|
||||
}
|
||||
|
||||
// 提交添加说话人
|
||||
async function submitAdd() {
|
||||
if (!addForm.value.sourceName.trim()) {
|
||||
toast.error('请输入姓名')
|
||||
return
|
||||
}
|
||||
if (!addForm.value.audioId) {
|
||||
toast.error('请选择声纹向量')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createVoicePrint(addForm.value)
|
||||
toast.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
await loadVoicePrintList()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('添加说话人失败:', error)
|
||||
toast.error('添加说话人失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交编辑说话人
|
||||
async function submitEdit() {
|
||||
if (!editForm.value.sourceName.trim()) {
|
||||
toast.error('请输入姓名')
|
||||
return
|
||||
}
|
||||
if (!editForm.value.audioId) {
|
||||
toast.error('请选择声纹向量')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateVoicePrint({
|
||||
id: editForm.value.id,
|
||||
audioId: editForm.value.audioId,
|
||||
sourceName: editForm.value.sourceName,
|
||||
introduce: editForm.value.introduce,
|
||||
createDate: editForm.value.createDate,
|
||||
})
|
||||
toast.success('编辑成功')
|
||||
showEditDialog.value = false
|
||||
await loadVoicePrintList()
|
||||
}
|
||||
catch (error) {
|
||||
console.error('编辑说话人失败:', error)
|
||||
toast.error('编辑说话人失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理编辑操作
|
||||
function handleEdit(item: VoicePrint) {
|
||||
openEditDialog(item)
|
||||
swipeStates.value[item.id] = 'close'
|
||||
}
|
||||
|
||||
// 删除声纹
|
||||
async function handleDelete(id: string) {
|
||||
message.confirm({
|
||||
msg: '确定要删除这个说话人吗?',
|
||||
title: '确认删除',
|
||||
}).then(async () => {
|
||||
await deleteVoicePrint(id)
|
||||
toast.success('删除成功')
|
||||
await loadVoicePrintList()
|
||||
}).catch(() => {
|
||||
console.log('点击了取消按钮')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 智能体已简化为默认
|
||||
|
||||
loadVoicePrintList()
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
refresh,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="voiceprint-container" style="background: #f5f7fb; min-height: 100%;">
|
||||
<!-- 加载状态 -->
|
||||
<view v-if="loading && voicePrintList.length === 0" class="loading-container">
|
||||
<wd-loading color="#336cff" />
|
||||
<text class="loading-text">
|
||||
加载中...
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 声纹列表 -->
|
||||
<view v-else-if="voicePrintList.length > 0" class="voiceprint-list">
|
||||
<!-- 声纹卡片列表 -->
|
||||
<view class="box-border flex flex-col gap-[24rpx] p-[20rpx]">
|
||||
<view v-for="item in voicePrintList" :key="item.id">
|
||||
<wd-swipe-action
|
||||
:model-value="swipeStates[item.id] || 'close'"
|
||||
@update:model-value="swipeStates[item.id] = $event"
|
||||
>
|
||||
<view class="bg-[#fbfbfb] p-[32rpx]" @click="handleEdit(item)">
|
||||
<view>
|
||||
<text class="mb-[12rpx] block text-[32rpx] text-[#232338] font-semibold">
|
||||
{{ item.sourceName }}
|
||||
</text>
|
||||
<text class="mb-[12rpx] block text-[28rpx] text-[#65686f] leading-[1.4]">
|
||||
{{ item.introduce || '暂无描述' }}
|
||||
</text>
|
||||
<text class="block text-[24rpx] text-[#9d9ea3]">
|
||||
{{ item.createDate }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template #right>
|
||||
<view class="h-full flex">
|
||||
<view
|
||||
class="h-full min-w-[120rpx] flex items-center justify-center bg-[#ff4d4f] p-x-[32rpx] text-[28rpx] text-white font-medium"
|
||||
@click="handleDelete(item.id)"
|
||||
>
|
||||
<wd-icon name="delete" />
|
||||
删除
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</wd-swipe-action>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else-if="!loading" class="empty-container">
|
||||
<view class="flex flex-col items-center justify-center p-[100rpx_40rpx] text-center">
|
||||
<wd-icon name="voice" custom-class="text-[120rpx] text-[#d9d9d9] mb-[32rpx]" />
|
||||
<text class="mb-[16rpx] text-[32rpx] text-[#666666] font-medium">
|
||||
暂无声纹数据
|
||||
</text>
|
||||
<text class="text-[26rpx] text-[#999999] leading-[1.5]">
|
||||
点击右下角 + 号添加您的第一个说话人
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<wd-fab type="primary" size="small" :draggable="true" :expandable="false" @click="openAddDialog">
|
||||
<wd-icon name="add" />
|
||||
</wd-fab>
|
||||
|
||||
<!-- MessageBox 组件 -->
|
||||
<wd-message-box />
|
||||
</view>
|
||||
|
||||
<!-- 添加说话人弹窗 -->
|
||||
<wd-popup
|
||||
v-model="showAddDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
|
||||
safe-area-inset-bottom
|
||||
>
|
||||
<view>
|
||||
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
|
||||
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
|
||||
添加说话人
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="p-[32rpx]">
|
||||
<!-- 声纹向量选择 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 声纹向量
|
||||
</text>
|
||||
<view
|
||||
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
|
||||
@click="loadChatHistory"
|
||||
>
|
||||
<text
|
||||
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
|
||||
:class="{ 'text-[#9d9ea3]': !addForm.audioId }"
|
||||
>
|
||||
{{ getSelectedAudioContent(addForm.audioId) }}
|
||||
</text>
|
||||
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 姓名 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 姓名
|
||||
</text>
|
||||
<input
|
||||
v-model="addForm.sourceName"
|
||||
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="text" placeholder="请输入姓名"
|
||||
>
|
||||
</view>
|
||||
|
||||
<!-- 描述 -->
|
||||
<view>
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 描述
|
||||
</text>
|
||||
<textarea
|
||||
v-model="addForm.introduce" :maxlength="100" placeholder="请输入描述"
|
||||
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
/>
|
||||
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||
{{ (addForm.introduce || '').length }}/100
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||
<wd-button type="info" custom-class="flex-1" @click="showAddDialog = false">
|
||||
取消
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitAdd">
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 编辑说话人弹窗 -->
|
||||
<wd-popup
|
||||
v-model="showEditDialog" position="center" custom-style="width: 90%; max-width: 400px; border-radius: 16px;"
|
||||
safe-area-inset-bottom
|
||||
>
|
||||
<view>
|
||||
<view class="w-full flex items-center justify-between border-b-[2rpx] border-[#eeeeee] p-[32rpx_32rpx_24rpx]">
|
||||
<text class="w-full text-center text-[32rpx] text-[#232338] font-semibold">
|
||||
编辑说话人
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="p-[32rpx]">
|
||||
<!-- 声纹向量选择 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 声纹向量
|
||||
</text>
|
||||
<view
|
||||
class="flex cursor-pointer items-center justify-between border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] transition-all duration-300 active:bg-[#eef3ff]"
|
||||
@click="loadChatHistory"
|
||||
>
|
||||
<text
|
||||
class="m-r-[16rpx] flex-1 text-left text-[26rpx] text-[#232338]"
|
||||
:class="{ 'text-[#9d9ea3]': !editForm.audioId }"
|
||||
>
|
||||
{{ getSelectedAudioContent(editForm.audioId) }}
|
||||
</text>
|
||||
<wd-icon name="arrow-down" custom-class="text-[20rpx] text-[#9d9ea3]" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 姓名 -->
|
||||
<view class="mb-[32rpx]">
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 姓名
|
||||
</text>
|
||||
<input
|
||||
v-model="editForm.sourceName"
|
||||
class="box-border h-[80rpx] w-full border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[16rpx_20rpx] text-[28rpx] text-[#232338] leading-[1.4] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
type="text" placeholder="请输入姓名"
|
||||
>
|
||||
</view>
|
||||
|
||||
<!-- 描述 -->
|
||||
<view>
|
||||
<text class="mb-[16rpx] block text-[28rpx] text-[#232338] font-medium">
|
||||
* 描述
|
||||
</text>
|
||||
<textarea
|
||||
v-model="editForm.introduce" :maxlength="100" placeholder="请输入描述"
|
||||
class="box-border h-[200rpx] w-full resize-none border-[1rpx] border-[#eeeeee] rounded-[12rpx] bg-[#f5f7fb] p-[20rpx] text-[26rpx] text-[#232338] leading-[1.6] outline-none focus:border-[#336cff] focus:bg-white placeholder:text-[#9d9ea3]"
|
||||
/>
|
||||
<view class="mt-[8rpx] text-right text-[22rpx] text-[#9d9ea3]">
|
||||
{{ (editForm.introduce || '').length }}/100
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex gap-[16rpx] border-t-[2rpx] border-[#eeeeee] p-[24rpx_32rpx_32rpx]">
|
||||
<wd-button type="info" custom-class="flex-1" @click="showEditDialog = false">
|
||||
取消
|
||||
</wd-button>
|
||||
<wd-button type="primary" custom-class="flex-1" @click="submitEdit">
|
||||
保存
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
|
||||
<!-- 语音对话记录选择动作面板 -->
|
||||
<wd-action-sheet
|
||||
v-model="showChatHistoryDialog" :actions="chatHistoryActions" title="选择声纹向量"
|
||||
@select="selectAudioId"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voiceprint-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 40rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
:deep(.wd-swipe-action) {
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
border: 1rpx solid #eeeeee;
|
||||
}
|
||||
|
||||
:deep(.flex-1) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user