@@ -12,6 +12,59 @@ function openApiSettings() {
1212 }
1313}
1414
15+ // 安全地解析 fetch 响应:当后端/反向代理返回 HTML(404/502/504/网关错误等)时
16+ // 不应抛出 "Unexpected token '<', '<html>...' is not valid JSON",而应返回带状态码的可读错误。
17+ async function safeReadResponse ( res ) {
18+ const contentType = ( res . headers . get ( 'content-type' ) || '' ) . toLowerCase ( ) ;
19+ // 识别 application/json 以及 RFC 6839 的结构化后缀(如 application/problem+json,
20+ // application/vnd.api+json 等),它们都是合法 JSON。
21+ const isJsonContentType = contentType . includes ( 'application/json' ) || / \+ j s o n ( \s * ; | \s * $ ) / . test ( contentType ) ;
22+ if ( isJsonContentType ) {
23+ try {
24+ return { data : await res . json ( ) , nonJson : false , text : '' } ;
25+ } catch ( _ ) {
26+ // Content-Type 声明 JSON 但解析失败,落到文本分支
27+ }
28+ }
29+ let text = '' ;
30+ try { text = await res . text ( ) ; } catch ( _ ) { text = '' ; }
31+ return { data : null , nonJson : true , text } ;
32+ }
33+
34+ function buildNonJsonError ( res , text ) {
35+ // 去除 HTML 标签并截断,避免把整段 HTML 报告给用户
36+ const snippet = text
37+ ? text . replace ( / < [ ^ > ] * > / g, ' ' ) . replace ( / \s + / g, ' ' ) . trim ( ) . slice ( 0 , 120 )
38+ : '' ;
39+ if ( window . t ) {
40+ if ( res . status === 404 ) {
41+ return window . t ( 'voice.serverRouteNotFound' , { status : res . status } ) ;
42+ }
43+ return window . t ( 'voice.serverNonJsonError' , {
44+ status : res . status ,
45+ snippet : snippet || res . statusText || ''
46+ } ) ;
47+ }
48+ if ( res . status === 404 ) {
49+ return `接口未找到 (HTTP 404),请确认服务端已正确部署并重启` ;
50+ }
51+ return `服务端返回了非JSON响应 (HTTP ${ res . status } )${ snippet ? ': ' + snippet : '' } ` ;
52+ }
53+
54+ // 把后端错误响应体转成可读消息:
55+ // 只有在 errors.<code> 翻译确实存在时才使用 i18n,否则回退到响应自带文案,
56+ // 避免 i18next 的「缺失 key 回退成 key 本身」行为把 "errors.XXX_UNKNOWN" 直接丢给用户。
57+ function resolveBackendErrorMsg ( data , status ) {
58+ if ( data && data . code && window . t ) {
59+ const i18nKey = 'errors.' + data . code ;
60+ const translated = window . t ( i18nKey , data . details || { } ) ;
61+ if ( translated && translated !== i18nKey ) {
62+ return translated ;
63+ }
64+ }
65+ return ( data && ( data . detail || data . message || data . error ) ) || `API returned ${ status } ` ;
66+ }
67+
1568function parseVoiceRegisterError ( errorObj ) {
1669 const errorCode = errorObj ?. code ;
1770 const errorMsg = errorObj ?. message || errorObj ?. error || errorObj || '' ;
@@ -436,11 +489,18 @@ function registerVoice() {
436489
437490 fetch ( apiUrl , requestOptions )
438491 . then ( async res => {
439- const data = await res . json ( ) ;
492+ const { data, nonJson , text } = await safeReadResponse ( res ) ;
440493 if ( ! res . ok ) {
441- // 从响应体中提取详细错误信息
442- const errorMsg = ( data . code && window . t ) ? window . t ( 'errors.' + data . code , data . details || { } ) : ( data . error || data . detail || `API returned ${ res . status } ` ) ;
443- throw new Error ( errorMsg ) ;
494+ if ( data ) {
495+ // 从响应体中提取详细错误信息(优先已翻译的 errors.<code>,缺失则回退到 message/detail/error)
496+ throw new Error ( resolveBackendErrorMsg ( data , res . status ) ) ;
497+ }
498+ // 后端/网关返回了 HTML(如 404/502/504),构造可读错误而不是 "Unexpected token '<'"
499+ throw new Error ( buildNonJsonError ( res , text ) ) ;
500+ }
501+ if ( nonJson ) {
502+ // 状态码 2xx 但响应体不是 JSON——不应发生,但仍优雅处理
503+ throw new Error ( buildNonJsonError ( res , text ) ) ;
444504 }
445505 return data ;
446506 } )
@@ -494,11 +554,18 @@ function registerVoice() {
494554 method : 'PUT' ,
495555 headers : { 'Content-Type' : 'application/json' } ,
496556 body : JSON . stringify ( { voice_id : data . voice_id } )
497- } ) . then ( resp => {
557+ } ) . then ( async resp => {
558+ const { data : respData , nonJson, text } = await safeReadResponse ( resp ) ;
498559 if ( ! resp . ok ) {
499- throw new Error ( `API returned ${ resp . status } ` ) ;
560+ if ( respData && ( respData . error || respData . detail ) ) {
561+ throw new Error ( respData . error || respData . detail ) ;
562+ }
563+ throw new Error ( buildNonJsonError ( resp , text ) ) ;
500564 }
501- return resp . json ( ) ;
565+ if ( nonJson ) {
566+ throw new Error ( buildNonJsonError ( resp , text ) ) ;
567+ }
568+ return respData ;
502569 } ) . then ( res => {
503570 if ( ! res . success ) {
504571 const errorMsg = res . error || ( window . t ? window . t ( 'common.unknownError' ) : '未知错误' ) ;
@@ -534,9 +601,13 @@ function registerVoice() {
534601 }
535602 }
536603 } ) . catch ( e => {
604+ // e 可能携带 safeReadResponse/buildNonJsonError 构造的可读错误
605+ // (含 HTTP 状态和正文摘要),必须拼进最终提示,否则诊断信息被吞。
606+ const saveErrorMsg = e ?. message || e ?. toString ( ) || ( window . t ? window . t ( 'common.unknownError' ) : '未知错误' ) ;
607+ const base = window . t ? window . t ( 'voice.voiceIdSaveRequestError' ) : 'voice_id自动保存请求出错' ;
537608 const errorSpan = document . createElement ( 'span' ) ;
538609 errorSpan . className = 'error' ;
539- errorSpan . textContent = ( window . t ? window . t ( 'voice.voiceIdSaveRequestError' ) : 'voice_id自动保存请求出错' ) ;
610+ errorSpan . textContent = saveErrorMsg ? ` ${ base } : ${ saveErrorMsg } ` : base ;
540611 resultDiv . appendChild ( document . createElement ( 'br' ) ) ;
541612 resultDiv . appendChild ( errorSpan ) ;
542613 } ) ;
@@ -590,10 +661,16 @@ async function playPreview(voiceId, btn) {
590661 if ( ! audioSrc ) {
591662 // 如果本地没有缓存,则从服务器获取
592663 const response = await fetch ( `/api/characters/voice_preview?voice_id=${ encodeURIComponent ( voiceId ) } ` ) ;
593- if ( response . status === 404 ) {
594- throw new Error ( 'API route not found (404). Please ensure the server has been restarted.' ) ;
664+ const { data, nonJson, text } = await safeReadResponse ( response ) ;
665+ if ( ! response . ok ) {
666+ if ( data && ( data . error || data . detail ) ) {
667+ throw new Error ( data . error || data . detail ) ;
668+ }
669+ throw new Error ( buildNonJsonError ( response , text ) ) ;
670+ }
671+ if ( nonJson ) {
672+ throw new Error ( buildNonJsonError ( response , text ) ) ;
595673 }
596- const data = await response . json ( ) ;
597674
598675 if ( data . success && data . audio ) {
599676 audioSrc = `data:${ data . mime_type || 'audio/mpeg' } ;base64,${ data . audio } ` ;
@@ -605,7 +682,7 @@ async function playPreview(voiceId, btn) {
605682 // localStorage 可能满了,但我们仍然可以播放这一次生成的音频
606683 }
607684 } else {
608- const _errMsg = ( data . code && window . t ) ? window . t ( 'errors.' + data . code , data . details || { } ) : ( data . error || 'Failed to get preview' ) ;
685+ const _errMsg = resolveBackendErrorMsg ( data , response . status ) || 'Failed to get preview' ;
609686 throw new Error ( _errMsg ) ;
610687 }
611688 }
@@ -652,10 +729,16 @@ async function loadVoices() {
652729
653730 try {
654731 const response = await fetch ( '/api/characters/voices' ) ;
732+ const { data, nonJson, text } = await safeReadResponse ( response ) ;
655733 if ( ! response . ok ) {
656- throw new Error ( `API returned ${ response . status } ` ) ;
734+ if ( data && ( data . error || data . detail ) ) {
735+ throw new Error ( data . error || data . detail ) ;
736+ }
737+ throw new Error ( buildNonJsonError ( response , text ) ) ;
738+ }
739+ if ( nonJson ) {
740+ throw new Error ( buildNonJsonError ( response , text ) ) ;
657741 }
658- const data = await response . json ( ) ;
659742
660743 if ( ( ! data . voices || Object . keys ( data . voices ) . length === 0 ) &&
661744 ( ! data . free_voices || Object . keys ( data . free_voices ) . length === 0 ) ) {
@@ -860,7 +943,15 @@ async function deleteVoice(voiceId, voiceName) {
860943 headers : { 'Content-Type' : 'application/json' }
861944 } ) ;
862945
863- const data = await response . json ( ) ;
946+ const { data : parsed , nonJson, text } = await safeReadResponse ( response ) ;
947+ if ( ! response . ok && ! parsed ) {
948+ // 后端/网关返回了 HTML(如 404/502),抛出可读错误
949+ throw new Error ( buildNonJsonError ( response , text ) ) ;
950+ }
951+ if ( nonJson ) {
952+ throw new Error ( buildNonJsonError ( response , text ) ) ;
953+ }
954+ const data = parsed || { } ;
864955
865956 if ( response . ok && data . success ) {
866957 // 删除本地缓存的预览音频
0 commit comments