DuplexVoicePanel.vue 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. <script setup lang="ts">
  2. import { computed, onMounted, ref, watch } from 'vue'
  3. import AppIcon from '@/components/common/AppIcon.vue'
  4. import { getDuplexVoiceSettings, saveDuplexVoiceSettings, type DuplexVoice } from '@/api/agentManagement'
  5. const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
  6. const loading = ref(false)
  7. const busy = ref(false)
  8. const error = ref('')
  9. const notice = ref('')
  10. const hasApiKey = ref(false)
  11. const apiKey = ref('')
  12. const voices = ref<DuplexVoice[]>([])
  13. const currentVoiceId = ref('')
  14. const baseline = ref('')
  15. function snapshot(): string {
  16. return JSON.stringify({ voices: voices.value, currentVoiceId: currentVoiceId.value })
  17. }
  18. const dirty = computed(() => Boolean(apiKey.value.trim()) || (baseline.value !== '' && snapshot() !== baseline.value))
  19. watch(dirty, value => emit('dirty-change', value), { immediate: true })
  20. async function load() {
  21. loading.value = true; error.value = ''
  22. try {
  23. const settings = await getDuplexVoiceSettings()
  24. hasApiKey.value = settings.hasApiKey
  25. voices.value = settings.voices.map(voice => ({ ...voice }))
  26. currentVoiceId.value = settings.currentVoiceId
  27. apiKey.value = ''
  28. baseline.value = snapshot()
  29. } catch (cause) {
  30. error.value = cause instanceof Error ? cause.message : '双流语音设置加载失败'
  31. } finally {
  32. if (!baseline.value) baseline.value = snapshot()
  33. loading.value = false
  34. }
  35. }
  36. function addVoice() {
  37. let suffix = voices.value.length + 1
  38. while (voices.value.some(voice => voice.id === `voice-${suffix}`)) suffix += 1
  39. voices.value.push({ id: `voice-${suffix}`, name: `新音色 ${suffix}` })
  40. if (!currentVoiceId.value) currentVoiceId.value = voices.value[0]!.id
  41. }
  42. function removeVoice(index: number) {
  43. if (voices.value.length <= 1) { error.value = '至少保留一个音色'; return }
  44. const [removed] = voices.value.splice(index, 1)
  45. if (removed?.id === currentVoiceId.value) currentVoiceId.value = voices.value[0]!.id
  46. }
  47. function validate(): string {
  48. if (!voices.value.length) return '至少保留一个音色'
  49. const ids = new Set<string>()
  50. for (const voice of voices.value) {
  51. voice.id = voice.id.trim(); voice.name = voice.name.trim()
  52. if (!voice.id || !voice.name) return '音色 ID 和名称不能为空'
  53. if (voice.id.length > 200 || voice.name.length > 200) return '音色 ID 和名称不能超过 200 个字符'
  54. if (ids.has(voice.id)) return `音色 ID 重复:${voice.id}`
  55. ids.add(voice.id)
  56. }
  57. if (!ids.has(currentVoiceId.value)) currentVoiceId.value = voices.value[0]!.id
  58. return ''
  59. }
  60. async function save() {
  61. const validationError = validate()
  62. if (validationError) { error.value = validationError; return }
  63. busy.value = true; error.value = ''; notice.value = ''
  64. try {
  65. const result = await saveDuplexVoiceSettings({
  66. ...(apiKey.value.trim() ? { apiKey: apiKey.value.trim() } : {}),
  67. voices: voices.value,
  68. currentVoiceId: currentVoiceId.value,
  69. })
  70. hasApiKey.value = result.hasApiKey
  71. voices.value = result.voices.map(voice => ({ ...voice }))
  72. currentVoiceId.value = result.currentVoiceId
  73. apiKey.value = ''
  74. baseline.value = snapshot()
  75. notice.value = '双流语音设置已保存,iOS 可直接同步使用'
  76. } catch (cause) {
  77. error.value = cause instanceof Error ? cause.message : '双流语音设置保存失败'
  78. } finally {
  79. busy.value = false
  80. }
  81. }
  82. onMounted(() => { void load() })
  83. </script>
  84. <template>
  85. <section class="voice-panel" aria-label="双流语音">
  86. <div class="voice-heading"><div><h3>yaoyao 双流语音</h3><p>这是整个 yaoyao 安装共享的全局设置,不随当前 Agent 切换。</p></div><span>全局</span></div>
  87. <p v-if="error" class="voice-error" role="alert">{{ error }}</p><p v-else-if="notice" class="voice-notice" role="status">{{ notice }}</p>
  88. <p v-if="loading" class="voice-empty">正在读取语音设置…</p>
  89. <form v-else :aria-busy="busy" @submit.prevent="save">
  90. <label>API Key<input v-model="apiKey" type="password" :disabled="busy" :placeholder="hasApiKey ? '已保存;留空保持不变' : '输入双流语音 API Key'" autocomplete="new-password" /></label>
  91. <div class="voice-list-heading"><strong>音色列表</strong><button type="button" :disabled="busy || voices.length >= 100" @click="addVoice"><AppIcon name="plus" :size="13" />增加音色</button></div>
  92. <div class="voice-list">
  93. <div v-for="(voice, index) in voices" :key="index" class="voice-row">
  94. <label>音色 ID<input v-model="voice.id" :disabled="busy" maxlength="200" autocomplete="off" /></label><label>显示名称<input v-model="voice.name" :disabled="busy" maxlength="200" autocomplete="off" /></label><button class="remove-button" type="button" aria-label="删除音色" :disabled="busy || voices.length <= 1" @click="removeVoice(index)"><AppIcon name="trash" :size="14" /></button>
  95. </div>
  96. </div>
  97. <label>当前音色<select v-model="currentVoiceId" :disabled="busy"><option v-for="voice in voices" :key="voice.id" :value="voice.id">{{ voice.name || voice.id }}</option></select></label>
  98. <footer><button class="primary-button" type="submit" :disabled="busy || !voices.length">{{ busy ? '正在保存…' : '保存双流语音设置' }}</button></footer>
  99. </form>
  100. </section>
  101. </template>
  102. <style scoped>
  103. .voice-panel{display:grid;gap:18px}.voice-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.voice-heading h3{margin:0;font-size:16px}.voice-heading p{margin:6px 0 0;color:var(--text-muted);font-size:13px;line-height:1.55}.voice-heading>span{padding:3px 8px;border:1px solid var(--line);border-radius:999px;color:var(--text-muted);font-size:11px}.voice-error,.voice-notice,.voice-empty{margin:0;padding:11px 12px;border-radius:9px;font-size:13px}.voice-error{background:color-mix(in srgb,var(--danger) 8%,transparent);color:var(--danger)}.voice-notice{background:color-mix(in srgb,var(--success,#21845b) 9%,transparent);color:var(--success,#21845b)}.voice-empty{color:var(--text-muted);background:var(--surface-soft)}form{display:grid;gap:18px}label{display:grid;gap:7px;color:var(--text-secondary);font-size:13px;font-weight:650}input,select{width:100%;height:42px;box-sizing:border-box;padding:0 11px;border:1px solid var(--line);border-radius:9px;outline:0;background:var(--surface-raised);color:var(--text-primary);font:13px var(--font-ui)}input:focus,select:focus{border-color:var(--line-strong);box-shadow:0 0 0 3px var(--focus-ring)}.voice-list-heading{display:flex;align-items:center;justify-content:space-between}.voice-list-heading strong{font-size:14px}.voice-list-heading button,.primary-button{display:inline-flex;min-height:40px;align-items:center;justify-content:center;gap:7px;padding:0 13px;border:0;border-radius:9px;cursor:pointer;font-size:13px;font-weight:600}.voice-list-heading button{border:1px solid var(--line);background:var(--surface-raised);color:var(--text-secondary)}.primary-button{min-width:150px;background:var(--accent);color:var(--text-on-solid)}button:disabled{cursor:wait;opacity:.5}.voice-list{display:grid;gap:10px}.voice-row{display:grid;grid-template-columns:1fr 1fr 40px;align-items:end;gap:10px}.remove-button{display:grid;width:40px;height:42px;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--danger);cursor:pointer}.remove-button:hover{background:color-mix(in srgb,var(--danger) 7%,transparent)}footer{display:flex;justify-content:flex-end;padding-top:16px;border-top:1px solid var(--line)}@media(max-width:600px){.voice-row{grid-template-columns:1fr 40px}.voice-row label:first-child{grid-column:1/-1}.voice-row label:nth-child(2){grid-column:1}.remove-button{grid-column:2}.primary-button{width:100%}}
  104. </style>