update: mobile 编辑源和语速音调页面样式调整

This commit is contained in:
zhuoqinglian
2026-03-11 17:10:18 +08:00
parent 6a14d694b0
commit 48087582c2
13 changed files with 487 additions and 819 deletions
+26 -60
View File
@@ -2,10 +2,8 @@
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent, updateAgentTags } from '@/api/agent/agent'
import ContextProviderDialog from '@/components/ContextProviderDialog.vue'
import VoiceSettingsDialog from '@/components/VoiceSettingsDialog.vue'
import { t } from '@/i18n'
import { usePluginStore } from '@/store'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { toast } from '@/utils/toast'
defineOptions({
@@ -105,29 +103,17 @@ const pickerShow = ref<{
report: false,
})
const ttsSettings = ref({
volume: 0,
speed: 0,
pitch: 0,
})
const allFunctions = ref<PluginDefinition[]>([])
const dynamicTags = ref([])
const inputValue = ref('')
const inputVisible = ref(false)
const showContextProviderDialog = ref(false)
const currentContextProviders = ref([])
const showVoiceSettingsDialog = ref(false)
const voiceSettings = ref({
volume: 0,
speed: 0,
pitch: 0,
})
const languageOptions = ref([])
const isVisibleReport = ref(false)
// 使用插件store
const pluginStore = usePluginStore()
const speedPitchStore = useSpeedPitch()
const providerStore = useProvider()
// tabs
const tabList = [
@@ -174,27 +160,15 @@ function handleInputConfirm() {
// 打开上下文源编辑弹窗
function openContextProviderDialog() {
showContextProviderDialog.value = true
}
// 处理上下文源更新
function handleUpdateContext(providers: any[]) {
currentContextProviders.value = providers
}
function openVoiceSettingsDialog() {
showVoiceSettingsDialog.value = true
}
function handleUpdateVoiceSettings(settings: any) {
ttsSettings.value = settings
formData.value.ttsVolume = settings.volume
formData.value.ttsRate = settings.speed
formData.value.ttsPitch = settings.pitch
uni.navigateTo({
url: '/pages/agent/provider',
})
}
function handleRegulate() {
openVoiceSettingsDialog()
uni.navigateTo({
url: '/pages/agent/speedPitch',
})
}
// 加载智能体详情
@@ -211,14 +185,15 @@ async function loadAgentDetail() {
pluginStore.setCurrentAgentId(agentId.value)
pluginStore.setCurrentFunctions(detail.functions || [])
// 更新语速音调
speedPitchStore.updateSpeedPitch({
ttsVolume: detail.ttsVolume || 0,
ttsRate: detail.ttsRate || 0,
ttsPitch: detail.ttsPitch || 0,
})
// 加载上下文配置
currentContextProviders.value = (detail as any).contextProviders || []
// 加载语音设置
voiceSettings.value = {
volume: (detail as any).volume || 0,
speed: (detail as any).speed || 0,
pitch: (detail as any).pitch || 0,
}
providerStore.updateProviders(detail.contextProviders || [])
// 如果有TTS模型,加载对应的音色选项
if (detail.ttsModelId) {
@@ -373,17 +348,17 @@ function filterVoicesByLanguage() {
}
// 同步到ttsSettings(如果值为null,使用0作为显示默认值,但不修改form中的值)
ttsSettings.value = {
volume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0,
speed: formData.value.ttsRate !== null && formData.value.ttsRate !== undefined ? formData.value.ttsRate : 0,
pitch: formData.value.ttsPitch !== null && formData.value.ttsPitch !== undefined ? formData.value.ttsPitch : 0,
}
speedPitchStore.updateSpeedPitch({
ttsVolume: formData.value.ttsVolume !== null && formData.value.ttsVolume !== undefined ? formData.value.ttsVolume : 0,
ttsRate: formData.value.ttsRate !== null && formData.value.ttsRate !== undefined ? formData.value.ttsRate : 0,
ttsPitch: formData.value.ttsPitch !== null && formData.value.ttsPitch !== undefined ? formData.value.ttsPitch : 0,
})
}
// 根据语音合成模型加载语言
async function fetchAllLanguag(ttsModelId: string) {
try {
const res = await getAllLanguage(ttsModelId, '') as any[]
const res = await getAllLanguage(ttsModelId)
// 保存完整的音色信息
voiceDetails.value = res.reduce((acc, voice) => {
acc[voice.id] = voice
@@ -564,8 +539,9 @@ async function saveAgent() {
// 构建保存数据,包含上下文配置和语音设置
const saveData = {
...formData.value,
...speedPitchStore.speedPitch,
ttsLanguage: formData.value.language,
contextProviders: currentContextProviders.value,
contextProviders: providerStore.providers,
}
await updateAgent(agentId.value, saveData)
loadAgentDetail()
@@ -726,7 +702,7 @@ onMounted(async () => {
</text>
<view class="mt-0 flex flex-wrap items-center gap-[12rpx]">
<text class="text-[26rpx] text-[#65686f]">
{{ t('agent.contextProviderSuccess', { count: currentContextProviders.length }) }}
{{ t('agent.contextProviderSuccess', { count: providerStore.providers.length }) }}
</text>
<a class="text-[26rpx] text-[#5778ff] no-underline" href="https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/context-provider-integration.md" target="_blank">
{{ t('agent.contextProviderDocLink') }}
@@ -994,16 +970,6 @@ onMounted(async () => {
@close="onPickerCancel('report')"
@select="({ item }) => onPickerConfirm('report', item.value, item.name)"
/>
<ContextProviderDialog
v-model:visible="showContextProviderDialog"
:providers="currentContextProviders"
@confirm="handleUpdateContext"
/>
<VoiceSettingsDialog
v-model:visible="showVoiceSettingsDialog"
:settings="ttsSettings"
@confirm="handleUpdateVoiceSettings"
/>
</view>
</template>
@@ -0,0 +1,208 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "编辑源",
"navigationStyle": "custom"
}
}
</route>
<script lang="ts" setup>
import type { Providers } from '@/api/agent/types'
import { onMounted, ref } from 'vue'
import { t } from '@/i18n'
import { useProvider } from '@/store/provider'
defineOptions({
name: 'Provider',
})
const providerStore = useProvider()
const localProviders = ref<Providers[]>([])
function initLocalData() {
localProviders.value = providerStore.providers.map((p) => {
const headers = p.headers || {}
return {
url: p.url,
headers: Object.entries(headers).map(([key, value]: [string, string]) => ({ key, value })),
}
})
if (localProviders.value.length === 0) {
localProviders.value.push({ url: '', headers: [{ key: '', value: '' }] })
}
}
function addProvider(index: number) {
localProviders.value.splice(index, 0, {
url: '',
headers: [{ key: '', value: '' }],
})
}
function removeProvider(index: number) {
localProviders.value.splice(index, 1)
}
function addHeader(pIndex: number, hIndex: number) {
localProviders.value[pIndex].headers.splice(hIndex, 0, { key: '', value: '' })
}
function removeHeader(pIndex: number, hIndex: number) {
localProviders.value[pIndex].headers.splice(hIndex, 1)
}
function handleConfirm() {
const result = localProviders.value
.filter(p => p.url.trim() !== '')
.map((p) => {
const headersObj = {}
p.headers.forEach((h) => {
if (h.key.trim()) {
headersObj[h.key.trim()] = h.value
}
})
return {
url: p.url.trim(),
headers: headersObj,
}
})
providerStore.updateProviders(result as Providers[])
goBack()
}
function goBack() {
uni.navigateBack()
}
onMounted(() => {
initLocalData()
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 头部导航 -->
<wd-navbar
:title="t('contextProviderDialog.title')"
safe-area-inset-top
left-arrow
:bordered="false"
@click-left="goBack"
>
<template #left>
<wd-icon name="arrow-left" size="18" />
</template>
</wd-navbar>
<view class="flex-1 overflow-y-auto px-[20rpx] pt-[20rpx]">
<view v-if="localProviders.length === 0" class="flex flex-col items-center justify-center gap-[30rpx] py-[100rpx]">
<text class="text-[28rpx] text-[#9d9ea3]">
{{ t('contextProviderDialog.noContextApi') }}
</text>
<wd-button type="primary" size="small" @click="addProvider(0)">
{{ t('contextProviderDialog.add') }}
</wd-button>
</view>
<view v-else class="flex flex-col">
<view v-for="(provider, pIndex) in localProviders" :key="pIndex" class="mb-[30rpx]">
<view class="flex-1 border border-l-[6rpx] border-[#eee] border-l-[#336cff] rounded-[16rpx] bg-[#fff] p-[20rpx] shadow-[0_4rpx_16rpx_rgba(0,0,0,0.05)]">
<view class="mb-[30rpx] flex items-center justify-between">
<view class="flex gap-[16rpx]">
<wd-button
type="icon"
icon="add"
size="small"
class="!h-[60rpx] !w-[60rpx] !bg-[#66b1ff] !text-[#fff]"
@click="addProvider(pIndex + 1)"
/>
<wd-button
type="icon"
icon="decrease"
size="small"
class="!h-[60rpx] !w-[60rpx] !bg-[#F56C6C] !text-[#fff]"
@click="removeProvider(pIndex)"
/>
</view>
</view>
<view class="mb-[40rpx] flex items-center gap-[20rpx]">
<text class="w-[140rpx] text-[28rpx] text-[#606266] font-semibold">
{{ t('contextProviderDialog.apiUrl') }}
</text>
<wd-input
v-model="provider.url"
class="flex-1"
:placeholder="t('contextProviderDialog.apiUrlPlaceholder')"
/>
</view>
<view class="flex items-start gap-[20rpx]">
<view class="mt-[6rpx] w-[140rpx] text-[28rpx] text-[#606266] font-semibold">
{{ t('contextProviderDialog.requestHeaders') }}
</view>
<view class="flex flex-1 flex-col gap-[20rpx] border border-[#dcdfe6] rounded-[12rpx] border-dashed bg-[#fcfcfc] p-[4rpx]">
<view
v-for="(header, hIndex) in provider.headers"
:key="hIndex"
class="flex flex-col gap-[16rpx] rounded-[12rpx] bg-[#fff]"
>
<view class="flex items-center gap-[8rpx]">
<wd-input
v-model="header.key"
placeholder="key"
class="w-full"
/>
</view>
<view class="flex items-center gap-[8rpx]">
<wd-input
v-model="header.value"
placeholder="value"
class="w-full"
/>
</view>
<view class="flex self-start gap-[12rpx]">
<wd-button
type="icon"
icon="add"
size="small"
class="!h-[50rpx] !w-[50rpx] !border-[1rpx] !border-solid !bg-[#ecf5ff] !text-[#b3d8ff]"
@click="addHeader(pIndex, hIndex + 1)"
/>
<wd-button
type="icon"
icon="decrease"
size="small"
class="!h-[50rpx] !w-[50rpx] !border-[1rpx] !border-solid !bg-[#fef0f0] !text-[#F56C6C]"
@click="removeHeader(pIndex, hIndex)"
/>
</view>
</view>
<view v-if="provider.headers.length === 0" class="flex items-center justify-center py-[20rpx] text-[26rpx] text-[#909399]">
<text class="mr-[16rpx]">
{{ t('contextProviderDialog.noHeaders') }}
</text>
<wd-button type="text" size="small" @click="addHeader(pIndex, 0)">
{{ t('contextProviderDialog.addHeader') }}
</wd-button>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="flex gap-[20rpx] border-t border-[#eee] px-[30rpx] py-[30rpx]">
<wd-button type="primary" class="h-[80rpx] flex-1 rounded-[12rpx] text-[28rpx]" @click="handleConfirm">
{{ t('contextProviderDialog.confirm') }}
</wd-button>
</view>
</view>
</template>
<style scoped lang="scss">
</style>
@@ -0,0 +1,153 @@
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "语音设置",
"navigationStyle": "custom"
}
}
</route>
<script lang="ts" setup>
import { t } from '@/i18n'
import { useSpeedPitch } from '@/store'
defineOptions({
name: 'SpeedPitch',
})
const speedPitchStore = useSpeedPitch()
const localSettings = ref({
ttsVolume: 0,
ttsRate: 0,
ttsPitch: 0,
})
function handleConfirm() {
speedPitchStore.updateSpeedPitch(localSettings.value)
goBack()
}
// 返回上一页并更新配置
function goBack() {
uni.navigateBack()
}
onMounted(() => {
localSettings.value = {
ttsVolume: speedPitchStore.speedPitch.ttsVolume,
ttsRate: speedPitchStore.speedPitch.ttsRate,
ttsPitch: speedPitchStore.speedPitch.ttsPitch,
}
})
</script>
<template>
<view class="h-screen flex flex-col bg-[#f5f7fb]">
<!-- 头部导航 -->
<wd-navbar
:title="t('agent.languageConfig')"
safe-area-inset-top
left-arrow
:bordered="false"
@click-left="goBack"
>
<template #left>
<wd-icon name="arrow-left" size="18" />
</template>
</wd-navbar>
<view class="flex flex-1 flex-col overflow-hidden">
<view class="flex flex-1 flex-col gap-[50rpx] overflow-y-auto px-[40rpx] py-[50rpx]">
<!-- 音量调节 -->
<view class="flex flex-col gap-[20rpx]">
<text class="text-[30rpx] text-[#232338] font-semibold">
{{ t('agent.ttsVolume') }}
</text>
<view class="flex items-center gap-[20rpx]">
<wd-slider
v-model="localSettings.ttsVolume"
:min="-100"
:max="100"
:step="1"
:show-value="false"
custom-class="voice-slider"
class="flex-1"
/>
<text class="min-w-[80rpx] text-right text-[28rpx] text-[#336cff] font-medium">
{{ localSettings.ttsVolume }}
</text>
</view>
<text class="mt-[10rpx] text-[24rpx] text-[#9d9ea3]">
{{ t('agent.volumeHint') }}
</text>
</view>
<!-- 语速调节 -->
<view class="flex flex-col gap-[20rpx]">
<text class="text-[30rpx] text-[#232338] font-semibold">
{{ t('agent.ttsRate') }}
</text>
<view class="flex items-center gap-[20rpx]">
<wd-slider
v-model="localSettings.ttsRate"
:min="-100"
:max="100"
:step="1"
:show-value="false"
custom-class="voice-slider"
class="flex-1"
/>
<text class="min-w-[80rpx] text-right text-[28rpx] text-[#336cff] font-medium">
{{ localSettings.ttsRate }}
</text>
</view>
<text class="mt-[10rpx] text-[24rpx] text-[#9d9ea3]">
{{ t('agent.speedHint') }}
</text>
</view>
<!-- 音调调节 -->
<view class="flex flex-col gap-[20rpx]">
<text class="text-[30rpx] text-[#232338] font-semibold">
{{ t('agent.ttsPitch') }}
</text>
<view class="flex items-center gap-[20rpx]">
<wd-slider
v-model="localSettings.ttsPitch"
:min="-100"
:max="100"
:step="1"
:show-value="false"
custom-class="voice-slider"
class="flex-1"
/>
<text class="min-w-[80rpx] text-right text-[28rpx] text-[#336cff] font-medium">
{{ localSettings.ttsPitch }}
</text>
</view>
<text class="mt-[10rpx] text-[24rpx] text-[#9d9ea3]">
{{ t('agent.pitchHint') }}
</text>
</view>
</view>
<view class="flex gap-[20rpx] border-t border-[#eee] px-[30rpx] py-[30rpx]">
<wd-button type="primary" class="h-[80rpx] flex-1 rounded-[12rpx] text-[28rpx] !bg-[#336cff]" @click="handleConfirm">
{{ t('agent.save') }}
</wd-button>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
/* 自定义滑块样式 */
:deep(.wd-slider) {
--wd-slider-bar-background: #e6ebff;
--wd-slider-bar-active-background: #336cff;
--wd-slider-thumb-border-color: #336cff;
--wd-slider-thumb-background: #336cff;
--wd-slider-thumb-size: 32rpx;
}
</style>
@@ -77,11 +77,6 @@ const areaCodeList = computed(() => {
return configStore.config.mobileAreaList || [{ name: '中国大陆', key: '+86' }]
})
// SM2公钥
const sm2PublicKey = computed(() => {
return configStore.config.sm2PublicKey
})
// 切换登录方式
function toggleLoginType() {
loginType.value = loginType.value === 'username' ? 'mobile' : 'username'
@@ -131,8 +126,6 @@ function generateUUID() {
})
}
let skipReLaunch = false // 全局或组件作用域
// 跳转至服务端设置页面
function goToServerSetting() {
uni.switchTab({
@@ -178,7 +171,8 @@ async function handleLogin() {
}
// 检查SM2公钥是否配置
if (!sm2PublicKey.value) {
const sm2PublicKey = configStore.config.sm2PublicKey
if (!sm2PublicKey) {
toast.warning(t('sm2.publicKeyNotConfigured'))
return
}
@@ -191,7 +185,7 @@ async function handleLogin() {
try {
// 拼接验证码和密码
const captchaAndPassword = formData.value.captcha + formData.value.password
encryptedPassword = sm2Encrypt(sm2PublicKey.value, captchaAndPassword)
encryptedPassword = sm2Encrypt(sm2PublicKey, captchaAndPassword)
}
catch (error) {
console.error('密码加密失败:', error)
@@ -1,18 +1,21 @@
<route lang="jsonc" type="page">{
<route lang="jsonc" type="page">
{
"layout": "default",
"style": {
"navigationBarTitleText": "设置",
"navigationStyle": "custom"
}
}</route>
}
</route>
<script lang="ts" setup>
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, t } from '@/i18n'
import type { Language } from '@/store/lang'
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
import { isMp } from '@/utils/platform'
import { computed, onMounted, reactive, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { changeLanguage, getCurrentLanguage, getSupportedLanguages, t } from '@/i18n'
import { useConfigStore } from '@/store'
import { clearServerBaseUrlOverride, getEnvBaseUrl, getServerBaseUrlOverride, setServerBaseUrlOverride } from '@/utils'
import { isMp } from '@/utils/platform'
defineOptions({
name: 'SettingsPage',
@@ -27,6 +30,8 @@ const cacheInfo = reactive({
dataCache: '0MB',
})
const configStore = useConfigStore()
// 服务端地址设置
const baseUrlInput = ref('')
const urlError = ref('')
@@ -81,19 +86,21 @@ async function testServerBaseUrl() {
const response = await uni.request({
url: `${baseUrlInput.value}/api/ping`,
method: 'GET',
timeout: 3000
timeout: 3000,
})
if (response.statusCode === 200) {
return true
} else {
}
else {
toast.error({
msg: t('message.invalidAddress'),
duration: 3000,
})
return false
}
} catch (error) {
}
catch (error) {
console.error('测试服务端地址失败:', error)
toast.error({
msg: t('message.invalidAddress'),
@@ -116,6 +123,20 @@ async function saveServerBaseUrl() {
return
}
setServerBaseUrlOverride(baseUrlInput.value)
// 处理config缓存无法更新的问题
uni.request({
url: `${getEnvBaseUrl()}/user/pub-config`,
method: 'GET',
success: (res) => {
if (res.statusCode === 200) {
configStore.setConfig(res.data.data)
uni.setStorageSync('config', res.data.data.sm2PubKey)
}
},
fail: (err) => {
console.error('获取SM2公钥失败:', err)
},
})
// 切换请求地址后清空所有缓存
clearAllCacheAfterUrlChange()