fix(manager): align frontend and backend field contracts

This commit is contained in:
Tyke Chen
2026-07-24 10:21:05 +08:00
parent 27e57631a7
commit 18e5c47a06
20 changed files with 455 additions and 64 deletions
@@ -34,7 +34,7 @@ public class TimbreDataDTO {
@Schema(description = "排序") @Schema(description = "排序")
@Min(value = 0, message = "{sort.number}") @Min(value = 0, message = "{sort.number}")
private long sort; private Long sort;
@Schema(description = "对应 TTS 模型主键") @Schema(description = "对应 TTS 模型主键")
@NotBlank(message = "{timbre.ttsModelId.require}") @NotBlank(message = "{timbre.ttsModelId.require}")
@@ -3,6 +3,7 @@ package xiaozhi.modules.timbre.entity;
import java.util.Date; import java.util.Date;
import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
@@ -41,7 +42,8 @@ public class TimbreEntity {
private String referenceText; private String referenceText;
@Schema(description = "排序") @Schema(description = "排序")
private long sort; @TableField(updateStrategy = FieldStrategy.NOT_NULL)
private Long sort;
@Schema(description = "对应 TTS 模型主键") @Schema(description = "对应 TTS 模型主键")
private String ttsModelId; private String ttsModelId;
@@ -99,6 +99,9 @@ public class TimbreServiceImpl extends BaseServiceImpl<TimbreDao, TimbreEntity>
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void save(TimbreDataDTO dto) { public void save(TimbreDataDTO dto) {
isTtsModelId(dto.getTtsModelId()); isTtsModelId(dto.getTtsModelId());
if (dto.getSort() == null) {
dto.setSort(0L);
}
TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class); TimbreEntity timbreEntity = ConvertUtils.sourceToTarget(dto, TimbreEntity.class);
baseDao.insert(timbreEntity); baseDao.insert(timbreEntity);
} }
@@ -32,7 +32,7 @@ public class TimbreDetailsVO implements Serializable {
private String referenceText; private String referenceText;
@Schema(description = "排序") @Schema(description = "排序")
private long sort; private Long sort;
@Schema(description = "对应 TTS 模型主键") @Schema(description = "对应 TTS 模型主键")
private String ttsModelId; private String ttsModelId;
@@ -2,16 +2,20 @@ package xiaozhi.modules.timbre.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import xiaozhi.common.redis.RedisUtils; import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.timbre.dao.TimbreDao; import xiaozhi.modules.timbre.dao.TimbreDao;
import xiaozhi.modules.timbre.dto.TimbreDataDTO;
import xiaozhi.modules.timbre.entity.TimbreEntity; import xiaozhi.modules.timbre.entity.TimbreEntity;
import xiaozhi.modules.timbre.vo.TimbreDetailsVO;
import xiaozhi.modules.voiceclone.dao.VoiceCloneDao; import xiaozhi.modules.voiceclone.dao.VoiceCloneDao;
import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity; import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity;
@@ -54,4 +58,74 @@ class TimbreServiceImplTest {
assertNull(service.getDefaultLanguageById("voice-id")); assertNull(service.getDefaultLanguageById("voice-id"));
} }
@Test
void updateLeavesSortOutOfTheUpdateWhenRequestOmitsIt() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) ->
"voice-id".equals(entity.getId()) && entity.getSort() == null));
verify(redisUtils).delete("timbre:details:voice-id");
}
@Test
void updateUsesExplicitSortWithoutLoadingExistingTimbre() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreDataDTO dto = validTimbreData();
dto.setSort(0L);
service.update("voice-id", dto);
verify(timbreDao, never()).selectById("voice-id");
verify(timbreDao).updateById(argThat((TimbreEntity entity) -> entity.getSort() == 0L));
}
@Test
void saveDefaultsOmittedSortToZero() {
TimbreDao timbreDao = mock(TimbreDao.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), mock(RedisUtils.class));
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
service.save(validTimbreData());
verify(timbreDao).insert(argThat((TimbreEntity entity) ->
"测试音色".equals(entity.getName()) && entity.getSort() == 0L));
}
@Test
void getSupportsLegacyRowsWithNullSort() {
TimbreDao timbreDao = mock(TimbreDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
TimbreServiceImpl service = new TimbreServiceImpl(
timbreDao, mock(VoiceCloneDao.class), redisUtils);
ReflectionTestUtils.setField(service, "baseDao", timbreDao);
TimbreEntity entity = new TimbreEntity();
entity.setId("voice-id");
entity.setSort(null);
when(timbreDao.selectById("voice-id")).thenReturn(entity);
TimbreDetailsVO details = service.get("voice-id");
assertNull(details.getSort());
}
private TimbreDataDTO validTimbreData() {
TimbreDataDTO dto = new TimbreDataDTO();
dto.setLanguages("中文");
dto.setName("测试音色");
dto.setTtsModelId("TTS_Test");
dto.setTtsVoice("test-voice");
return dto;
}
} }
+1 -1
View File
@@ -69,7 +69,7 @@
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union", "build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs", "test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs src/pages/agent/components/voicePreviewUtils.test.mjs src/pages/device/deviceTimeUtils.test.mjs",
"openapi-ts-request": "openapi-ts", "openapi-ts-request": "openapi-ts",
"prepare": "git init && husky", "prepare": "git init && husky",
"lint": "eslint", "lint": "eslint",
+16 -2
View File
@@ -8,6 +8,7 @@ import type {
ModelOption, ModelOption,
PageData, PageData,
RoleTemplate, RoleTemplate,
TtsVoice,
} from './types' } from './types'
import { http } from '@/http/request/alova' import { http } from '@/http/request/alova'
@@ -89,7 +90,7 @@ export function deleteAgent(id: string) {
// 获取TTS音色列表 // 获取TTS音色列表
export function getTTSVoices(ttsModelId: string, voiceName: string = '') { export function getTTSVoices(ttsModelId: string, voiceName: string = '') {
return http.Get<{ id: string, name: string }[]>(`/models/${ttsModelId}/voices`, { return http.Get<TtsVoice[]>(`/models/${ttsModelId}/voices`, {
params: { params: {
voiceName, voiceName,
}, },
@@ -214,7 +215,7 @@ export function updateAgentTags(agentId: string, data) {
// 获取所有语言 // 获取所有语言
export function getAllLanguage(modelId: string) { export function getAllLanguage(modelId: string) {
return http.Get<{ id: string, name: string, languages: string }[]>(`/models/${modelId}/voices`, { return http.Get<TtsVoice[]>(`/models/${modelId}/voices`, {
meta: { meta: {
ignoreAuth: false, ignoreAuth: false,
toast: false, toast: false,
@@ -225,6 +226,19 @@ export function getAllLanguage(modelId: string) {
}) })
} }
/**
* 获取克隆音色的临时播放ID
* @param cloneId 克隆音色记录ID
*/
export function getVoiceCloneAudioId(cloneId: string) {
return http.Post<string>(`/voiceClone/audio/${cloneId}`, {}, {
meta: {
ignoreAuth: false,
toast: false,
},
})
}
// 获取智能体历史版本列表 // 获取智能体历史版本列表
export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) { export function getAgentSnapshots(agentId: string, params: AgentSnapshotPageParams) {
return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, { return http.Get<PageData<AgentSnapshot>>(`/agent/${agentId}/snapshots`, {
@@ -114,6 +114,14 @@ export interface CorrectWordFile {
wordCount?: number wordCount?: number
} }
export interface TtsVoice {
id: string
name: string
voiceDemo?: string | null
languages?: string | null
isClone?: boolean | null
}
// 角色模板数据类型 // 角色模板数据类型
export interface RoleTemplate { export interface RoleTemplate {
id: string id: string
+1 -1
View File
@@ -7,7 +7,7 @@ export interface Device {
id: string id: string
userId: string userId: string
macAddress: string macAddress: string
lastConnectedAt: string lastConnectedAtTimestamp: string | null
autoUpdate: number autoUpdate: number
board: string board: string
alias?: string alias?: string
@@ -0,0 +1,42 @@
/** @param {Record<string, any>} voice */
export function hasVoicePreview(voice) {
return Boolean(voice?.isClone || voice?.voiceDemo || voice?.voice_demo)
}
export function createVoicePreviewRequestGate() {
let sequence = 0
return {
begin() {
sequence += 1
return sequence
},
invalidate() {
sequence += 1
},
isCurrent(requestId) {
return requestId === sequence
},
}
}
/**
* @param {{ id: string, isClone?: boolean, voiceDemo?: string | null }} voice
* @param {(cloneId: string) => Promise<string>} getCloneAudioId
* @param {string} baseUrl
*/
export async function resolveVoicePreviewUrl(voice, getCloneAudioId, baseUrl) {
if (!voice?.isClone) {
return typeof voice?.voiceDemo === 'string' ? voice.voiceDemo : ''
}
if (!voice.id) {
return ''
}
const uuid = await getCloneAudioId(voice.id)
if (!uuid) {
return ''
}
return `${baseUrl.replace(/\/+$/, '')}/voiceClone/play/${encodeURIComponent(uuid)}`
}
@@ -0,0 +1,50 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './voicePreviewUtils.mjs'
test('keeps normal voice previews on their direct URL', async () => {
let cloneRequestCount = 0
const url = await resolveVoicePreviewUrl({
id: 'normal-voice',
isClone: false,
voiceDemo: 'https://cdn.example.test/normal.wav',
}, async () => {
cloneRequestCount += 1
return 'unused'
}, 'https://api.example.test')
assert.equal(url, 'https://cdn.example.test/normal.wav')
assert.equal(cloneRequestCount, 0)
})
test('uses the clone record id to obtain and construct a temporary play URL', async () => {
let requestedCloneId = ''
const url = await resolveVoicePreviewUrl({
id: 'clone-record-id',
isClone: true,
voiceDemo: 'provider-speaker-id-must-not-be-played',
}, async (cloneId) => {
requestedCloneId = cloneId
return 'temporary uuid'
}, 'https://api.example.test/')
assert.equal(requestedCloneId, 'clone-record-id')
assert.equal(url, 'https://api.example.test/voiceClone/play/temporary%20uuid')
})
test('shows a preview control for cloned voices even without voiceDemo', () => {
assert.equal(hasVoicePreview({ id: 'clone-record-id', isClone: true }), true)
assert.equal(hasVoicePreview({ id: 'normal-voice', isClone: false, voiceDemo: '' }), false)
})
test('invalidates an older request when the same voice is cancelled and retried', () => {
const gate = createVoicePreviewRequestGate()
const firstRequest = gate.begin()
gate.invalidate()
const retryRequest = gate.begin()
assert.equal(gate.isCurrent(firstRequest), false)
assert.equal(gate.isCurrent(retryRequest), true)
})
+83 -29
View File
@@ -1,12 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate } from '@/api/agent/types' import type { AgentDetail, ModelOption, PluginDefinition, RoleTemplate, TtsVoice } from '@/api/agent/types'
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, updateAgent } from '@/api/agent/agent' import { getAgentDetail, getAgentTags, getAllLanguage, getModelOptions, getPluginFunctions, getRoleTemplates, getVoiceCloneAudioId, updateAgent } from '@/api/agent/agent'
import { t } from '@/i18n' import { t } from '@/i18n'
import { usePluginStore, useProvider, useSpeedPitch } from '@/store' import { usePluginStore, useProvider, useSpeedPitch } from '@/store'
import { getEnvBaseUrl } from '@/utils'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue' import AgentSnapshotPanel from './components/AgentSnapshotPanel.vue'
import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs' import { filterTtsVoicesByLanguage, hasUsableTtsVoiceMetadata } from './components/agentSnapshotUtils.mjs'
import { createVoicePreviewRequestGate, hasVoicePreview, resolveVoicePreviewUrl } from './components/voicePreviewUtils.mjs'
defineOptions({ defineOptions({
name: 'AgentEdit', name: 'AgentEdit',
@@ -84,10 +86,20 @@ const modelOptions = ref<{
TTS: [], TTS: [],
}) })
interface VoiceOption {
id?: string
value: string
name: string
voiceDemo?: string | null
voice_demo?: string | null
isClone: boolean
train_status?: number
}
// 音色选项数据 // 音色选项数据
const voiceOptions = ref<any[]>([]) const voiceOptions = ref<VoiceOption[]>([])
// 保存完整的音色信息 // 保存完整的音色信息
const voiceDetails = ref<Record<string, any>>({}) const voiceDetails = ref<Record<string, TtsVoice>>({})
// 上报模式选项数据 // 上报模式选项数据
const reportOptions = [ const reportOptions = [
@@ -139,6 +151,7 @@ interface SnapshotRestoreContext {
// 音频播放相关 // 音频播放相关
const audioRef = ref<UniApp.InnerAudioContext | null>(null) const audioRef = ref<UniApp.InnerAudioContext | null>(null)
const playingVoiceId = ref<string>('') const playingVoiceId = ref<string>('')
const voicePreviewRequestGate = createVoicePreviewRequestGate()
// 使用插件store // 使用插件store
const pluginStore = usePluginStore() const pluginStore = usePluginStore()
@@ -513,8 +526,8 @@ interface TtsSelectionState {
languageTouched: boolean languageTouched: boolean
voiceTouched: boolean voiceTouched: boolean
optionsModelId: string optionsModelId: string
voiceOptions: any[] voiceOptions: VoiceOption[]
voiceDetails: Record<string, any> voiceDetails: Record<string, TtsVoice>
languageOptions: any[] languageOptions: any[]
displayNames: { displayNames: {
tts: string tts: string
@@ -565,7 +578,7 @@ function filterVoicesByLanguage(options: VoiceSelectionOptions = {}) {
return return
} }
const allVoices = Object.values(voiceDetails.value) as any[] const allVoices = Object.values(voiceDetails.value)
// 根据选中的语言筛选音色 // 根据选中的语言筛选音色
const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value) const filteredVoices = filterTtsVoicesByLanguage(allVoices, selectedTtsLanguage.value)
@@ -624,7 +637,7 @@ async function fetchAllLanguag(ttsModelId: string, options: VoiceSelectionOption
throw new Error('No TTS voice metadata is available') throw new Error('No TTS voice metadata is available')
} }
// 保存完整的音色信息 // 保存完整的音色信息
voiceDetails.value = res.reduce((acc, voice) => { voiceDetails.value = res.reduce<Record<string, TtsVoice>>((acc, voice) => {
acc[voice.id] = voice acc[voice.id] = voice
return acc return acc
}, {}) }, {})
@@ -863,44 +876,85 @@ function onPickerCancel(type: string) {
} }
// 播放音频 // 播放音频
function playAudio(voiceDemo: string, voiceId: string, event: Event) { async function playAudio(voice: VoiceOption, event: Event) {
event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框 event.stopPropagation() // 阻止事件冒泡,防止关闭下拉框
if (!voiceDemo) { if (!hasVoicePreview(voice)) {
return return
} }
// 如果正在播放同一个音频,则停止 // 如果正在播放同一个音频,则停止
if (playingVoiceId.value === voiceId) { if (playingVoiceId.value === voice.value) {
stopAudio() stopAudio()
return return
} }
// 停止之前的音频 // 停止之前的音频
stopAudio() stopAudio()
const requestId = voicePreviewRequestGate.begin()
playingVoiceId.value = voice.value
// 创建新的音频实例 try {
audioRef.value = uni.createInnerAudioContext() const audioUrl = await resolveVoicePreviewUrl({
audioRef.value.src = voiceDemo id: voice.value,
playingVoiceId.value = voiceId isClone: voice.isClone,
voiceDemo: voice.voiceDemo || voice.voice_demo,
}, getVoiceCloneAudioId, getEnvBaseUrl())
// 监听播放结束 // 用户可能在等待克隆音色临时地址时取消或切换了音色。
audioRef.value.onEnded(() => { if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
if (!audioUrl) {
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = ''
return
}
// 创建新的音频实例
const audio = uni.createInnerAudioContext()
audioRef.value = audio
audio.src = audioUrl
// 监听播放结束
audio.onEnded(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
playingVoiceId.value = ''
}
})
// 监听播放错误
audio.onError(() => {
if (
voicePreviewRequestGate.isCurrent(requestId)
&& audioRef.value === audio
&& playingVoiceId.value === voice.value
) {
toast.error(t('voiceprint.audioPlayFailed'))
playingVoiceId.value = ''
}
})
// 播放音频
audio.play()
}
catch (error) {
if (!voicePreviewRequestGate.isCurrent(requestId) || playingVoiceId.value !== voice.value) {
return
}
console.error('获取克隆音色试听地址失败:', error)
toast.error(t('voiceprint.getAudioFailed'))
playingVoiceId.value = '' playingVoiceId.value = ''
}) }
// 监听播放错误
audioRef.value.onError(() => {
toast.error('音频播放失败')
playingVoiceId.value = ''
})
// 播放音频
audioRef.value.play()
} }
// 停止音频 // 停止音频
function stopAudio() { function stopAudio() {
voicePreviewRequestGate.invalidate()
if (audioRef.value) { if (audioRef.value) {
audioRef.value.stop() audioRef.value.stop()
audioRef.value.destroy() audioRef.value.destroy()
@@ -1592,10 +1646,10 @@ onMounted(async () => {
class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]" class="flex items-center justify-between border-b border-[#f5f5f5] p-[32rpx] transition-all active:bg-[#f5f7fb]"
@click="onPickerConfirm('voiceprint', voice.value, voice.name)" @click="onPickerConfirm('voiceprint', voice.value, voice.name)"
> >
<text :class="`flex-1 text-[28rpx] text-[#232338] ${(voice.voiceDemo || voice.voice_demo) ? '' : 'text-center'}`"> <text :class="`flex-1 text-[28rpx] text-[#232338] ${hasVoicePreview(voice) ? '' : 'text-center'}`">
{{ voice.name }} {{ voice.name }}
</text> </text>
<view v-if="voice.voiceDemo || voice.voice_demo" class="ml-[20rpx]" @click.stop="playAudio(voice.voiceDemo || voice.voice_demo, voice.value, $event)"> <view v-if="hasVoicePreview(voice)" class="ml-[20rpx]" @click.stop="playAudio(voice, $event)">
<wd-icon <wd-icon
:name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'" :name="playingVoiceId === voice.value ? 'pause-circle' : 'play-circle'"
size="24px" size="24px"
@@ -0,0 +1,14 @@
/** @param {unknown} timestamp */
export function parseDeviceLastConnectedAtTimestamp(timestamp) {
if (typeof timestamp !== 'string' || !timestamp.trim()) {
return null
}
const milliseconds = Number(timestamp)
if (!Number.isFinite(milliseconds)) {
return null
}
const date = new Date(milliseconds)
return Number.isNaN(date.getTime()) ? null : date
}
@@ -0,0 +1,15 @@
/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
test('parses the backend Long timestamp serialized as a string', () => {
const timestamp = '1783689702000'
assert.equal(parseDeviceLastConnectedAtTimestamp(timestamp)?.getTime(), Number(timestamp))
})
test('rejects missing and malformed device timestamps', () => {
assert.equal(parseDeviceLastConnectedAtTimestamp(null), null)
assert.equal(parseDeviceLastConnectedAtTimestamp(''), null)
assert.equal(parseDeviceLastConnectedAtTimestamp('not-a-timestamp'), null)
})
@@ -5,6 +5,7 @@ import { useMessage } from 'wot-design-uni/components/wd-message-box'
import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device' import { bindDevice, bindDeviceManual, getBindDevices, getFirmwareTypes, unbindDevice, updateDeviceAutoUpdate } from '@/api/device'
import { t } from '@/i18n' import { t } from '@/i18n'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { parseDeviceLastConnectedAtTimestamp } from './deviceTimeUtils.mjs'
defineOptions({ defineOptions({
name: 'DeviceManage', name: 'DeviceManage',
@@ -131,10 +132,10 @@ function getDeviceTypeName(boardKey: string): string {
} }
// 格式化时间 // 格式化时间
function formatTime(timeStr: string) { function formatTime(timestamp: string | null) {
if (!timeStr) const date = parseDeviceLastConnectedAtTimestamp(timestamp)
if (!date)
return t('device.neverConnected') return t('device.neverConnected')
const date = new Date(timeStr)
const now = new Date() const now = new Date()
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
@@ -410,7 +411,7 @@ defineExpose({
{{ t('device.firmwareVersion') }}{{ device.appVersion }} {{ t('device.firmwareVersion') }}{{ device.appVersion }}
</text> </text>
<text class="block text-[28rpx] text-[#65686f] leading-[1.4]"> <text class="block text-[28rpx] text-[#65686f] leading-[1.4]">
{{ t('device.lastConnection') }}{{ formatTime(device.lastConnectedAt) }} {{ t('device.lastConnection') }}{{ formatTime(device.lastConnectedAtTimestamp) }}
</text> </text>
</view> </view>
@@ -27,7 +27,7 @@ export default {
getFileList(params, callback) { getFileList(params, callback) {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page, page: params.page,
pageSize: params.pageSize limit: params.pageSize
}).toString(); }).toString();
RequestService.sendRequest() RequestService.sendRequest()
@@ -79,6 +79,7 @@ export default {
remark: params.remark, remark: params.remark,
referenceAudio: params.referenceAudio, referenceAudio: params.referenceAudio,
referenceText: params.referenceText, referenceText: params.referenceText,
sort: params.sort,
ttsModelId: params.ttsModelId, ttsModelId: params.ttsModelId,
ttsVoice: params.voiceCode, ttsVoice: params.voiceCode,
voiceDemo: params.voiceDemo || '' voiceDemo: params.voiceDemo || ''
@@ -141,23 +141,24 @@
<p class="section-desc">{{ $t('addressBookManagement.setPermissionDesc', { count: selectedPermissions.length }) }}</p> <p class="section-desc">{{ $t('addressBookManagement.setPermissionDesc', { count: selectedPermissions.length }) }}</p>
</div> </div>
<div class="section-actions"> <div class="section-actions">
<CustomButton size="small" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton> <CustomButton size="small" :disabled="permissionsLoading" @click="handleCancel">{{ $t('common.cancel') }}</CustomButton>
<CustomButton size="small" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton> <CustomButton size="small" :disabled="permissionsLoading" @click="handleToggleSelectAll">{{ isAllSelected ? $t('addressBookManagement.deselectAll') : $t('addressBookManagement.selectAll') }}</CustomButton>
<CustomButton type="confirm" size="small" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton> <CustomButton type="confirm" size="small" :disabled="permissionsLoading" @click="handleSavePermissions">{{ $t('addressBookManagement.save') }}</CustomButton>
</div> </div>
</div> </div>
<div class="permission-grid"> <div v-loading="permissionsLoading" class="permission-grid">
<div <div
v-for="device in allDevices" v-for="device in allDevices"
:key="device.id" :key="device.id"
class="permission-item" class="permission-item"
:class="{ active: selectedPermissions.includes(device.id) }" :class="{ active: selectedPermissions.includes(device.deviceId) }"
> >
<el-checkbox <el-checkbox
class="permission-checkbox" class="permission-checkbox"
:value="selectedPermissions.includes(device.id)" :disabled="permissionsLoading"
@change="(checked) => handlePermissionToggle(device.id, checked)" :value="selectedPermissions.includes(device.deviceId)"
@change="(checked) => handlePermissionToggle(device.deviceId, checked)"
></el-checkbox> ></el-checkbox>
<div class="permission-avatar"> <div class="permission-avatar">
<img :src="getDeviceAvatar(device.id)" alt="avatar" /> <img :src="getDeviceAvatar(device.id)" alt="avatar" />
@@ -229,7 +230,9 @@ export default {
editAgentNameValue: '', editAgentNameValue: '',
editingDeviceId: null, editingDeviceId: null,
editingDeviceName: '', editingDeviceName: '',
mqttServiceAvailable: false mqttServiceAvailable: false,
permissionRequestSequence: 0,
permissionsLoading: false
}; };
}, },
created() { created() {
@@ -363,18 +366,45 @@ export default {
this.loadAddressBookPermissions(device.deviceId); this.loadAddressBookPermissions(device.deviceId);
}, },
loadAddressBookPermissions(macAddress) { loadAddressBookPermissions(macAddress) {
const requestId = ++this.permissionRequestSequence;
this.permissionsLoading = true;
this.selectedPermissions = [];
this.originalPermissions = [];
this.editingDeviceId = null;
this.editingDeviceName = '';
this.allDevices.forEach(device => {
device.addressBookAlias = '';
});
AddressBookApi.getAddressBookList(macAddress, (res) => { AddressBookApi.getAddressBookList(macAddress, (res) => {
if (
requestId !== this.permissionRequestSequence ||
this.selectedDevice?.deviceId !== macAddress
) {
return;
}
this.permissionsLoading = false;
if (res.data?.code === 0) { if (res.data?.code === 0) {
const permissions = res.data.data || []; const permissions = res.data.data || [];
const permissionsByTargetMac = new Map(
permissions.map(permission => [
(permission.targetMac || '').toLowerCase(),
permission
])
);
// 设置已选择的权限 // 设置已选择的权限
this.selectedPermissions = permissions const permittedTargetMacs = new Set(
.filter(p => p.hasPermission) permissions
.map(p => p.targetMac); .filter(p => p.hasPermission)
.map(p => (p.targetMac || '').toLowerCase())
);
this.selectedPermissions = this.allDevices
.filter(device => permittedTargetMacs.has((device.deviceId || '').toLowerCase()))
.map(device => device.deviceId);
// 保存初始权限状态(用于对比变更) // 保存初始权限状态(用于对比变更)
this.originalPermissions = [...this.selectedPermissions]; this.originalPermissions = [...this.selectedPermissions];
// 更新设备的通讯录别名 // 更新设备的通讯录别名
this.allDevices.forEach(device => { this.allDevices.forEach(device => {
const addrBook = permissions.find(p => p.targetMac === device.deviceId); const addrBook = permissionsByTargetMac.get((device.deviceId || '').toLowerCase());
if (addrBook) { if (addrBook) {
device.addressBookAlias = addrBook.alias || ''; device.addressBookAlias = addrBook.alias || '';
} else { } else {
@@ -385,6 +415,7 @@ export default {
}); });
}, },
handleStartEditPermission(device) { handleStartEditPermission(device) {
if (this.permissionsLoading) return;
this.editingDeviceId = device.id; this.editingDeviceId = device.id;
this.editingDeviceName = device.addressBookAlias || device.name; this.editingDeviceName = device.addressBookAlias || device.name;
this.$nextTick(() => { this.$nextTick(() => {
@@ -412,13 +443,14 @@ export default {
this.editingDeviceId = null; this.editingDeviceId = null;
this.editingDeviceName = ''; this.editingDeviceName = '';
}, },
handlePermissionToggle(deviceId, checked) { handlePermissionToggle(targetMac, checked) {
if (this.permissionsLoading) return;
if (checked) { if (checked) {
if (!this.selectedPermissions.includes(deviceId)) { if (!this.selectedPermissions.includes(targetMac)) {
this.selectedPermissions.push(deviceId); this.selectedPermissions.push(targetMac);
} }
} else { } else {
const index = this.selectedPermissions.indexOf(deviceId); const index = this.selectedPermissions.indexOf(targetMac);
if (index > -1) { if (index > -1) {
this.selectedPermissions.splice(index, 1); this.selectedPermissions.splice(index, 1);
} }
@@ -428,21 +460,22 @@ export default {
if (this.isAllSelected) { if (this.isAllSelected) {
this.selectedPermissions = []; this.selectedPermissions = [];
} else { } else {
this.selectedPermissions = this.allDevices.map(d => d.id); this.selectedPermissions = this.allDevices.map(d => d.deviceId);
} }
}, },
handleCancel() { handleCancel() {
this.selectedPermissions = []; this.selectedPermissions = [];
}, },
handleSavePermissions() { handleSavePermissions() {
if (this.permissionsLoading) return;
const promises = this.allDevices const promises = this.allDevices
.filter(device => { .filter(device => {
const isNowSelected = this.selectedPermissions.includes(device.id); const isNowSelected = this.selectedPermissions.includes(device.deviceId);
const wasOriginallySelected = this.originalPermissions.includes(device.id); const wasOriginallySelected = this.originalPermissions.includes(device.deviceId);
return isNowSelected !== wasOriginallySelected; return isNowSelected !== wasOriginallySelected;
}) })
.map(device => { .map(device => {
const hasPermission = this.selectedPermissions.includes(device.id); const hasPermission = this.selectedPermissions.includes(device.deviceId);
return new Promise((resolve) => { return new Promise((resolve) => {
AddressBookApi.updatePermission({ AddressBookApi.updatePermission({
macAddress: this.selectedDevice.deviceId, macAddress: this.selectedDevice.deviceId,
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const addressBookSource = await readFile(
new URL('../src/views/AddressBookManagement.vue', import.meta.url),
'utf8',
);
const correctWordApiSource = await readFile(
new URL('../src/apis/module/correctWord.js', import.meta.url),
'utf8',
);
test('address-book permission state consistently uses the target device MAC', () => {
assert.match(
addressBookSource,
/:value="selectedPermissions\.includes\(device\.deviceId\)"/,
);
assert.match(
addressBookSource,
/@change="\(checked\) => handlePermissionToggle\(device\.deviceId, checked\)"/,
);
assert.match(
addressBookSource,
/this\.selectedPermissions = this\.allDevices\.map\(d => d\.deviceId\)/,
);
assert.match(
addressBookSource,
/this\.originalPermissions\.includes\(device\.deviceId\)/,
);
assert.doesNotMatch(
addressBookSource,
/selectedPermissions\.includes\(device\.id\)/,
);
assert.doesNotMatch(
addressBookSource,
/originalPermissions\.includes\(device\.id\)/,
);
assert.match(
addressBookSource,
/requestId !== this\.permissionRequestSequence/,
);
assert.match(
addressBookSource,
/this\.selectedDevice\?\.deviceId !== macAddress/,
);
assert.match(
addressBookSource,
/this\.permissionsLoading = true;\s*this\.selectedPermissions = \[\];\s*this\.originalPermissions = \[\];/,
);
assert.match(
addressBookSource,
/handleSavePermissions\(\) \{\s*if \(this\.permissionsLoading\) return;/,
);
});
test('correct-word pagination maps the UI page size to the backend limit query', () => {
assert.match(
correctWordApiSource,
/new URLSearchParams\(\{\s*page: params\.page,\s*limit: params\.pageSize\s*\}\)/,
);
assert.doesNotMatch(correctWordApiSource, /pageSize: params\.pageSize/);
});
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const timbreApiSource = await readFile(
new URL('../src/apis/module/timbre.js', import.meta.url),
'utf8',
);
test('timbre update sends the current sort value to the backend', () => {
const updateStart = timbreApiSource.indexOf('updateVoice(params, callback)');
assert.notEqual(updateStart, -1);
const updateSource = timbreApiSource.slice(updateStart);
assert.match(updateSource, /\.method\('PUT'\)/);
assert.match(updateSource, /sort:\s*params\.sort/);
});