From 33ce80a6f0266a2fb613736137b84f2803ee1231 Mon Sep 17 00:00:00 2001 From: bob798 Date: Mon, 1 Jun 2026 14:37:56 +0800 Subject: [PATCH] =?UTF-8?q?fix(library):=20=E6=96=87=E7=AB=A0=E5=BA=93=20U?= =?UTF-8?q?RL=20=E5=AF=BC=E5=85=A5=E5=A4=B1=E8=B4=A5=E6=97=B6=E6=8A=8A?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=20detail=20=E6=8C=82=E5=88=B0=20err?= =?UTF-8?q?.detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 现象 /library 里粘贴反爬虫/SPA/无效 URL 导入时,后端正确返回 `{"detail":{"error":"fetch_failed","hint":"抓取失败,请改用粘贴正文模式"}}`, 但用户屏幕上看到的是一坨 JSON 串,且不会自动切到粘贴正文模式。 # 根因 authFetchJson 拿到对象型 detail 后 JSON.stringify 包进 new Error(detail) 抛出,调用方拿到的 err 只有 message(字符串),没有 .detail(对象)。 Library.vue 的友好降级分支 `typeof err.detail === 'object'` 永远 false, fetchFailHint / 自动切 markdown 模式 / "该页面无法自动抓取" 全部走不到。 # 修复 authFetchJson 在 raw 是对象时: - message 取 raw.hint || raw.error || JSON.stringify(raw)(仍向后兼容) - 把原对象挂到 err.detail,让调用方按业务字段降级 仅 Library.vue 一处用 .detail;其余 130+ 调用方只看 .message,行为不变。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../__tests__/useAuthFetch.spec.js | 28 +++++++++++++++++-- frontend/src/composables/useAuthFetch.js | 10 +++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/frontend/src/composables/__tests__/useAuthFetch.spec.js b/frontend/src/composables/__tests__/useAuthFetch.spec.js index cb5401d..370af8e 100644 --- a/frontend/src/composables/__tests__/useAuthFetch.spec.js +++ b/frontend/src/composables/__tests__/useAuthFetch.spec.js @@ -89,13 +89,37 @@ describe('useAuthFetch', () => { ) }) - it('对象 detail: stringify 而非 [object Object]', async () => { + it('对象 detail: hint/error 当作 message · 原对象挂到 err.detail', async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ detail: { error: 'fetch_failed', hint: '抓取失败,请改用粘贴正文模式' } }), + { status: 400 } + ) + ) + + const { authFetchJson } = useAuthFetch() + try { + await authFetchJson('/library/articles', { url: 'https://x.example' }) + throw new Error('should have thrown') + } catch (err) { + expect(err.message).toBe('抓取失败,请改用粘贴正文模式') + expect(err.detail).toEqual({ error: 'fetch_failed', hint: '抓取失败,请改用粘贴正文模式' }) + } + }) + + it('对象 detail 无 hint/error: 退回 JSON 串当 message · 仍挂 err.detail', async () => { fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ detail: { code: 'X', reason: 'Y' } }), { status: 500 }) ) const { authFetchJson } = useAuthFetch() - await expect(authFetchJson('/api/weird', {})).rejects.toThrow(/"code":"X"/) + try { + await authFetchJson('/api/weird', {}) + throw new Error('should have thrown') + } catch (err) { + expect(err.message).toMatch(/"code":"X"/) + expect(err.detail).toEqual({ code: 'X', reason: 'Y' }) + } }) it('network 错误路径: 透传 fetch 抛出的错误', async () => { diff --git a/frontend/src/composables/useAuthFetch.js b/frontend/src/composables/useAuthFetch.js index e75342d..32b658c 100644 --- a/frontend/src/composables/useAuthFetch.js +++ b/frontend/src/composables/useAuthFetch.js @@ -90,6 +90,7 @@ export function useAuthFetch() { }) if (!resp.ok) { let detail = `HTTP ${resp.status}` + let structured = null try { const body = await resp.json() const raw = body?.detail ?? body?.error @@ -100,12 +101,17 @@ export function useAuthFetch() { const parts = raw.map((d) => (d && typeof d === 'object' && d.msg) || JSON.stringify(d)) if (parts.length) detail = parts.join('; ') } else if (raw != null && typeof raw === 'object') { - detail = JSON.stringify(raw) + // 结构化 detail(如 {error, hint}):message 取 hint/error 作可读文案, + // 同时把原对象挂到 err.detail,让调用方能按业务字段降级处理 + structured = raw + detail = raw.hint || raw.error || JSON.stringify(raw) } } catch { /* ignore parse error */ } - throw new Error(detail) + const err = new Error(detail) + if (structured) err.detail = structured + throw err } return resp.json() }