From 84aa275bbb1ea36edb5a901eeb2c7c5875f81447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 16 Sep 2026 15:26:15 +0800 Subject: [PATCH 01/12] fix(rating): correct current user star display --- .../comment/components/rating/RatingItem.tsx | 3 ++- .../comment/components/rating/UserRatingForm.tsx | 16 +++++++++------- .../[id]/comment/components/rating/utils.test.ts | 12 ++++++++++++ .../[id]/comment/components/rating/utils.ts | 7 ++++++- 4 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.test.ts diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx index e7608153..a53813c7 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx @@ -17,6 +17,7 @@ import ReplyItem from './ReplyItem'; import type { RatingItemProps } from './types'; import { useSemDateTime } from '@/lib/utils/semdate'; import { useScript } from '../../../components/ScriptContext'; +import { scoreToRating } from './utils'; const { TextArea } = Input; @@ -84,7 +85,7 @@ export default function RatingItem({ } /> diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/UserRatingForm.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/UserRatingForm.tsx index 92d44cae..b11eaf8e 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/UserRatingForm.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/UserRatingForm.tsx @@ -15,7 +15,7 @@ import { useState } from 'react'; import { useUser } from '@/contexts/UserContext'; import LoadingBlock from '@/components/ui/LoadingBlock'; import type { UserRatingFormProps } from './types'; -import { getRatingText } from './utils'; +import { getRatingText, scoreToRating } from './utils'; import { useSemDateTime } from '@/lib/utils/semdate'; import { useTranslations } from 'next-intl'; @@ -35,7 +35,7 @@ export default function UserRatingForm({ const tLoading = useTranslations('components.loading'); const tRatingText = useTranslations('script.rating.text'); const [userRating, setUserRating] = useState( - existingRating ? existingRating.score / 10 : 0, + existingRating ? scoreToRating(existingRating.score) : 0, ); const [userComment, setUserComment] = useState( existingRating?.message ?? '', @@ -52,7 +52,7 @@ export default function UserRatingForm({ const [syncedRating, setSyncedRating] = useState(existingRating); if (existingRating !== syncedRating && !isEditing && !expanded) { setSyncedRating(existingRating); - setUserRating(existingRating ? existingRating.score / 10 : 0); + setUserRating(existingRating ? scoreToRating(existingRating.score) : 0); setUserComment(existingRating?.message ?? ''); } @@ -86,14 +86,14 @@ export default function UserRatingForm({ const handleEdit = () => { // 进编辑态时把当前评价灌回表单,否则编辑框里是初值(0 / 空)。 - setUserRating(existingRating ? existingRating.score / 10 : 0); + setUserRating(existingRating ? scoreToRating(existingRating.score) : 0); setUserComment(existingRating?.message ?? ''); setIsEditing(true); }; const handleCancel = () => { if (existingRating) { - setUserRating(existingRating.score / 10); + setUserRating(scoreToRating(existingRating.score)); setUserComment(existingRating.message); setIsEditing(false); } else { @@ -144,6 +144,8 @@ export default function UserRatingForm({ // 如果存在已有评分且不在编辑模式,显示已有评分 if (existingRating && !isEditing) { + const existingUserRating = scoreToRating(existingRating.score); + return (
@@ -186,13 +188,13 @@ export default function UserRatingForm({
} /> - {getRatingDescription(existingRating.score)} + {getRatingDescription(existingUserRating)}
diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.test.ts b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.test.ts new file mode 100644 index 00000000..6cf91947 --- /dev/null +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { scoreToRating } from './utils'; + +describe('scoreToRating', () => { + it.each([ + [10, 1], + [40, 4], + [50, 5], + ])('converts backend score %i to %i stars', (score, rating) => { + expect(scoreToRating(score)).toBe(rating); + }); +}); diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.ts b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.ts index 737d7311..6a60cc3d 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.ts +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/utils.ts @@ -1,5 +1,10 @@ import type { ScoreStateResponse } from '@/lib/api/services/scripts/scripts'; +/** Convert the backend's 10-50 score scale to the 1-5 star scale. */ +export function scoreToRating(score: number): number { + return score / 10; +} + /** * 获取评分对应的文本描述 * @param rating 评分数值 @@ -50,7 +55,7 @@ export function calculateRatingStats( // 填充分布数据 Object.entries(scoreState.score_group).forEach(([score, count]) => { - const scoreNum = parseInt(score) / 10; + const scoreNum = scoreToRating(parseInt(score)); if (scoreNum >= 1 && scoreNum <= 5) { distribution[scoreNum as keyof typeof distribution] = count; totalScore += scoreNum * count; From f65e977f935beb9069b6e84393d71fe4b5557454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 8 Oct 2026 15:58:27 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(upload):=20=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=BA=A6=E5=B9=B6?= =?UTF-8?q?=E6=94=BE=E5=AE=BD=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上传沿用 fetch + 20s 超时,慢速上行链路下几百 KB 的图片就会被 abort, 请求到不了源站、也没有任何提示。新增基于 XHR 的 apiClient.upload, 支持上传进度回调,默认超时 5 分钟;广告管理页上传按钮显示进度条。 --- .../advertise/components/AdvertiseClient.tsx | 48 ++++++- src/lib/api/client.ts | 55 ++++++++ src/lib/api/client.upload.test.ts | 126 ++++++++++++++++++ src/lib/api/config.ts | 2 + src/lib/api/services/resource.ts | 5 +- 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 src/lib/api/client.upload.test.ts diff --git a/src/app/[locale]/(main)/admin/advertise/components/AdvertiseClient.tsx b/src/app/[locale]/(main)/admin/advertise/components/AdvertiseClient.tsx index 8db11bc0..47143232 100644 --- a/src/app/[locale]/(main)/admin/advertise/components/AdvertiseClient.tsx +++ b/src/app/[locale]/(main)/admin/advertise/components/AdvertiseClient.tsx @@ -12,6 +12,7 @@ import { message, Modal, Popconfirm, + Progress, Segmented, Select, Spin, @@ -63,6 +64,11 @@ export default function AdvertiseClient() { const [editing, setEditing] = useState(null); const [lightUrl, setLightUrl] = useState(''); const [darkUrl, setDarkUrl] = useState(''); + // 上传进度(0-100),undefined 表示未在上传 + const [uploadProgress, setUploadProgress] = useState<{ + light?: number; + dark?: number; + }>({}); const [form] = Form.useForm(); const selectedSlots = (Form.useWatch('slot_keys', form) as string[] | undefined) ?? []; @@ -339,15 +345,29 @@ export default function AdvertiseClient() { } }; - const upload = async (file: File, setUrl: (u: string) => void) => { + const upload = async ( + file: File, + target: 'light' | 'dark', + setUrl: (u: string) => void, + ) => { + const setProgress = (percent?: number) => + setUploadProgress((prev) => ({ ...prev, [target]: percent })); + setProgress(0); try { - const resp = await resourceService.uploadImage(file, 'advertise', 0); + const resp = await resourceService.uploadImage( + file, + 'advertise', + 0, + setProgress, + ); setUrl(`${API_CONFIG.baseURL}/resource/image/${resp.id}`); message.success(t('upload_success')); } catch (err) { if (err instanceof APIError) { message.error(err.msg); } + } finally { + setProgress(undefined); } }; @@ -737,13 +757,21 @@ export default function AdvertiseClient() { showUploadList={false} maxCount={1} beforeUpload={(file) => { - upload(file as File, setLightUrl); + upload(file as File, 'light', setLightUrl); return false; }} > - + + {uploadProgress.light !== undefined && ( + + )} )} {fieldRequirements.lightImage && ( @@ -761,13 +789,21 @@ export default function AdvertiseClient() { showUploadList={false} maxCount={1} beforeUpload={(file) => { - upload(file as File, setDarkUrl); + upload(file as File, 'dark', setDarkUrl); return false; }} > - + + {uploadProgress.dark !== undefined && ( + + )} )} {fieldRequirements.linkUrl && ( diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 6683095d..1e29c6be 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -97,6 +97,15 @@ export interface RequestOptions extends RequestInit { params?: Record; } +/** + * 上传选项 + */ +export interface UploadOptions { + timeout?: number; + /** 上传进度回调,参数为 0-100 的百分比 */ + onProgress?: (percent: number) => void; +} + /** * API客户端类 */ @@ -339,6 +348,52 @@ class APIClient { }); } + /** + * 上传文件(仅客户端) + * fetch 无法获取上传进度,这里使用 XMLHttpRequest + */ + upload( + endpoint: string, + data: FormData, + options: UploadOptions = {}, + ): Promise { + const { timeout = API_CONFIG.uploadTimeout, onProgress } = options; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', this.buildURL(endpoint)); + xhr.withCredentials = true; + xhr.timeout = timeout; + + if (onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable && e.total > 0) { + onProgress(Math.round((e.loaded / e.total) * 100)); + } + }; + } + + xhr.onload = () => { + let resp: APIResponse; + try { + resp = JSON.parse(xhr.responseText); + } catch (error) { + reject(new APIError(xhr.status, -1, '解析响应数据失败', error)); + return; + } + if (resp.code !== 0) { + reject(new APIError(xhr.status, resp.code, resp.msg, resp)); + return; + } + resolve(resp.data as T); + }; + xhr.ontimeout = () => reject(new APIError(-1, 0, '请求超时')); + xhr.onerror = () => reject(new APIError(-1, 0, '网络错误')); + + xhr.send(data); + }); + } + /** * PUT请求 */ diff --git a/src/lib/api/client.upload.test.ts b/src/lib/api/client.upload.test.ts new file mode 100644 index 00000000..7ac6cb95 --- /dev/null +++ b/src/lib/api/client.upload.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiClient } from './client'; +import { APIError } from '@/types/api'; + +// fetch 拿不到上传进度,apiClient.upload 走 XMLHttpRequest;这里用一个最小的假 XHR 驱动各分支。 +class FakeXHR { + static last: FakeXHR; + method = ''; + url = ''; + body: unknown; + withCredentials = false; + timeout = 0; + status = 0; + responseText = ''; + upload: { onprogress: ((e: ProgressEvent) => void) | null } = { + onprogress: null, + }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + ontimeout: (() => void) | null = null; + + constructor() { + FakeXHR.last = this; + } + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + send(body: unknown) { + this.body = body; + } + + progress(loaded: number, total: number) { + this.upload.onprogress?.({ + lengthComputable: true, + loaded, + total, + } as ProgressEvent); + } + + respond(status: number, body: unknown) { + this.status = status; + this.responseText = typeof body === 'string' ? body : JSON.stringify(body); + this.onload?.(); + } +} + +describe('apiClient.upload', () => { + beforeEach(() => { + vi.stubGlobal('XMLHttpRequest', FakeXHR); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('POSTs the form data with credentials and resolves the response data', async () => { + const form = new FormData(); + form.append('comment', 'advertise'); + const p = apiClient.upload<{ id: string }>('/resource/image', form); + + const xhr = FakeXHR.last; + expect(xhr.method).toBe('POST'); + expect(xhr.url.endsWith('/resource/image')).toBe(true); + expect(xhr.withCredentials).toBe(true); + expect(xhr.body).toBe(form); + + xhr.respond(200, { code: 0, msg: 'ok', data: { id: 'abc' } }); + await expect(p).resolves.toEqual({ id: 'abc' }); + }); + + it('reports upload progress as a percentage', async () => { + const onProgress = vi.fn(); + const p = apiClient.upload('/resource/image', new FormData(), { + onProgress, + }); + + const xhr = FakeXHR.last; + xhr.progress(0, 200); + xhr.progress(50, 200); + xhr.progress(200, 200); + xhr.respond(200, { code: 0, msg: 'ok', data: {} }); + await p; + + expect(onProgress.mock.calls.map((c) => c[0])).toEqual([0, 25, 100]); + }); + + it('uses a longer timeout than regular API requests by default', () => { + apiClient.upload('/resource/image', new FormData()).catch(() => {}); + expect(FakeXHR.last.timeout).toBeGreaterThan(20000); + }); + + it('rejects with the backend message on a non-zero business code', async () => { + const p = apiClient.upload('/resource/image', new FormData()); + FakeXHR.last.respond(400, { code: 101001, msg: '图片过大', data: null }); + + await expect(p).rejects.toMatchObject({ + statusCode: 400, + code: 101001, + msg: '图片过大', + }); + }); + + it('rejects with APIError when the response is not JSON', async () => { + const p = apiClient.upload('/resource/image', new FormData()); + FakeXHR.last.respond(520, 'cloudflare'); + + await expect(p).rejects.toBeInstanceOf(APIError); + }); + + it('rejects with a timeout APIError', async () => { + const p = apiClient.upload('/resource/image', new FormData()); + FakeXHR.last.ontimeout?.(); + + await expect(p).rejects.toMatchObject({ msg: '请求超时' }); + }); + + it('rejects with a network APIError', async () => { + const p = apiClient.upload('/resource/image', new FormData()); + FakeXHR.last.onerror?.(); + + await expect(p).rejects.toBeInstanceOf(APIError); + }); +}); diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 00ee9a15..3b1956a9 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -8,6 +8,8 @@ export const API_CONFIG = { process.env.NEXT_PUBLIC_APP_URL + '/api/v2' || '', timeout: 20000, + // 上传走慢速上行链路时 20s 不够(几百 KB 就可能超时),且有进度条可见,放宽到 5 分钟 + uploadTimeout: 300000, defaultHeaders: { 'Content-Type': 'application/json', }, diff --git a/src/lib/api/services/resource.ts b/src/lib/api/services/resource.ts index e4c20335..125632c4 100644 --- a/src/lib/api/services/resource.ts +++ b/src/lib/api/services/resource.ts @@ -18,11 +18,13 @@ export class ResourceService { * @param image 图片文件 * @param comment 注释 * @param linkId 关联ID + * @param onProgress 上传进度回调(0-100) */ async uploadImage( image: Blob | File, comment: string, linkId: number, + onProgress?: (percent: number) => void, ): Promise { const formData = new FormData(); formData.append('image', image); @@ -30,9 +32,10 @@ export class ResourceService { formData.append('link_id', linkId.toString()); // 不要手动设置 Content-Type,让浏览器自动设置 multipart/form-data 和边界字符串 - return apiClient.post( + return apiClient.upload( `${this.basePath}/image`, formData, + { onProgress }, ); } } From 5d8e895ef1e3872c18bf781082895c57fe4a179c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 08:36:09 +0000 Subject: [PATCH 03/12] =?UTF-8?q?feat(report):=20=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E4=B8=BE=E6=8A=A5=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: https://gitea.icodef.com/scriptscat/scriptlist-frontend/pulls/27 Co-authored-by: 王一之 --- public/locales/de-DE/translations.json | 91 +++++++- public/locales/en-US/translations.json | 91 +++++++- public/locales/ja-JP/translations.json | 91 +++++++- public/locales/ru-RU/translations.json | 91 +++++++- public/locales/vi-VN/translations.json | 91 +++++++- public/locales/zh-CN/translations.json | 91 +++++++- public/locales/zh-TW/translations.json | 91 +++++++- .../components/ContentReportActions.test.tsx | 132 +++++++++++ .../components/ContentReportActions.tsx | 88 ++++++++ .../components/ContentReportDrawer.tsx | 208 +++++++++++++++++ .../components/ContentReportsPanel.tsx | 212 ++++++++++++++++++ .../reports/components/ReportsClient.tsx | 187 +++++---------- .../reports/components/ScriptReportsPanel.tsx | 147 ++++++++++++ .../reports/components/contentReportView.ts | 14 ++ .../reports/components/drawerActions.test.ts | 31 +++ .../admin/reports/components/drawerActions.ts | 20 ++ .../reports/components/reportTabs.test.ts | 47 ++++ .../admin/reports/components/reportTabs.ts | 26 +++ .../[locale]/(main)/admin/reports/page.tsx | 9 +- .../comment/components/rating/RatingItem.tsx | 13 ++ .../components/IssueCommentClient.tsx | 30 +++ .../components/ReportDetailClient.tsx | 2 + src/components/ActionMenu/index.test.tsx | 63 ++++++ src/components/ActionMenu/index.tsx | 5 +- .../ContentReportModal/index.test.tsx | 156 +++++++++++++ src/components/ContentReportModal/index.tsx | 178 +++++++++++++++ .../ContentReportModal/validate.test.ts | 52 +++++ src/components/ContentReportModal/validate.ts | 41 ++++ src/lib/api/errorCodes.ts | 2 + src/lib/api/hooks/admin.test.tsx | 144 +++++++++++- src/lib/api/hooks/admin.ts | 81 ++++++- src/lib/api/services/admin.ts | 46 +++- src/lib/api/services/scripts/report.ts | 12 +- src/types/api.ts | 82 +++++++ 34 files changed, 2513 insertions(+), 152 deletions(-) create mode 100644 src/app/[locale]/(main)/admin/reports/components/ContentReportActions.test.tsx create mode 100644 src/app/[locale]/(main)/admin/reports/components/ContentReportActions.tsx create mode 100644 src/app/[locale]/(main)/admin/reports/components/ContentReportDrawer.tsx create mode 100644 src/app/[locale]/(main)/admin/reports/components/ContentReportsPanel.tsx create mode 100644 src/app/[locale]/(main)/admin/reports/components/ScriptReportsPanel.tsx create mode 100644 src/app/[locale]/(main)/admin/reports/components/contentReportView.ts create mode 100644 src/app/[locale]/(main)/admin/reports/components/drawerActions.test.ts create mode 100644 src/app/[locale]/(main)/admin/reports/components/drawerActions.ts create mode 100644 src/app/[locale]/(main)/admin/reports/components/reportTabs.test.ts create mode 100644 src/app/[locale]/(main)/admin/reports/components/reportTabs.ts create mode 100644 src/components/ActionMenu/index.test.tsx create mode 100644 src/components/ContentReportModal/index.test.tsx create mode 100644 src/components/ContentReportModal/index.tsx create mode 100644 src/components/ContentReportModal/validate.test.ts create mode 100644 src/components/ContentReportModal/validate.ts diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index 09e17473..bf237383 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Created at", "col_actions": "Actions", "action_view": "View", - "filter_status": "Filter by status" + "filter_status": "Filter by status", + "tab_script": "Skriptmeldungen", + "tab_content": "Inhaltsmeldungen", + "content": { + "status_all": "Alle", + "status_1": "Offen", + "status_2": "Bearbeitet", + "status_3": "Abgelehnt", + "type_all": "Alle Typen", + "type_score": "Bewertung", + "type_issue": "Feedback", + "type_issue_comment": "Feedback-Antwort", + "col_type": "Typ", + "col_summary": "Gemeldeter Inhalt", + "col_author": "Autor", + "col_times": "Anzahl", + "target_deleted": "(Inhalt gelöscht)", + "action_handle": "Bearbeiten", + "drawer_title": "Inhaltsmeldung #{id}", + "view_origin": "Am Originalort ansehen", + "published_at": "Veröffentlicht {time}", + "handler": "Bearbeitet von", + "handle_note": "Notiz", + "no_note": "(keine)", + "result_deleted": "Inhalt gelöscht", + "result_resolved": "Bearbeitet", + "result_rejected": "Abgelehnt", + "snapshot": "Gemeldeter Inhalt", + "records": "Meldungen ({count})", + "load_failed": "Laden fehlgeschlagen", + "retry": "Erneut versuchen", + "action_reject": "Ablehnen", + "action_resolve": "Als bearbeitet markieren", + "action_delete": "Inhalt löschen und abschließen", + "action_reopen": "Wieder öffnen", + "delete_confirm_title": "Diesen Inhalt löschen?", + "delete_confirm_desc": "Der Inhalt wird endgültig gelöscht und alle offenen Meldungen dazu werden abgeschlossen.", + "note_hint": "Nur für Admins sichtbar", + "note_placeholder": "Bearbeitungsnotiz (optional)", + "action_success": "Erledigt", + "action_failed": "Aktion fehlgeschlagen, bitte erneut versuchen" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "Editor wird geladen...", "placeholder": "Bitte Inhalt eingeben...", "uploading_image": "Bild wird hochgeladen..." + }, + "contentReport": { + "title_score": "Bewertung melden", + "title_issue": "Feedback melden", + "title_issue_comment": "Feedback-Antwort melden", + "quote_score": "Bewertung von {name}", + "quote_issue": "Feedback von {name}: „{title}“", + "quote_issue_comment": "Antwort von {name}", + "reason_label": "Meldegrund", + "reasons": { + "spam": { + "name": "Spam / Werbung", + "desc": "Eigenwerbung, Traffic-Lenkung, Flooding" + }, + "abuse": { + "name": "Beleidigung", + "desc": "Persönliche Angriffe, Diskriminierung, Belästigung" + }, + "porn": { + "name": "Pornografisch / vulgär", + "desc": "Sexuelle, gewalttätige oder verstörende Inhalte" + }, + "illegal": { + "name": "Rechtswidrig", + "desc": "Illegale Inhalte, Betrug, schädliche Links" + }, + "misleading": { + "name": "Manipulierte Bewertung / Falschinfo", + "desc": "Ohne Bezug zum Skript, gefälschte Bewertungen" + }, + "other": { + "name": "Sonstiges", + "desc": "Bitte in der Anmerkung erläutern" + } + }, + "note_label": "Zusätzliche Angaben", + "note_optional": "(optional)", + "note_required_mark": "(erforderlich)", + "note_placeholder": "Beschreibe das Problem", + "note_required_error": "Bei „Sonstiges“ bitte eine Erläuterung angeben", + "note_too_long_error": "Die Angaben dürfen höchstens 500 Zeichen lang sein", + "privacy": "Meldungen sind nur für Administratoren sichtbar. Die gemeldete Person erfährt nicht, wer gemeldet hat.", + "submit": "Meldung absenden", + "cancel": "Abbrechen", + "success": "Meldung gesendet. Danke, dass du die Community unterstützt.", + "duplicate": "Du hast diesen Inhalt bereits gemeldet. Die Administratoren kümmern sich bald darum.", + "not_found": "Der Inhalt existiert nicht oder wurde gelöscht", + "submit_failed": "Meldung konnte nicht gesendet werden, bitte später erneut versuchen" } }, "error": { diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index deff728f..21032585 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Created at", "col_actions": "Actions", "action_view": "View", - "filter_status": "Filter by status" + "filter_status": "Filter by status", + "tab_script": "Script reports", + "tab_content": "Content reports", + "content": { + "status_all": "All", + "status_1": "Pending", + "status_2": "Resolved", + "status_3": "Rejected", + "type_all": "All types", + "type_score": "Review", + "type_issue": "Feedback", + "type_issue_comment": "Feedback reply", + "col_type": "Type", + "col_summary": "Reported content", + "col_author": "Author", + "col_times": "Count", + "target_deleted": "(Content deleted)", + "action_handle": "Handle", + "drawer_title": "Content report #{id}", + "view_origin": "View in place", + "published_at": "Posted {time}", + "handler": "Handled by", + "handle_note": "Note", + "no_note": "(none)", + "result_deleted": "Content deleted", + "result_resolved": "Resolved", + "result_rejected": "Rejected", + "snapshot": "Reported content", + "records": "Reports ({count})", + "load_failed": "Failed to load", + "retry": "Retry", + "action_reject": "Reject", + "action_resolve": "Mark as resolved", + "action_delete": "Delete content and close", + "action_reopen": "Reopen", + "delete_confirm_title": "Delete this content?", + "delete_confirm_desc": "The content will be permanently deleted and all pending reports on it will be closed.", + "note_hint": "Visible to admins only", + "note_placeholder": "Handling note (optional)", + "action_success": "Done", + "action_failed": "Action failed, please try again" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "Loading editor...", "placeholder": "Please enter content...", "uploading_image": "Uploading image..." + }, + "contentReport": { + "title_score": "Report review", + "title_issue": "Report feedback", + "title_issue_comment": "Report feedback reply", + "quote_score": "Review by {name}", + "quote_issue": "Feedback by {name}: \"{title}\"", + "quote_issue_comment": "Reply by {name}", + "reason_label": "Reason", + "reasons": { + "spam": { + "name": "Spam / ads", + "desc": "Promotion, traffic diversion, flooding" + }, + "abuse": { + "name": "Abuse", + "desc": "Personal attacks, discrimination, harassment" + }, + "porn": { + "name": "Pornographic / vulgar", + "desc": "Sexual, violent or disturbing content" + }, + "illegal": { + "name": "Illegal", + "desc": "Illegal info, scams, malicious links" + }, + "misleading": { + "name": "Fake ratings / misleading", + "desc": "Unrelated to the script, fake reviews" + }, + "other": { + "name": "Other", + "desc": "Please explain in the note" + } + }, + "note_label": "Additional details", + "note_optional": "(optional)", + "note_required_mark": "(required)", + "note_placeholder": "Describe the problem", + "note_required_error": "Please provide details when choosing \"Other\"", + "note_too_long_error": "Details cannot exceed 500 characters", + "privacy": "Reports are only visible to administrators. The reported user will not know who reported them.", + "submit": "Submit report", + "cancel": "Cancel", + "success": "Report submitted. Thank you for helping keep the community healthy.", + "duplicate": "You have already reported this content. Administrators will handle it soon.", + "not_found": "The content does not exist or has been deleted", + "submit_failed": "Failed to submit the report, please try again later" } }, "error": { diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 72b43139..d730c9e6 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Created at", "col_actions": "Actions", "action_view": "View", - "filter_status": "Filter by status" + "filter_status": "Filter by status", + "tab_script": "スクリプトの通報", + "tab_content": "コンテンツの通報", + "content": { + "status_all": "すべて", + "status_1": "未処理", + "status_2": "処理済み", + "status_3": "却下", + "type_all": "すべての種類", + "type_score": "レビュー", + "type_issue": "フィードバック", + "type_issue_comment": "フィードバックへの返信", + "col_type": "種類", + "col_summary": "通報されたコンテンツ", + "col_author": "投稿者", + "col_times": "件数", + "target_deleted": "(コンテンツは削除されました)", + "action_handle": "対応", + "drawer_title": "コンテンツ通報 #{id}", + "view_origin": "元の場所で表示", + "published_at": "投稿日時 {time}", + "handler": "対応者", + "handle_note": "対応メモ", + "no_note": "(記入なし)", + "result_deleted": "コンテンツ削除済み", + "result_resolved": "処理済み", + "result_rejected": "却下", + "snapshot": "通報されたコンテンツ", + "records": "通報履歴({count})", + "load_failed": "読み込みに失敗しました", + "retry": "再試行", + "action_reject": "却下", + "action_resolve": "対応済みにする", + "action_delete": "コンテンツを削除して完了", + "action_reopen": "再オープン", + "delete_confirm_title": "このコンテンツを削除しますか?", + "delete_confirm_desc": "コンテンツは完全に削除され、このコンテンツに対する未処理の通報はすべて完了になります。", + "note_hint": "管理者のみ表示", + "note_placeholder": "対応メモ(任意)", + "action_success": "操作が完了しました", + "action_failed": "操作に失敗しました。もう一度お試しください" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "エディターを読み込み中...", "placeholder": "内容を入力してください...", "uploading_image": "画像をアップロード中..." + }, + "contentReport": { + "title_score": "レビューを通報", + "title_issue": "フィードバックを通報", + "title_issue_comment": "フィードバックへの返信を通報", + "quote_score": "{name} さんのレビュー", + "quote_issue": "{name} さんのフィードバック「{title}」", + "quote_issue_comment": "{name} さんの返信", + "reason_label": "通報の理由", + "reasons": { + "spam": { + "name": "スパム・広告", + "desc": "誘導、宣伝、連投" + }, + "abuse": { + "name": "誹謗中傷", + "desc": "個人攻撃、差別、嫌がらせ" + }, + "porn": { + "name": "性的・低俗", + "desc": "性的・暴力的・不快な内容" + }, + "illegal": { + "name": "違法・規約違反", + "desc": "違法情報、詐欺、悪意のあるリンク" + }, + "misleading": { + "name": "不正評価・虚偽内容", + "desc": "スクリプトと無関係、偽のレビュー" + }, + "other": { + "name": "その他", + "desc": "補足説明に記入してください" + } + }, + "note_label": "補足説明", + "note_optional": "(任意)", + "note_required_mark": "(必須)", + "note_placeholder": "問題の内容を記入してください", + "note_required_error": "「その他」を選んだ場合は説明を記入してください", + "note_too_long_error": "補足説明は 500 文字以内で入力してください", + "privacy": "通報内容は管理者のみに表示され、通報された人には誰が通報したか分かりません。", + "submit": "通報する", + "cancel": "キャンセル", + "success": "通報を送信しました。コミュニティへのご協力ありがとうございます。", + "duplicate": "このコンテンツはすでに通報済みです。管理者が対応しますのでお待ちください。", + "not_found": "コンテンツが存在しないか、削除されています", + "submit_failed": "通報の送信に失敗しました。しばらくしてからもう一度お試しください" } }, "error": { diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index 45c64bdd..c0167e95 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Created at", "col_actions": "Actions", "action_view": "View", - "filter_status": "Filter by status" + "filter_status": "Filter by status", + "tab_script": "Жалобы на скрипты", + "tab_content": "Жалобы на контент", + "content": { + "status_all": "Все", + "status_1": "Ожидает", + "status_2": "Обработано", + "status_3": "Отклонено", + "type_all": "Все типы", + "type_score": "Отзыв", + "type_issue": "Обращение", + "type_issue_comment": "Ответ на обращение", + "col_type": "Тип", + "col_summary": "Контент жалобы", + "col_author": "Автор", + "col_times": "Кол-во", + "target_deleted": "(Контент удалён)", + "action_handle": "Обработать", + "drawer_title": "Жалоба на контент #{id}", + "view_origin": "Открыть на месте", + "published_at": "Опубликовано {time}", + "handler": "Обработал", + "handle_note": "Примечание", + "no_note": "(не указано)", + "result_deleted": "Контент удалён", + "result_resolved": "Обработано", + "result_rejected": "Отклонено", + "snapshot": "Контент жалобы", + "records": "Жалобы ({count})", + "load_failed": "Не удалось загрузить", + "retry": "Повторить", + "action_reject": "Отклонить", + "action_resolve": "Отметить как обработанную", + "action_delete": "Удалить контент и закрыть", + "action_reopen": "Открыть снова", + "delete_confirm_title": "Удалить этот контент?", + "delete_confirm_desc": "Контент будет удалён безвозвратно, а все ожидающие жалобы на него будут закрыты.", + "note_hint": "Видно только администраторам", + "note_placeholder": "Заметка об обработке (необязательно)", + "action_success": "Готово", + "action_failed": "Не удалось выполнить действие, попробуйте ещё раз" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "Загрузка редактора...", "placeholder": "Пожалуйста, введите содержание...", "uploading_image": "Загрузка изображения..." + }, + "contentReport": { + "title_score": "Пожаловаться на отзыв", + "title_issue": "Пожаловаться на обратную связь", + "title_issue_comment": "Пожаловаться на ответ", + "quote_score": "Отзыв пользователя {name}", + "quote_issue": "Обращение пользователя {name}: «{title}»", + "quote_issue_comment": "Ответ пользователя {name}", + "reason_label": "Причина жалобы", + "reasons": { + "spam": { + "name": "Спам / реклама", + "desc": "Привлечение трафика, продвижение, флуд" + }, + "abuse": { + "name": "Оскорбления", + "desc": "Личные нападки, дискриминация, травля" + }, + "porn": { + "name": "Порнография / пошлость", + "desc": "Сексуальный, жестокий или неприятный контент" + }, + "illegal": { + "name": "Нарушение закона", + "desc": "Незаконная информация, мошенничество, вредоносные ссылки" + }, + "misleading": { + "name": "Накрутка / ложная информация", + "desc": "Не относится к скрипту, фальшивые отзывы" + }, + "other": { + "name": "Другое", + "desc": "Опишите в дополнительном комментарии" + } + }, + "note_label": "Дополнительное описание", + "note_optional": "(необязательно)", + "note_required_mark": "(обязательно)", + "note_placeholder": "Опишите проблему", + "note_required_error": "При выборе «Другое» необходимо добавить описание", + "note_too_long_error": "Описание не должно превышать 500 символов", + "privacy": "Жалобы видны только администраторам. Автор контента не узнает, кто пожаловался.", + "submit": "Отправить жалобу", + "cancel": "Отмена", + "success": "Жалоба отправлена. Спасибо, что помогаете поддерживать сообщество.", + "duplicate": "Вы уже отправляли жалобу на этот контент. Администраторы скоро её рассмотрят.", + "not_found": "Контент не существует или был удалён", + "submit_failed": "Не удалось отправить жалобу, попробуйте позже" } }, "error": { diff --git a/public/locales/vi-VN/translations.json b/public/locales/vi-VN/translations.json index e0779256..0bace6cb 100644 --- a/public/locales/vi-VN/translations.json +++ b/public/locales/vi-VN/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Thời gian tạo", "col_actions": "Thao tác", "action_view": "Xem", - "filter_status": "Lọc theo trạng thái" + "filter_status": "Lọc theo trạng thái", + "tab_script": "Báo cáo script", + "tab_content": "Báo cáo nội dung", + "content": { + "status_all": "Tất cả", + "status_1": "Chờ xử lý", + "status_2": "Đã xử lý", + "status_3": "Đã bác bỏ", + "type_all": "Tất cả loại", + "type_score": "Đánh giá", + "type_issue": "Phản hồi", + "type_issue_comment": "Trả lời phản hồi", + "col_type": "Loại", + "col_summary": "Nội dung bị báo cáo", + "col_author": "Tác giả", + "col_times": "Số lần", + "target_deleted": "(Nội dung đã bị xóa)", + "action_handle": "Xử lý", + "drawer_title": "Báo cáo nội dung #{id}", + "view_origin": "Xem tại vị trí gốc", + "published_at": "Đăng lúc {time}", + "handler": "Người xử lý", + "handle_note": "Ghi chú", + "no_note": "(không có)", + "result_deleted": "Đã xóa nội dung", + "result_resolved": "Đã xử lý", + "result_rejected": "Đã bác bỏ", + "snapshot": "Nội dung bị báo cáo", + "records": "Báo cáo ({count})", + "load_failed": "Tải thất bại", + "retry": "Thử lại", + "action_reject": "Từ chối", + "action_resolve": "Đánh dấu đã xử lý", + "action_delete": "Xóa nội dung và đóng", + "action_reopen": "Mở lại", + "delete_confirm_title": "Xóa nội dung này?", + "delete_confirm_desc": "Nội dung sẽ bị xóa vĩnh viễn và mọi báo cáo đang chờ xử lý về nội dung này sẽ được đóng.", + "note_hint": "Chỉ quản trị viên xem được", + "note_placeholder": "Ghi chú xử lý (tùy chọn)", + "action_success": "Hoàn tất", + "action_failed": "Thao tác thất bại, vui lòng thử lại" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "Đang tải trình soạn thảo...", "placeholder": "Vui lòng nhập nội dung...", "uploading_image": "Đang tải ảnh lên..." + }, + "contentReport": { + "title_score": "Báo cáo đánh giá", + "title_issue": "Báo cáo phản hồi", + "title_issue_comment": "Báo cáo trả lời phản hồi", + "quote_score": "Đánh giá của {name}", + "quote_issue": "Phản hồi của {name}: \"{title}\"", + "quote_issue_comment": "Trả lời của {name}", + "reason_label": "Lý do báo cáo", + "reasons": { + "spam": { + "name": "Spam / quảng cáo", + "desc": "Dẫn dắt người dùng, quảng bá, spam" + }, + "abuse": { + "name": "Xúc phạm, công kích", + "desc": "Công kích cá nhân, phân biệt đối xử, quấy rối" + }, + "porn": { + "name": "Khiêu dâm, thô tục", + "desc": "Nội dung khiêu dâm, bạo lực, gây khó chịu" + }, + "illegal": { + "name": "Vi phạm pháp luật", + "desc": "Thông tin bất hợp pháp, lừa đảo, liên kết độc hại" + }, + "misleading": { + "name": "Chấm điểm ảo / sai sự thật", + "desc": "Không liên quan đến script, đánh giá giả" + }, + "other": { + "name": "Khác", + "desc": "Cần ghi rõ trong phần mô tả bổ sung" + } + }, + "note_label": "Mô tả bổ sung", + "note_optional": "(không bắt buộc)", + "note_required_mark": "(bắt buộc)", + "note_placeholder": "Hãy mô tả vấn đề bạn gặp phải", + "note_required_error": "Vui lòng nhập mô tả khi chọn \"Khác\"", + "note_too_long_error": "Mô tả bổ sung không được vượt quá 500 ký tự", + "privacy": "Nội dung báo cáo chỉ quản trị viên xem được, người bị báo cáo sẽ không biết ai đã báo cáo.", + "submit": "Gửi báo cáo", + "cancel": "Hủy", + "success": "Đã gửi báo cáo, cảm ơn bạn đã giúp giữ gìn cộng đồng.", + "duplicate": "Bạn đã báo cáo nội dung này rồi, quản trị viên sẽ xử lý sớm.", + "not_found": "Nội dung không tồn tại hoặc đã bị xóa", + "submit_failed": "Gửi báo cáo thất bại, vui lòng thử lại sau" } }, "error": { diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 0e8db758..1276f809 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -328,7 +328,48 @@ "col_createtime": "创建时间", "col_actions": "操作", "action_view": "查看", - "filter_status": "筛选状态" + "filter_status": "筛选状态", + "tab_script": "脚本举报", + "tab_content": "内容举报", + "content": { + "status_all": "全部", + "status_1": "待处理", + "status_2": "已处理", + "status_3": "已驳回", + "type_all": "全部类型", + "type_score": "评价", + "type_issue": "反馈", + "type_issue_comment": "反馈回复", + "col_type": "类型", + "col_summary": "被举报内容", + "col_author": "内容作者", + "col_times": "次数", + "target_deleted": "(内容已删除)", + "action_handle": "处理", + "drawer_title": "内容举报 #{id}", + "view_origin": "在原位置查看", + "published_at": "发布于 {time}", + "handler": "处理人", + "handle_note": "处理备注", + "no_note": "(未填写)", + "result_deleted": "内容已删除", + "result_resolved": "已处理", + "result_rejected": "已驳回", + "snapshot": "被举报内容", + "records": "举报记录({count})", + "load_failed": "加载失败", + "retry": "重试", + "action_reject": "驳回", + "action_resolve": "标记已处理", + "action_delete": "删除内容并结案", + "action_reopen": "重新打开", + "delete_confirm_title": "确认删除该内容?", + "delete_confirm_desc": "内容将被删除且无法恢复,并且该内容的所有待处理举报会一并结案。", + "note_hint": "仅管理员可见", + "note_placeholder": "处理备注(选填)", + "action_success": "操作成功", + "action_failed": "操作失败,请重试" + } }, "oauth_apps": { "action_delete": "删除", @@ -812,6 +853,54 @@ "loading_editor": "加载编辑器中...", "placeholder": "请输入内容...", "uploading_image": "图片上传中..." + }, + "contentReport": { + "title_score": "举报评价", + "title_issue": "举报反馈", + "title_issue_comment": "举报反馈回复", + "quote_score": "{name} 的评价", + "quote_issue": "{name} 的反馈《{title}》", + "quote_issue_comment": "{name} 的回复", + "reason_label": "举报原因", + "reasons": { + "spam": { + "name": "垃圾广告", + "desc": "引流、推广、刷屏" + }, + "abuse": { + "name": "辱骂攻击", + "desc": "人身攻击、歧视、骚扰" + }, + "porn": { + "name": "色情低俗", + "desc": "色情、暴力、令人不适" + }, + "illegal": { + "name": "违法违规", + "desc": "违法信息、诈骗、恶意链接" + }, + "misleading": { + "name": "恶意刷分 / 不实内容", + "desc": "与脚本无关、虚假评价" + }, + "other": { + "name": "其他", + "desc": "需在补充说明中写明" + } + }, + "note_label": "补充说明", + "note_optional": "(选填)", + "note_required_mark": "(必填)", + "note_placeholder": "请描述你遇到的问题", + "note_required_error": "选择「其他」时请填写说明", + "note_too_long_error": "补充说明不能超过 500 字", + "privacy": "举报内容仅管理员可见,被举报人不会知道是谁举报的。", + "submit": "提交举报", + "cancel": "取消", + "success": "举报已提交,感谢你帮助维护社区", + "duplicate": "你已经举报过该内容,管理员会尽快处理", + "not_found": "内容不存在或已被删除", + "submit_failed": "举报提交失败,请稍后重试" } }, "error": { diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index 53f90a21..30ef0721 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -669,7 +669,48 @@ "col_createtime": "Created at", "col_actions": "Actions", "action_view": "View", - "filter_status": "Filter by status" + "filter_status": "Filter by status", + "tab_script": "腳本檢舉", + "tab_content": "內容檢舉", + "content": { + "status_all": "全部", + "status_1": "待處理", + "status_2": "已處理", + "status_3": "已駁回", + "type_all": "全部類型", + "type_score": "評價", + "type_issue": "回饋", + "type_issue_comment": "回饋回覆", + "col_type": "類型", + "col_summary": "被檢舉內容", + "col_author": "內容作者", + "col_times": "次數", + "target_deleted": "(內容已刪除)", + "action_handle": "處理", + "drawer_title": "內容檢舉 #{id}", + "view_origin": "在原位置查看", + "published_at": "發佈於 {time}", + "handler": "處理人", + "handle_note": "處理備註", + "no_note": "(未填寫)", + "result_deleted": "內容已刪除", + "result_resolved": "已處理", + "result_rejected": "已駁回", + "snapshot": "被檢舉內容", + "records": "檢舉記錄({count})", + "load_failed": "載入失敗", + "retry": "重試", + "action_reject": "駁回", + "action_resolve": "標記為已處理", + "action_delete": "刪除內容並結案", + "action_reopen": "重新開啟", + "delete_confirm_title": "確認刪除此內容?", + "delete_confirm_desc": "內容將被刪除且無法復原,該內容的所有待處理檢舉也會一併結案。", + "note_hint": "僅管理員可見", + "note_placeholder": "處理備註(選填)", + "action_success": "操作成功", + "action_failed": "操作失敗,請重試" + } } }, "auth": { @@ -812,6 +853,54 @@ "loading_editor": "載入編輯器中...", "placeholder": "請輸入內容...", "uploading_image": "圖片上傳中..." + }, + "contentReport": { + "title_score": "檢舉評價", + "title_issue": "檢舉回饋", + "title_issue_comment": "檢舉回饋回覆", + "quote_score": "{name} 的評價", + "quote_issue": "{name} 的回饋〈{title}〉", + "quote_issue_comment": "{name} 的回覆", + "reason_label": "檢舉原因", + "reasons": { + "spam": { + "name": "垃圾廣告", + "desc": "引流、推廣、洗版" + }, + "abuse": { + "name": "辱罵攻擊", + "desc": "人身攻擊、歧視、騷擾" + }, + "porn": { + "name": "色情低俗", + "desc": "色情、暴力、令人不適" + }, + "illegal": { + "name": "違法違規", + "desc": "違法資訊、詐騙、惡意連結" + }, + "misleading": { + "name": "惡意刷分 / 不實內容", + "desc": "與腳本無關、虛假評價" + }, + "other": { + "name": "其他", + "desc": "需在補充說明中寫明" + } + }, + "note_label": "補充說明", + "note_optional": "(選填)", + "note_required_mark": "(必填)", + "note_placeholder": "請描述你遇到的問題", + "note_required_error": "選擇「其他」時請填寫說明", + "note_too_long_error": "補充說明不能超過 500 字", + "privacy": "檢舉內容僅管理員可見,被檢舉人不會知道是誰檢舉的。", + "submit": "提交檢舉", + "cancel": "取消", + "success": "檢舉已提交,感謝你協助維護社群", + "duplicate": "你已經檢舉過該內容,管理員會盡快處理", + "not_found": "內容不存在或已被刪除", + "submit_failed": "檢舉提交失敗,請稍後重試" } }, "error": { diff --git a/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.test.tsx b/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.test.tsx new file mode 100644 index 00000000..46982221 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.test.tsx @@ -0,0 +1,132 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { APIError } from '@/types/api'; +import type { AdminContentReportDetail } from '@/types/api'; +import ContentReportActions from './ContentReportActions'; + +const handle = vi.fn(); +const reopen = vi.fn(); +const success = vi.fn(); +const error = vi.fn(); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock('@/lib/api/hooks/admin', () => ({ + useContentReportActions: () => ({ handle, reopen }), +})); +vi.mock('antd', async (orig) => ({ + ...(await orig>()), + message: { + success: (...a: unknown[]) => success(...a), + error: (...a: unknown[]) => error(...a), + }, +})); + +const detail = (status: number) => + ({ id: 9, status }) as unknown as AdminContentReportDetail; + +describe('ContentReportActions', () => { + beforeEach(() => { + // jsdom 没有 ResizeObserver,antd 浮层需要 + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.clearAllMocks(); + handle.mockResolvedValue(undefined); + reopen.mockResolvedValue(undefined); + }); + afterEach(cleanup); + + it('待处理:驳回带备注调用 handle,成功后提示并 onDone', async () => { + const onDone = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('action_reject')); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(handle).toHaveBeenCalledWith(9, { action: 'reject', note: '误报' }); + expect(success).toHaveBeenCalled(); + }); + + it('标记已处理调用 resolve', async () => { + render( + , + ); + fireEvent.click(screen.getByText('action_resolve')); + await waitFor(() => + expect(handle).toHaveBeenCalledWith(9, { action: 'resolve' }), + ); + }); + + it('删除需要二次确认:点击按钮本身不请求,确认后才 delete', async () => { + const onDone = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('action_delete')); + expect(handle).not.toHaveBeenCalled(); + const ok = await screen.findAllByText('action_delete'); + fireEvent.click(ok[ok.length - 1]); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(handle).toHaveBeenCalledWith(9, { action: 'delete', note: 'n' }); + }); + + it('失败:提示后端错误,不关闭抽屉(不调 onDone)', async () => { + handle.mockRejectedValue(new APIError(400, 112010, '该举报已处理')); + const onDone = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('action_reject')); + await waitFor(() => expect(error).toHaveBeenCalledWith('该举报已处理')); + expect(onDone).not.toHaveBeenCalled(); + expect(success).not.toHaveBeenCalled(); + }); + + it('非 APIError 失败回落通用提示', async () => { + handle.mockRejectedValue(new Error('boom')); + render( + , + ); + fireEvent.click(screen.getByText('action_reject')); + await waitFor(() => expect(error).toHaveBeenCalledWith('action_failed')); + }); + + it('已结案:只有重新打开', async () => { + const onDone = vi.fn(); + render(); + expect(screen.queryByText('action_reject')).toBeNull(); + expect(screen.queryByText('action_delete')).toBeNull(); + fireEvent.click(screen.getByText('action_reopen')); + await waitFor(() => expect(reopen).toHaveBeenCalledWith(9)); + await waitFor(() => expect(onDone).toHaveBeenCalled()); + }); + + it('请求进行中其余按钮禁用', async () => { + let release!: () => void; + handle.mockReturnValue(new Promise((r) => (release = r))); + render( + , + ); + fireEvent.click(screen.getByText('action_reject')); + await waitFor(() => + expect( + screen.getByText('action_resolve').closest('button'), + ).toBeDisabled(), + ); + release(); + }); +}); diff --git a/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.tsx b/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.tsx new file mode 100644 index 00000000..ef489628 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/ContentReportActions.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Popconfirm, message } from 'antd'; +import { useTranslations } from 'next-intl'; +import { useContentReportActions } from '@/lib/api/hooks/admin'; +import { APIError } from '@/types/api'; +import type { AdminContentReportDetail } from '@/types/api'; +import { + buildHandleBody, + drawerActionsFor, + type DrawerAction, +} from './drawerActions'; + +interface ContentReportActionsProps { + detail: AdminContentReportDetail; + /** 处理备注(由抽屉正文的输入框持有) */ + note: string; + /** 成功后调用:关闭抽屉 */ + onDone: () => void; +} + +/** 抽屉底部操作区:待处理 → 驳回 / 标记已处理 / 删除内容并结案;已结案 → 重新打开。 */ +export default function ContentReportActions({ + detail, + note, + onDone, +}: ContentReportActionsProps) { + const t = useTranslations('admin.reports.content'); + const actions = useContentReportActions(); + const [busy, setBusy] = useState(null); + + const run = async (action: DrawerAction) => { + setBusy(action); + try { + if (action === 'reopen') { + await actions.reopen(detail.id); + } else { + await actions.handle(detail.id, buildHandleBody(action, note)); + } + message.success(t('action_success')); + onDone(); + } catch (err) { + // 失败:抽屉与备注保持原样,只提示错误 + message.error(err instanceof APIError ? err.msg : t('action_failed')); + } finally { + setBusy(null); + } + }; + + const props = (action: DrawerAction) => ({ + loading: busy === action, + disabled: busy !== null && busy !== action, + }); + + return ( +
+ {drawerActionsFor(detail.status).map((action) => { + if (action === 'delete') { + return ( + run('delete')} + disabled={busy !== null} + > + + + ); + } + return ( + + ); + })} +
+ ); +} diff --git a/src/app/[locale]/(main)/admin/reports/components/ContentReportDrawer.tsx b/src/app/[locale]/(main)/admin/reports/components/ContentReportDrawer.tsx new file mode 100644 index 00000000..73c9a742 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/ContentReportDrawer.tsx @@ -0,0 +1,208 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { + Button, + Drawer, + Empty, + Input, + Rate, + Skeleton, + Tag, + Typography, +} from 'antd'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import { useAdminContentReport } from '@/lib/api/hooks/admin'; +import type { AdminContentReportDetail } from '@/types/api'; +import { CONTENT_REPORT_NOTE_MAX } from './drawerActions'; +import { REPORT_STATUS_COLORS, TARGET_TYPE_COLORS } from './contentReportView'; +import { buildContentReportOriginHref } from './reportTabs'; + +interface ContentReportDrawerProps { + /** null = 抽屉关闭 */ + reportId: number | null; + onClose: () => void; + /** + * 操作区插槽(删除 / 标记已处理 / 驳回 / 重新打开按钮)。 + * 详情加载成功后渲染在抽屉底部;不传则无底部栏。 + */ + footer?: (detail: AdminContentReportDetail) => ReactNode; + /** 待处理时正文底部的处理备注输入(受控,由父组件持有以便 footer 读取) */ + note?: string; + onNoteChange?: (note: string) => void; +} + +const formatTime = (sec: number) => new Date(sec * 1000).toLocaleString(); + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +export default function ContentReportDrawer({ + reportId, + onClose, + footer, + note = '', + onNoteChange, +}: ContentReportDrawerProps) { + const t = useTranslations('admin.reports'); + const tReason = useTranslations('components.contentReport'); + const { data, isLoading, error, refresh } = useAdminContentReport(reportId); + + const body = (d: AdminContentReportDetail) => { + const closed = d.status !== 1; + const resultKey = + d.handle_result || (d.status === 2 ? 'resolved' : 'rejected'); + return ( + <> + + + {t(`content.type_${d.target_type}`)} + + {d.target_type === 'score' && ( + + )} + + + + {d.script_name || `#${d.script_id}`} + + {!d.target_deleted && ( + + {t('content.view_origin')} + + )} + + + + {d.author_name} + + + {t('content.published_at', { + time: formatTime(d.snapshot.createtime), + })} + + + + + {t(`content.status_${d.status}`)} + + {closed && {t(`content.result_${resultKey}`)}} + + {closed && ( + <> + + {d.handler_name} + + {formatTime(d.handletime)} + + + + {d.handle_note || t('content.no_note')} + + + )} + +

{t('content.snapshot')}

+ {/* 纯文本 + pre-wrap 保留换行;绝不当 HTML 渲染 */} +
+ {d.target_deleted && ( + + {t('content.target_deleted')} + + )} + {d.snapshot.title && ( +
{d.snapshot.title}
+ )} + {d.snapshot.content} +
+ +

+ {t('content.records', { count: d.reports.length })} +

+
    + {d.reports.map((r) => ( +
  • +
    + + {r.username} + + {tReason(`reasons.${r.reason}.name`)} + + {formatTime(r.createtime)} + +
    +
    + {r.content || ( + + {t('content.no_note')} + + )} +
    +
  • + ))} +
+ + {!closed && onNoteChange && ( + <> +

+ {t('content.handle_note')} + + {t('content.note_hint')} + +

+ onNoteChange(e.target.value)} + maxLength={CONTENT_REPORT_NOTE_MAX} + showCount + autoSize={{ minRows: 3, maxRows: 8 }} + placeholder={t('content.note_placeholder')} + /> + + )} + + ); + }; + + return ( + +
+ {isLoading && } + {!isLoading && error && ( +
+ + + +
+ )} + {data && body(data)} +
+
+ ); +} diff --git a/src/app/[locale]/(main)/admin/reports/components/ContentReportsPanel.tsx b/src/app/[locale]/(main)/admin/reports/components/ContentReportsPanel.tsx new file mode 100644 index 00000000..b9355d26 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/ContentReportsPanel.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Select, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import { ADMIN_PAGE_SIZE, useAdminContentReports } from '@/lib/api/hooks/admin'; +import type { + AdminContentReportItem, + ContentReportTargetType, +} from '@/types/api'; +import { useTableStateLocale } from '../../components/tableState'; +import ContentReportActions from './ContentReportActions'; +import ContentReportDrawer from './ContentReportDrawer'; +import { REPORT_STATUS_COLORS, TARGET_TYPE_COLORS } from './contentReportView'; + +/** 默认只看待处理 */ +const DEFAULT_STATUS = 1; + +export default function ContentReportsPanel() { + const t = useTranslations('admin.reports'); + const tReason = useTranslations('components.contentReport'); + const [page, setPage] = useState(1); + const [status, setStatus] = useState(DEFAULT_STATUS); + const [targetType, setTargetType] = useState( + '', + ); + const [activeId, setActiveId] = useState(null); + const [note, setNote] = useState(''); + + const openDrawer = (id: number | null) => { + setNote(''); + setActiveId(id); + }; + + const { + list: data, + total, + isLoading, + isRefreshing, + error, + refresh, + } = useAdminContentReports({ page, status, targetType }); + const tableLocale = useTableStateLocale({ + isLoading, + error, + onRetry: refresh, + }); + + const statusLabel = (s: number) => t(`content.status_${s}`); + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, + { + title: t('content.col_type'), + dataIndex: 'target_type', + key: 'target_type', + render: (type: ContentReportTargetType) => ( + {t(`content.type_${type}`)} + ), + }, + { + title: t('content.col_summary'), + key: 'summary', + width: 280, + render: (_: unknown, record) => + record.target_deleted ? ( + + {t('content.target_deleted')} + + ) : ( + + {record.summary} + + ), + }, + { + title: t('col_script'), + key: 'script', + render: (_: unknown, record) => ( + + {record.script_name || `#${record.script_id}`} + + ), + }, + { + title: t('content.col_author'), + key: 'author', + render: (_: unknown, record) => ( + + {record.author_name} + + ), + }, + { + title: t('col_reason'), + dataIndex: 'reason', + key: 'reason', + render: (reason: string) => ( + {tReason(`reasons.${reason}.name`)} + ), + }, + { + title: t('col_reporter'), + key: 'reporter', + render: (_: unknown, record) => ( + + {record.username} + + ), + }, + { + title: t('content.col_times'), + dataIndex: 'pending_count', + key: 'pending_count', + width: 70, + }, + { + title: t('col_status'), + dataIndex: 'status', + key: 'status', + render: (val: number) => ( + {statusLabel(val)} + ), + }, + { + title: t('col_createtime'), + dataIndex: 'createtime', + key: 'createtime', + render: (val: number) => new Date(val * 1000).toLocaleString(), + }, + { + title: t('col_actions'), + key: 'actions', + render: (_: unknown, record) => ( + + ), + }, + ]; + + return ( +
+
+ { + setTargetType(value); + setPage(1); + }} + style={{ width: 140 }} + options={[ + { value: '', label: t('content.type_all') }, + { value: 'score', label: t('content.type_score') }, + { value: 'issue', label: t('content.type_issue') }, + { value: 'issue_comment', label: t('content.type_issue_comment') }, + ]} + /> +
+ + + + openDrawer(null)} + note={note} + onNoteChange={setNote} + footer={(detail) => ( + openDrawer(null)} + /> + )} + /> + + ); +} diff --git a/src/app/[locale]/(main)/admin/reports/components/ReportsClient.tsx b/src/app/[locale]/(main)/admin/reports/components/ReportsClient.tsx index a377a969..7525df71 100644 --- a/src/app/[locale]/(main)/admin/reports/components/ReportsClient.tsx +++ b/src/app/[locale]/(main)/admin/reports/components/ReportsClient.tsx @@ -1,147 +1,62 @@ 'use client'; -import { useState } from 'react'; -import { Select, Table, Tag } from 'antd'; +import { useCallback, useState } from 'react'; +import { Badge, Tabs } from 'antd'; import { useTranslations } from 'next-intl'; -import type { AdminReportItem } from '@/lib/api/services/admin'; -import { useAdminReports } from '@/lib/api/hooks/admin'; -import type { ColumnsType } from 'antd/es/table'; -import { Link } from '@/i18n/routing'; -import { useTableStateLocale } from '../../components/tableState'; - -const REASON_COLORS: Record = { - malware: 'red', - privacy: 'orange', - copyright: 'purple', - spam: 'gold', - other: 'default', -}; +import { useAdminContentReportPendingCount } from '@/lib/api/hooks/admin'; +import ContentReportsPanel from './ContentReportsPanel'; +import ScriptReportsPanel from './ScriptReportsPanel'; +import { parseReportTab } from './reportTabs'; +import type { ReportTab } from './reportTabs'; + +interface ReportsClientProps { + /** 服务端从 `?tab` 读到的原始值 */ + initialTab?: string; +} -export default function ReportsClient() { +export default function ReportsClient({ initialTab }: ReportsClientProps) { const t = useTranslations('admin.reports'); - const tReport = useTranslations('script.report'); - const [page, setPage] = useState(1); - const [status, setStatus] = useState(undefined); - - const { - list: data, - total, - isLoading, - isRefreshing, - error, - refresh, - } = useAdminReports({ page, status }); - const tableLocale = useTableStateLocale({ - isLoading, - error, - onRetry: refresh, - }); - - const handleStatusChange = (value: number | undefined) => { - setStatus(value); - setPage(1); - }; - - const columns: ColumnsType = [ - { - title: 'ID', - dataIndex: 'id', - key: 'id', - width: 80, - }, - { - title: t('col_script'), - key: 'script', - render: (_: unknown, record: AdminReportItem) => ( - - {record.script_name || `#${record.script_id}`} - - ), - }, - { - title: t('col_reporter'), - key: 'reporter', - render: (_: unknown, record: AdminReportItem) => ( - - {record.username} - - ), - }, - { - title: t('col_reason'), - dataIndex: 'reason', - key: 'reason', - render: (reason: string) => ( - - {tReport(`reasons.${reason}`)} - - ), - }, - { - title: t('col_status'), - dataIndex: 'status', - key: 'status', - render: (val: number) => ( - - {val === 1 ? tReport('status_pending') : tReport('status_resolved')} - - ), - }, - { - title: t('col_comments'), - dataIndex: 'comment_count', - key: 'comment_count', - width: 80, - }, - { - title: t('col_createtime'), - dataIndex: 'createtime', - key: 'createtime', - render: (val: number) => new Date(val * 1000).toLocaleString(), - }, - { - title: t('col_actions'), - key: 'actions', - render: (_: unknown, record: AdminReportItem) => ( - - {t('action_view')} - - ), - }, - ]; + const [tab, setTab] = useState(parseReportTab(initialTab)); + const { count: pendingCount } = useAdminContentReportPendingCount(); + + // 不触发导航,只让刷新后停留在同一页签 + const handleTabChange = useCallback((key: string) => { + const next = parseReportTab(key); + setTab(next); + const url = new URL(window.location.href); + if (next === 'content') url.searchParams.set('tab', 'content'); + else url.searchParams.delete('tab'); + window.history.replaceState(null, '', url); + }, []); return (
-
-

{t('title')}

-
{t('title')} + , + }, + { + key: 'content', + label: ( + + {t('tab_content')} + + + ), + children: , + }, + ]} /> ); diff --git a/src/app/[locale]/(main)/admin/reports/components/ScriptReportsPanel.tsx b/src/app/[locale]/(main)/admin/reports/components/ScriptReportsPanel.tsx new file mode 100644 index 00000000..204c5ae1 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/ScriptReportsPanel.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import { Select, Table, Tag } from 'antd'; +import { useTranslations } from 'next-intl'; +import type { AdminReportItem } from '@/lib/api/services/admin'; +import { useAdminReports } from '@/lib/api/hooks/admin'; +import type { ColumnsType } from 'antd/es/table'; +import { Link } from '@/i18n/routing'; +import { useTableStateLocale } from '../../components/tableState'; + +const REASON_COLORS: Record = { + malware: 'red', + privacy: 'orange', + copyright: 'purple', + spam: 'gold', + other: 'default', +}; + +export default function ScriptReportsPanel() { + const t = useTranslations('admin.reports'); + const tReport = useTranslations('script.report'); + const [page, setPage] = useState(1); + const [status, setStatus] = useState(undefined); + + const { + list: data, + total, + isLoading, + isRefreshing, + error, + refresh, + } = useAdminReports({ page, status }); + const tableLocale = useTableStateLocale({ + isLoading, + error, + onRetry: refresh, + }); + + const handleStatusChange = (value: number | undefined) => { + setStatus(value); + setPage(1); + }; + + const columns: ColumnsType = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80, + }, + { + title: t('col_script'), + key: 'script', + render: (_: unknown, record: AdminReportItem) => ( + + {record.script_name || `#${record.script_id}`} + + ), + }, + { + title: t('col_reporter'), + key: 'reporter', + render: (_: unknown, record: AdminReportItem) => ( + + {record.username} + + ), + }, + { + title: t('col_reason'), + dataIndex: 'reason', + key: 'reason', + render: (reason: string) => ( + + {tReport(`reasons.${reason}`)} + + ), + }, + { + title: t('col_status'), + dataIndex: 'status', + key: 'status', + render: (val: number) => ( + + {val === 1 ? tReport('status_pending') : tReport('status_resolved')} + + ), + }, + { + title: t('col_comments'), + dataIndex: 'comment_count', + key: 'comment_count', + width: 80, + }, + { + title: t('col_createtime'), + dataIndex: 'createtime', + key: 'createtime', + render: (val: number) => new Date(val * 1000).toLocaleString(), + }, + { + title: t('col_actions'), + key: 'actions', + render: (_: unknown, record: AdminReportItem) => ( + + {t('action_view')} + + ), + }, + ]; + + return ( +
+
+
+ + ); +} diff --git a/src/app/[locale]/(main)/admin/reports/components/contentReportView.ts b/src/app/[locale]/(main)/admin/reports/components/contentReportView.ts new file mode 100644 index 00000000..6c58a766 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/contentReportView.ts @@ -0,0 +1,14 @@ +import type { ContentReportTargetType } from '@/types/api'; + +export const TARGET_TYPE_COLORS: Record = { + score: 'blue', + issue: 'purple', + issue_comment: 'cyan', +}; + +/** 1 待处理 | 2 已处理 | 3 已驳回 */ +export const REPORT_STATUS_COLORS: Record = { + 1: 'error', + 2: 'success', + 3: 'default', +}; diff --git a/src/app/[locale]/(main)/admin/reports/components/drawerActions.test.ts b/src/app/[locale]/(main)/admin/reports/components/drawerActions.test.ts new file mode 100644 index 00000000..4629f631 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/drawerActions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { + CONTENT_REPORT_NOTE_MAX, + buildHandleBody, + drawerActionsFor, +} from './drawerActions'; + +describe('drawerActionsFor', () => { + it('待处理:驳回 / 标记已处理 / 删除内容并结案', () => { + expect(drawerActionsFor(1)).toEqual(['reject', 'resolve', 'delete']); + }); + it('已处理 / 已驳回:只有重新打开', () => { + expect(drawerActionsFor(2)).toEqual(['reopen']); + expect(drawerActionsFor(3)).toEqual(['reopen']); + }); +}); + +describe('buildHandleBody', () => { + it('带上动作与去除首尾空白的备注', () => { + expect(buildHandleBody('delete', ' 违规广告 ')).toEqual({ + action: 'delete', + note: '违规广告', + }); + }); + it('备注为空白时不发送 note', () => { + expect(buildHandleBody('reject', ' ')).toEqual({ action: 'reject' }); + }); + it('备注上限 500', () => { + expect(CONTENT_REPORT_NOTE_MAX).toBe(500); + }); +}); diff --git a/src/app/[locale]/(main)/admin/reports/components/drawerActions.ts b/src/app/[locale]/(main)/admin/reports/components/drawerActions.ts new file mode 100644 index 00000000..76612b20 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/drawerActions.ts @@ -0,0 +1,20 @@ +import type { ContentReportHandleBody } from '@/types/api'; + +export type DrawerAction = 'reject' | 'resolve' | 'delete' | 'reopen'; + +/** 处理备注长度上限,与后端 `note` 校验一致。 */ +export const CONTENT_REPORT_NOTE_MAX = 500; + +/** 待处理(status=1)给出三个处理动作;已结案(已处理 / 已驳回)只能重新打开。 */ +export function drawerActionsFor(status: number): DrawerAction[] { + return status === 1 ? ['reject', 'resolve', 'delete'] : ['reopen']; +} + +/** 处理请求体:备注去首尾空白,空白则不发送。 */ +export function buildHandleBody( + action: ContentReportHandleBody['action'], + note: string, +): ContentReportHandleBody { + const trimmed = note.trim(); + return trimmed ? { action, note: trimmed } : { action }; +} diff --git a/src/app/[locale]/(main)/admin/reports/components/reportTabs.test.ts b/src/app/[locale]/(main)/admin/reports/components/reportTabs.test.ts new file mode 100644 index 00000000..2ddc683f --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/reportTabs.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { buildContentReportOriginHref, parseReportTab } from './reportTabs'; + +describe('parseReportTab', () => { + it('content 进内容举报页签', () => { + expect(parseReportTab('content')).toBe('content'); + }); + it('缺省 / 未知值回落到脚本举报', () => { + expect(parseReportTab(undefined)).toBe('script'); + expect(parseReportTab('')).toBe('script'); + expect(parseReportTab('foo')).toBe('script'); + expect(parseReportTab('script')).toBe('script'); + }); +}); + +describe('buildContentReportOriginHref', () => { + it('评价 → 脚本评分页', () => { + expect( + buildContentReportOriginHref({ + target_type: 'score', + script_id: 7, + issue_id: 0, + target_id: 3, + }), + ).toBe('/script-show-page/7/comment'); + }); + it('反馈 → 反馈详情', () => { + expect( + buildContentReportOriginHref({ + target_type: 'issue', + script_id: 7, + issue_id: 9, + target_id: 9, + }), + ).toBe('/script-show-page/7/issue/9'); + }); + it('反馈回复 → 反馈详情锚点到回复', () => { + expect( + buildContentReportOriginHref({ + target_type: 'issue_comment', + script_id: 7, + issue_id: 9, + target_id: 12, + }), + ).toBe('/script-show-page/7/issue/9#comment-12'); + }); +}); diff --git a/src/app/[locale]/(main)/admin/reports/components/reportTabs.ts b/src/app/[locale]/(main)/admin/reports/components/reportTabs.ts new file mode 100644 index 00000000..a6b23045 --- /dev/null +++ b/src/app/[locale]/(main)/admin/reports/components/reportTabs.ts @@ -0,0 +1,26 @@ +import type { AdminContentReportItem } from '@/types/api'; + +export type ReportTab = 'script' | 'content'; + +/** `?tab=content` 进内容举报;缺省或未知值回落到脚本举报。 */ +export function parseReportTab(tab: string | undefined): ReportTab { + return tab === 'content' ? 'content' : 'script'; +} + +/** 「在原位置查看」链接:评价 → 评分页,反馈 → 反馈详情,回复 → 反馈详情内锚点。 */ +export function buildContentReportOriginHref( + item: Pick< + AdminContentReportItem, + 'target_type' | 'script_id' | 'issue_id' | 'target_id' + >, +): string { + const base = `/script-show-page/${item.script_id}`; + switch (item.target_type) { + case 'score': + return `${base}/comment`; + case 'issue': + return `${base}/issue/${item.issue_id}`; + case 'issue_comment': + return `${base}/issue/${item.issue_id}#comment-${item.target_id}`; + } +} diff --git a/src/app/[locale]/(main)/admin/reports/page.tsx b/src/app/[locale]/(main)/admin/reports/page.tsx index 82a80329..44488deb 100644 --- a/src/app/[locale]/(main)/admin/reports/page.tsx +++ b/src/app/[locale]/(main)/admin/reports/page.tsx @@ -1,5 +1,10 @@ import ReportsClient from './components/ReportsClient'; -export default function ReportsPage() { - return ; +interface ReportsPageProps { + searchParams: Promise<{ tab?: string }>; +} + +export default async function ReportsPage({ searchParams }: ReportsPageProps) { + const { tab } = await searchParams; + return ; } diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx index a53813c7..a5a13c39 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/rating/RatingItem.tsx @@ -11,6 +11,7 @@ import { import { useState } from 'react'; import { useUser } from '@/contexts/UserContext'; import ActionMenu from '@/components/ActionMenu'; +import ContentReportModal from '@/components/ContentReportModal'; import { Link } from '@/i18n/routing'; import { useTranslations } from 'next-intl'; import ReplyItem from './ReplyItem'; @@ -35,6 +36,7 @@ export default function RatingItem({ // 回复是一次写请求,之前按钮只有 disabled={!content}, // 请求在飞的时候按钮仍然可点 —— 连点就会发出多条回复。 const [replying, setReplying] = useState(false); + const [reportOpen, setReportOpen] = useState(false); const semDateTime = useSemDateTime(); const handleReply = async () => { @@ -114,6 +116,7 @@ export default function RatingItem({ uid={rating.user_id} deleteLevel="super_moderator" allowSelfDelete={false} + onReportClick={() => setReportOpen(true)} onDeleteClick={async () => { await onDeleteRating(rating.id); }} @@ -123,6 +126,16 @@ export default function RatingItem({ + setReportOpen(false)} + scriptId={rating.script_id || script.id} + targetType="score" + targetId={rating.id} + authorName={rating.username} + quote={rating.message} + /> + {/* 评价内容 */} {rating.message && (
diff --git a/src/app/[locale]/(main)/script-show-page/[id]/issue/[issueId]/components/IssueCommentClient.tsx b/src/app/[locale]/(main)/script-show-page/[id]/issue/[issueId]/components/IssueCommentClient.tsx index 3f0815ef..f91f01cd 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/issue/[issueId]/components/IssueCommentClient.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/issue/[issueId]/components/IssueCommentClient.tsx @@ -57,6 +57,7 @@ const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { loading: () => , }); import ActionMenu from '@/components/ActionMenu'; +import ContentReportModal from '@/components/ContentReportModal'; import type { IssueComment } from '@/lib/api/services/scripts/issue'; import { scriptIssueService, @@ -126,6 +127,14 @@ export default function IssueCommentClient({ const t = useTranslations('script.issue.comment'); const tIssue = useTranslations('script.issue'); const [commentContent, setCommentContent] = useState(''); + // 举报弹窗:null 关闭;issue = 反馈主楼;number = 回复 id + const [reportTarget, setReportTarget] = useState<'issue' | number | null>( + null, + ); + const reportComment = + typeof reportTarget === 'number' + ? list.find((c) => c.id === reportTarget) + : undefined; // 使用 hook 获取关注状态 const { @@ -149,6 +158,25 @@ export default function IssueCommentClient({ return ( + setReportTarget(null)} + scriptId={scriptId} + targetType={reportTarget === 'issue' ? 'issue' : 'issue_comment'} + targetId={ + reportTarget === 'issue' ? issue.id : (reportComment?.id ?? 0) + } + issueId={issue.id} + authorName={ + reportTarget === 'issue' + ? issue.username + : (reportComment?.username ?? '') + } + quote={ + reportTarget === 'issue' ? issue.content : reportComment?.content + } + issueTitle={issue.title} + />
@@ -157,6 +185,7 @@ export default function IssueCommentClient({ uid={[issue.user_id, script.script?.user_id || -1]} deleteLevel="moderator" allowSelfDelete + onReportClick={() => setReportTarget('issue')} onDeleteClick={async () => { try { await scriptIssueService.deleteIssue( @@ -261,6 +290,7 @@ export default function IssueCommentClient({ ]} deleteLevel="moderator" allowSelfDelete + onReportClick={() => setReportTarget(item.id)} onDeleteClick={async () => { try { await scriptIssueService.deleteComment( diff --git a/src/app/[locale]/(main)/script-show-page/[id]/report/[reportId]/components/ReportDetailClient.tsx b/src/app/[locale]/(main)/script-show-page/[id]/report/[reportId]/components/ReportDetailClient.tsx index 5a7ae979..daa06bc0 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/report/[reportId]/components/ReportDetailClient.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/report/[reportId]/components/ReportDetailClient.tsx @@ -149,6 +149,7 @@ export default function ReportDetailClient({ { try { @@ -251,6 +252,7 @@ export default function ReportDetailClient({ script.script?.user_id || -1, ]} deleteLevel="admin" + hideReport allowSelfDelete={false} onDeleteClick={async () => { try { diff --git a/src/components/ActionMenu/index.test.tsx b/src/components/ActionMenu/index.test.tsx new file mode 100644 index 00000000..ee0e2f3d --- /dev/null +++ b/src/components/ActionMenu/index.test.tsx @@ -0,0 +1,63 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import zhCN from '../../../public/locales/zh-CN/translations.json'; +import ActionMenu from './index'; + +vi.mock('@/contexts/UserContext', () => ({ + useUser: () => ({ user: { user_id: 2, is_admin: 1 } }), +})); + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(cleanup); + +function open(hideReport?: boolean) { + render( + + {}} + hideReport={hideReport} + > + + + , + ); + fireEvent.mouseEnter(screen.getByText('more')); +} + +describe('ActionMenu 举报项', () => { + it('默认对非作者显示「举报」', async () => { + open(); + expect(await screen.findByText('举报')).toBeTruthy(); + }); + + it('hideReport 时不显示「举报」,其它项仍在', async () => { + open(true); + expect(await screen.findByText('删除')).toBeTruthy(); + expect(screen.queryByText('举报')).toBeNull(); + }); +}); diff --git a/src/components/ActionMenu/index.tsx b/src/components/ActionMenu/index.tsx index 38dbfcbd..b7a7dacf 100644 --- a/src/components/ActionMenu/index.tsx +++ b/src/components/ActionMenu/index.tsx @@ -30,6 +30,8 @@ export interface ActionMenuProps { // 举报 scriptId?: number; onReportClick?: () => void; + // 隐藏「举报」项(如仅管理员可见的举报详情页) + hideReport?: boolean; // 分享(移动端将分享收进更多菜单) onShareClick?: () => void; // AI 审核 @@ -48,6 +50,7 @@ const ActionMenu: React.FC = ({ onPunishClick, scriptId, onReportClick, + hideReport, allowAIReview, aiReviewLoading, onAIReviewClick, @@ -138,7 +141,7 @@ const ActionMenu: React.FC = ({ } } - if (!user.user || !authorMap.has(user.user.user_id)) { + if (!hideReport && (!user.user || !authorMap.has(user.user.user_id))) { items.push({ label: ( diff --git a/src/components/ContentReportModal/index.test.tsx b/src/components/ContentReportModal/index.test.tsx new file mode 100644 index 00000000..becf41e2 --- /dev/null +++ b/src/components/ContentReportModal/index.test.tsx @@ -0,0 +1,156 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import zhCN from '../../../public/locales/zh-CN/translations.json'; +import { APIError } from '@/types/api'; +import { scriptReportService } from '@/lib/api/services/scripts/report'; +import ContentReportModal from './index'; + +vi.mock('@/lib/api/services/scripts/report', () => ({ + scriptReportService: { createContentReport: vi.fn() }, +})); + +const create = vi.mocked(scriptReportService.createContentReport); + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + cleanup(); + create.mockReset(); +}); + +function setup( + target: Partial> = {}, +) { + const onClose = vi.fn(); + const onSubmitted = vi.fn(); + render( + + + , + ); + return { onClose, onSubmitted }; +} + +const submitBtn = () => screen.getByRole('button', { name: /提交举报/ }); + +describe('ContentReportModal', () => { + it('标题随对象变化并引用原文', () => { + setup(); + expect(screen.getByText('举报评价')).toBeTruthy(); + expect(screen.getByText('这是被举报的评价')).toBeTruthy(); + expect(screen.getAllByRole('radio')).toHaveLength(6); + }); + + it('引用行:反馈带《标题》,回复只写「作者名 的回复」', () => { + setup({ targetType: 'issue', authorName: 'bob', issueTitle: '无法安装' }); + expect(screen.getByText('bob 的反馈《无法安装》')).toBeTruthy(); + cleanup(); + setup({ + targetType: 'issue_comment', + issueId: 3, + authorName: 'carol', + issueTitle: '无法安装', + }); + expect(screen.getByText('carol 的回复')).toBeTruthy(); + }); + + it('未选原因时提交按钮禁用', () => { + setup(); + expect((submitBtn() as HTMLButtonElement).disabled).toBe(true); + }); + + it('选「其他」且说明为空:显示错误且不发请求', () => { + setup(); + fireEvent.click(screen.getByRole('radio', { name: /其他/ })); + fireEvent.click(submitBtn()); + expect(screen.getByText('选择「其他」时请填写说明')).toBeTruthy(); + expect(create).not.toHaveBeenCalled(); + }); + + it('成功:发送 trim 后的请求体并关闭', async () => { + create.mockResolvedValue({ id: 1 }); + const { onClose } = setup({ + targetType: 'issue_comment', + targetId: 5, + issueId: 3, + }); + fireEvent.click(screen.getByRole('radio', { name: /垃圾广告/ })); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: ' 刷屏 ' }, + }); + fireEvent.click(submitBtn()); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(create).toHaveBeenCalledWith(7, { + target_type: 'issue_comment', + target_id: 5, + issue_id: 3, + reason: 'spam', + content: '刷屏', + }); + }); + + it.each([ + [ + '重复举报', + new APIError(400, 112006, '你已经举报过该内容,管理员会尽快处理'), + ], + ['目标不存在', new APIError(404, 112007, '内容不存在')], + ['其它失败', new Error('network')], + ])('%s:弹窗保留且输入不丢', async (_n, err) => { + create.mockRejectedValue(err); + const { onClose } = setup(); + fireEvent.click(screen.getByRole('radio', { name: /辱骂攻击/ })); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: '保留我' }, + }); + fireEvent.click(submitBtn()); + await waitFor(() => expect(create).toHaveBeenCalled()); + await waitFor(() => + expect((submitBtn() as HTMLButtonElement).disabled).toBe(false), + ); + expect(onClose).not.toHaveBeenCalled(); + expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe( + '保留我', + ); + expect( + (screen.getByRole('radio', { name: /辱骂攻击/ }) as HTMLInputElement) + .checked, + ).toBe(true); + }); +}); diff --git a/src/components/ContentReportModal/index.tsx b/src/components/ContentReportModal/index.tsx new file mode 100644 index 00000000..5345164f --- /dev/null +++ b/src/components/ContentReportModal/index.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Input, Modal, Radio, message } from 'antd'; +import { useTranslations } from 'next-intl'; +import { ErrorCodes } from '@/lib/api/errorCodes'; +import { scriptReportService } from '@/lib/api/services/scripts/report'; +import type { ContentReportReason, ContentReportTargetType } from '@/types/api'; +import { + CONTENT_REPORT_REASONS, + NOTE_MAX_LENGTH, + countChars, + validateContentReport, +} from './validate'; + +export interface ContentReportModalProps { + open: boolean; + onClose: () => void; + scriptId: number; + targetType: ContentReportTargetType; + targetId: number; + /** targetType 为 issue_comment 时必填:回复所属反馈 */ + issueId?: number; + /** 被举报内容的作者名 */ + authorName: string; + /** 被举报内容的正文(引用块最多显示两行) */ + quote?: string; + /** targetType 为 issue / issue_comment 时的反馈标题 */ + issueTitle?: string; +} + +export default function ContentReportModal({ + open, + onClose, + scriptId, + targetType, + targetId, + issueId, + authorName, + quote, + issueTitle, +}: ContentReportModalProps) { + const t = useTranslations('components.contentReport'); + const [reason, setReason] = useState(); + const [note, setNote] = useState(''); + const [attempted, setAttempted] = useState(false); + const [submitting, setSubmitting] = useState(false); + + // 重新打开时从空白开始;失败时弹窗不关闭,输入因此得以保留 + useEffect(() => { + if (open) { + setReason(undefined); + setNote(''); + setAttempted(false); + } + }, [open]); + + const validation = validateContentReport({ reason, note }); + const errorKey = + validation.error === 'note_too_long' || attempted + ? validation.error + : undefined; + + const handleSubmit = async () => { + if (submitting || !reason) return; + setAttempted(true); + if (!validation.canSubmit) return; + setSubmitting(true); + try { + await scriptReportService.createContentReport(scriptId, { + target_type: targetType, + target_id: targetId, + ...(targetType === 'issue_comment' ? { issue_id: issueId } : {}), + reason, + content: note.trim() || undefined, + }); + message.success(t('success')); + onClose(); + } catch (error: any) { + if (error?.code === ErrorCodes.ReportDuplicatePending) { + message.warning(t('duplicate')); + } else if (error?.code === ErrorCodes.ReportTargetNotFound) { + message.error(t('not_found')); + } else { + message.error(error?.message || t('submit_failed')); + } + } finally { + setSubmitting(false); + } + }; + + return ( + +
+
+
+ {t(`quote_${targetType}`, { + name: authorName, + title: issueTitle ?? '', + })} +
+ {quote && ( +

+ {quote} +

+ )} +
+ +
+
{t('reason_label')}
+ setReason(e.target.value)} + className="!grid w-full grid-cols-1 gap-2 sm:grid-cols-2" + > + {CONTENT_REPORT_REASONS.map((key) => ( + + + {t(`reasons.${key}.name`)} + + + {t(`reasons.${key}.desc`)} + + + ))} + +
+ +
+
+ {t('note_label')} + + {reason === 'other' + ? t('note_required_mark') + : t('note_optional')} + +
+ setNote(e.target.value)} + rows={3} + placeholder={t('note_placeholder')} + status={errorKey ? 'error' : undefined} + count={{ + show: true, + max: NOTE_MAX_LENGTH, + strategy: countChars, + }} + /> + {errorKey && ( +
+ {t(`${errorKey}_error`)} +
+ )} +
+ +
+ {t('privacy')} +
+
+
+ ); +} diff --git a/src/components/ContentReportModal/validate.test.ts b/src/components/ContentReportModal/validate.test.ts new file mode 100644 index 00000000..0a924045 --- /dev/null +++ b/src/components/ContentReportModal/validate.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { NOTE_MAX_LENGTH, validateContentReport } from './validate'; + +describe('validateContentReport', () => { + it('未选原因不可提交', () => { + expect(validateContentReport({ reason: undefined, note: '' })).toEqual({ + canSubmit: false, + }); + }); + + it('选了普通原因且无说明可以提交', () => { + expect(validateContentReport({ reason: 'spam', note: '' })).toEqual({ + canSubmit: true, + }); + }); + + it('其他 + 空白说明:不可提交并提示必填', () => { + expect(validateContentReport({ reason: 'other', note: ' \n ' })).toEqual({ + canSubmit: false, + error: 'note_required', + }); + }); + + it('其他 + 有说明可以提交', () => { + expect(validateContentReport({ reason: 'other', note: ' 广告 ' })).toEqual({ + canSubmit: true, + }); + }); + + it('说明恰好 500 字可提交,501 字报错', () => { + expect( + validateContentReport({ + reason: 'spam', + note: 'a'.repeat(NOTE_MAX_LENGTH), + }).canSubmit, + ).toBe(true); + expect( + validateContentReport({ + reason: 'spam', + note: 'a'.repeat(NOTE_MAX_LENGTH + 1), + }), + ).toEqual({ canSubmit: false, error: 'note_too_long' }); + }); + + it('按字符(rune)而非 UTF-16 单元计数,trim 后计数', () => { + // 500 个 emoji = 1000 个 UTF-16 单元,但只有 500 个字符 + expect( + validateContentReport({ reason: 'spam', note: ' ' + '😀'.repeat(500) }) + .canSubmit, + ).toBe(true); + }); +}); diff --git a/src/components/ContentReportModal/validate.ts b/src/components/ContentReportModal/validate.ts new file mode 100644 index 00000000..924d7445 --- /dev/null +++ b/src/components/ContentReportModal/validate.ts @@ -0,0 +1,41 @@ +import type { ContentReportReason } from '@/types/api'; + +export const NOTE_MAX_LENGTH = 500; + +export const CONTENT_REPORT_REASONS: ContentReportReason[] = [ + 'spam', + 'abuse', + 'porn', + 'illegal', + 'misleading', + 'other', +]; + +export type ContentReportValidationError = 'note_required' | 'note_too_long'; + +export interface ContentReportValidation { + canSubmit: boolean; + error?: ContentReportValidationError; +} + +/** 按字符(与后端 rune 一致)计数,而不是 UTF-16 单元 */ +export function countChars(text: string): number { + return Array.from(text).length; +} + +export function validateContentReport(input: { + reason: ContentReportReason | undefined; + note: string; +}): ContentReportValidation { + if (!input.reason) { + return { canSubmit: false }; + } + const note = input.note.trim(); + if (input.reason === 'other' && note === '') { + return { canSubmit: false, error: 'note_required' }; + } + if (countChars(note) > NOTE_MAX_LENGTH) { + return { canSubmit: false, error: 'note_too_long' }; + } + return { canSubmit: true }; +} diff --git a/src/lib/api/errorCodes.ts b/src/lib/api/errorCodes.ts index cd2c843f..040119d0 100644 --- a/src/lib/api/errorCodes.ts +++ b/src/lib/api/errorCodes.ts @@ -1,4 +1,6 @@ export const ErrorCodes = { AccessNotFound: 10501, SimilarityIntegrityRejected: 114005, + ReportDuplicatePending: 112006, + ReportTargetNotFound: 112007, } as const; diff --git a/src/lib/api/hooks/admin.test.tsx b/src/lib/api/hooks/admin.test.tsx index 782815d9..4949ca61 100644 --- a/src/lib/api/hooks/admin.test.tsx +++ b/src/lib/api/hooks/admin.test.tsx @@ -6,11 +6,25 @@ import { SWRConfig } from 'swr'; import { swrConfig } from '@/lib/swr-config'; import { APIError } from '@/types/api'; import type { ListData } from '@/types/api'; -import { useAdminList, useAdminUsers } from './admin'; +import { + useAdminContentReport, + useContentReportActions, + useAdminContentReportPendingCount, + useAdminContentReports, + useAdminList, + useAdminUsers, +} from './admin'; import { adminService } from '@/lib/api/services/admin'; vi.mock('@/lib/api/services/admin', () => ({ - adminService: { listUsers: vi.fn() }, + adminService: { + listUsers: vi.fn(), + listContentReports: vi.fn(), + getContentReportPendingCount: vi.fn(), + getContentReport: vi.fn(), + handleContentReport: vi.fn(), + reopenContentReport: vi.fn(), + }, })); /** @@ -161,3 +175,129 @@ describe('useAdminUsers', () => { expect(adminService.listUsers).toHaveBeenCalledWith(1, 20, undefined); }); }); + +describe('内容举报 hooks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('useAdminContentReports 把筛选与分页传给 service,key 含参数', async () => { + vi.mocked(adminService.listContentReports).mockResolvedValue(listOf([], 0)); + const { result } = renderHook( + () => + useAdminContentReports({ + page: 2, + status: 1, + targetType: 'issue', + }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(adminService.listContentReports).toHaveBeenCalledWith({ + page: 2, + size: 20, + status: 1, + target_type: 'issue', + }); + }); + + it('useAdminContentReportPendingCount 返回 count', async () => { + vi.mocked(adminService.getContentReportPendingCount).mockResolvedValue({ + count: 5, + }); + const { result } = renderHook(() => useAdminContentReportPendingCount(), { + wrapper, + }); + await waitFor(() => expect(result.current.count).toBe(5)); + }); + + it('useAdminContentReport(null) 不发请求;有 id 才取详情', async () => { + vi.mocked(adminService.getContentReport).mockResolvedValue({ + id: 9, + } as never); + const { result, rerender } = renderHook( + ({ id }: { id: number | null }) => useAdminContentReport(id), + { wrapper, initialProps: { id: null as number | null } }, + ); + expect(adminService.getContentReport).not.toHaveBeenCalled(); + rerender({ id: 9 }); + await waitFor(() => expect(result.current.data?.id).toBe(9)); + expect(adminService.getContentReport).toHaveBeenCalledWith(9); + }); +}); + +describe('useContentReportActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + /** 同一个 provider 里挂上三个会被刷新的 key,验证处理后全部重新请求。 */ + function setup() { + vi.mocked(adminService.listContentReports).mockResolvedValue(listOf([])); + vi.mocked(adminService.getContentReportPendingCount).mockResolvedValue({ + count: 1, + }); + vi.mocked(adminService.getContentReport).mockResolvedValue({} as never); + return renderHook( + () => ({ + list: useAdminContentReports({ page: 1, status: 1 }), + badge: useAdminContentReportPendingCount(), + detail: useAdminContentReport(7), + actions: useContentReportActions(), + }), + { wrapper }, + ); + } + + it('handle 以 {action, note} 调用服务,并刷新列表 / 徽标 / 详情', async () => { + vi.mocked(adminService.handleContentReport).mockResolvedValue({}); + const { result } = setup(); + await waitFor(() => expect(result.current.detail.data).toBeDefined()); + vi.mocked(adminService.listContentReports).mockClear(); + vi.mocked(adminService.getContentReportPendingCount).mockClear(); + vi.mocked(adminService.getContentReport).mockClear(); + + await act(async () => { + await result.current.actions.handle(7, { action: 'resolve', note: 'x' }); + }); + + expect(adminService.handleContentReport).toHaveBeenCalledWith(7, { + action: 'resolve', + note: 'x', + }); + await waitFor(() => { + expect(adminService.listContentReports).toHaveBeenCalled(); + expect(adminService.getContentReportPendingCount).toHaveBeenCalled(); + expect(adminService.getContentReport).toHaveBeenCalled(); + }); + }); + + it('reopen 调用服务并刷新三处', async () => { + vi.mocked(adminService.reopenContentReport).mockResolvedValue({}); + const { result } = setup(); + await waitFor(() => expect(result.current.detail.data).toBeDefined()); + vi.mocked(adminService.getContentReportPendingCount).mockClear(); + + await act(async () => { + await result.current.actions.reopen(7); + }); + + expect(adminService.reopenContentReport).toHaveBeenCalledWith(7); + await waitFor(() => + expect(adminService.getContentReportPendingCount).toHaveBeenCalled(), + ); + }); + + it('失败时抛出原错误且不刷新', async () => { + const err = new APIError(400, 112010, '该举报已处理'); + vi.mocked(adminService.handleContentReport).mockRejectedValue(err); + const { result } = setup(); + await waitFor(() => expect(result.current.detail.data).toBeDefined()); + vi.mocked(adminService.getContentReportPendingCount).mockClear(); + + await expect( + result.current.actions.handle(7, { action: 'reject' }), + ).rejects.toBe(err); + expect(adminService.getContentReportPendingCount).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/api/hooks/admin.ts b/src/lib/api/hooks/admin.ts index 86672a16..8609643d 100644 --- a/src/lib/api/hooks/admin.ts +++ b/src/lib/api/hooks/admin.ts @@ -1,7 +1,14 @@ import { useCallback } from 'react'; -import useSWR from 'swr'; +import useSWR, { useSWRConfig } from 'swr'; import type { Key } from 'swr'; -import type { APIError, ListData } from '@/types/api'; +import type { + AdminContentReportDetail, + AdminContentReportItem, + APIError, + ContentReportHandleBody, + ContentReportTargetType, + ListData, +} from '@/types/api'; import { adminService } from '../services/admin'; import type { AdminReportItem, @@ -91,6 +98,76 @@ export function useAdminReports(params: { ); } +// ==================== 内容举报 ==================== + +export function useAdminContentReports(params: { + page: number; + size?: number; + /** 0 全部 | 1 待处理 | 2 已处理 | 3 已驳回 */ + status?: number; + /** 空串 / 未传表示全部类型 */ + targetType?: ContentReportTargetType | ''; +}): AdminListResult { + const { page, size = ADMIN_PAGE_SIZE, status, targetType } = params; + const target_type = targetType || undefined; + const query = { page, size, status, target_type }; + return useAdminList(['admin-content-reports', query], () => + adminService.listContentReports(query), + ); +} + +/** 内容举报页签徽标:待处理数。失败 / 加载中时 count 为 0(徽标隐藏)。 */ +export function useAdminContentReportPendingCount() { + const swr = useSWR<{ count: number }, APIError>( + ['admin-content-reports-pending-count'], + () => adminService.getContentReportPendingCount(), + ); + return { count: swr.data?.count ?? 0, refresh: () => void swr.mutate() }; +} + +/** 举报详情;id 为 null 时不请求(抽屉关闭)。 */ +export function useAdminContentReport(id: number | null) { + const swr = useSWR( + id === null ? null : ['admin-content-report', id], + () => adminService.getContentReport(id as number), + ); + const { data, isInitialLoading, error } = useResource(swr); + return { + data, + isLoading: isInitialLoading, + error, + refresh: () => void swr.mutate(), + }; +} + +const CONTENT_REPORT_KEYS = new Set([ + 'admin-content-reports', + 'admin-content-reports-pending-count', + 'admin-content-report', +]); + +/** + * 内容举报处理动作。成功后一并刷新列表、待处理徽标、详情; + * 失败原样抛出(调用方负责提示),且不刷新。 + */ +export function useContentReportActions() { + const { mutate } = useSWRConfig(); + const revalidate = () => + mutate( + (key) => Array.isArray(key) && CONTENT_REPORT_KEYS.has(key[0] as string), + ); + return { + handle: async (id: number, body: ContentReportHandleBody) => { + await adminService.handleContentReport(id, body); + await revalidate(); + }, + reopen: async (id: number) => { + await adminService.reopenContentReport(id); + await revalidate(); + }, + }; +} + // ==================== 脚本 ==================== export interface AdminScriptsParams { diff --git a/src/lib/api/services/admin.ts b/src/lib/api/services/admin.ts index e57c9b87..3a89d9c1 100644 --- a/src/lib/api/services/admin.ts +++ b/src/lib/api/services/admin.ts @@ -1,4 +1,10 @@ -import type { ListData } from '@/types/api'; +import type { + AdminContentReportDetail, + AdminContentReportItem, + ContentReportHandleBody, + ContentReportTargetType, + ListData, +} from '@/types/api'; import { apiClient } from '../client'; // ==================== OAuth Apps ==================== @@ -494,6 +500,44 @@ class AdminService { ); } + // Content Report Management + async listContentReports(params: { + page: number; + size: number; + status?: number; + target_type?: ContentReportTargetType; + }) { + return apiClient.get>( + `${this.basePath}/content-reports`, + params, + ); + } + + async getContentReportPendingCount() { + return apiClient.get<{ count: number }>( + `${this.basePath}/content-reports/pending-count`, + ); + } + + async getContentReport(id: number) { + return apiClient.get( + `${this.basePath}/content-reports/${id}`, + ); + } + + async handleContentReport(id: number, body: ContentReportHandleBody) { + return apiClient.post>( + `${this.basePath}/content-reports/${id}/handle`, + body, + ); + } + + async reopenContentReport(id: number) { + return apiClient.put>( + `${this.basePath}/content-reports/${id}/reopen`, + ); + } + // OIDC Icon Upload async uploadOIDCIcon(file: File) { const formData = new FormData(); diff --git a/src/lib/api/services/scripts/report.ts b/src/lib/api/services/scripts/report.ts index 77123942..916ed1b6 100644 --- a/src/lib/api/services/scripts/report.ts +++ b/src/lib/api/services/scripts/report.ts @@ -1,5 +1,5 @@ import { apiClient } from '../../client'; -import type { ListData } from '@/types/api'; +import type { CreateContentReportRequest, ListData } from '@/types/api'; export type ReportStatusType = 1 | 3; // 1: 待处理, 3: 已解决 @@ -69,6 +69,16 @@ export class ScriptReportService { ); } + async createContentReport( + scriptId: number, + data: CreateContentReportRequest, + ): Promise<{ id: number }> { + return apiClient.post<{ id: number }>( + `${this.basePath}/${scriptId}/content-reports`, + data, + ); + } + async resolveReport( scriptId: number, reportId: number, diff --git a/src/types/api.ts b/src/types/api.ts index ab102f97..943f6a0c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -71,3 +71,85 @@ export interface Notification { createtime: number; updatetime: number; } + +// 内容举报(评价 / 反馈 / 反馈回复) +export type ContentReportTargetType = 'score' | 'issue' | 'issue_comment'; + +export type ContentReportReason = + 'spam' | 'abuse' | 'porn' | 'illegal' | 'misleading' | 'other'; + +/** 内容举报状态:1 待处理 | 2 已处理 | 3 已驳回 */ +export type ContentReportStatus = 1 | 2 | 3; + +/** 结案结果;待处理时为空串 */ +export type ContentReportHandleResult = + '' | 'deleted' | 'resolved' | 'rejected'; + +/** 管理后台内容举报列表项 */ +export interface AdminContentReportItem { + id: number; + target_type: ContentReportTargetType; + target_id: number; + issue_id: number; + script_id: number; + /** 脚本已删除时为空串 */ + script_name: string; + author_id: number; + author_name: string; + /** 后端已截断;反馈以【标题】开头 */ + summary: string; + target_deleted: boolean; + reason: ContentReportReason; + /** 举报人补充说明 */ + content: string; + user_id: number; + username: string; + /** 该内容当前待处理举报数 */ + pending_count: number; + status: ContentReportStatus; + handle_result: ContentReportHandleResult; + createtime: number; +} + +/** 举报时保存的内容快照 */ +export interface ContentReportSnapshot { + title: string; + content: string; + score: number; + createtime: number; +} + +/** 同一内容下的单条举报记录 */ +export interface ContentReportRecord { + id: number; + user_id: number; + username: string; + reason: ContentReportReason; + content: string; + status: ContentReportStatus; + createtime: number; +} + +export interface AdminContentReportDetail extends AdminContentReportItem { + snapshot: ContentReportSnapshot; + handler_id: number; + handler_name: string; + handle_note: string; + handletime: number; + reports: ContentReportRecord[]; +} + +export interface ContentReportHandleBody { + action: 'delete' | 'resolve' | 'reject'; + /** 仅管理员可见,≤500 字 */ + note?: string; +} + +export interface CreateContentReportRequest { + target_type: ContentReportTargetType; + target_id: number; + /** target_type 为 issue_comment 时必填 */ + issue_id?: number; + reason: ContentReportReason; + content?: string; +} From d6a1ce3dab50fb8dafc6b258cd5ba452a1a38cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Fri, 9 Oct 2026 19:38:50 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(admin):=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=A2=9E=E5=8A=A0=20Turnstile=20=E5=90=AF?= =?UTF-8?q?=E7=94=A8=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/locales/de-DE/translations.json | 2 + public/locales/en-US/translations.json | 2 + public/locales/ja-JP/translations.json | 2 + public/locales/ru-RU/translations.json | 2 + public/locales/vi-VN/translations.json | 2 + public/locales/zh-CN/translations.json | 2 + public/locales/zh-TW/translations.json | 2 + .../components/SystemConfigClient.tsx | 39 +++++++++++++++++-- 8 files changed, 49 insertions(+), 4 deletions(-) diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index bf237383..030375f5 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -434,6 +434,8 @@ "save_button": "Speichern", "save_success": "Erfolgreich gespeichert", "title": "Systemkonfiguration", + "turnstile_enabled": "Verifizierung aktivieren", + "turnstile_enabled_extra": "Wenn deaktiviert, entfällt die Menschen-Verifizierung bei Anmeldung, Registrierung und Passwort-Zurücksetzung", "turnstile_site_key": "Website-Schlüssel (Site Key)", "turnstile_secret_key": "Geheimer Schlüssel", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 21032585..db312413 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -498,6 +498,8 @@ "save_button": "Save", "save_success": "Saved successfully", "title": "System Configuration", + "turnstile_enabled": "Enable verification", + "turnstile_enabled_extra": "When off, login, registration and password reset skip the human verification", "turnstile_site_key": "Site Key", "turnstile_secret_key": "Secret Key", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index d730c9e6..6ae71b4d 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -434,6 +434,8 @@ "save_button": "保存", "save_success": "保存しました", "title": "システム設定", + "turnstile_enabled": "人間確認を有効化", + "turnstile_enabled_extra": "オフにすると、ログイン・登録・パスワード再設定で人間確認を行いません", "turnstile_site_key": "サイトキー", "turnstile_secret_key": "シークレットキー", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index c0167e95..ceb2274b 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -434,6 +434,8 @@ "save_button": "Сохранить", "save_success": "Успешно сохранено", "title": "Настройки системы", + "turnstile_enabled": "Включить проверку", + "turnstile_enabled_extra": "Если выключено, проверка на человека при входе, регистрации и сбросе пароля не выполняется", "turnstile_site_key": "Ключ сайта (Site Key)", "turnstile_secret_key": "Секретный ключ", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/vi-VN/translations.json b/public/locales/vi-VN/translations.json index 0bace6cb..a7e50601 100644 --- a/public/locales/vi-VN/translations.json +++ b/public/locales/vi-VN/translations.json @@ -417,6 +417,8 @@ "save_button": "Lưu", "save_success": "Đã lưu thành công", "title": "Cấu hình hệ thống", + "turnstile_enabled": "Bật xác minh", + "turnstile_enabled_extra": "Khi tắt, đăng nhập, đăng ký và đặt lại mật khẩu sẽ không yêu cầu xác minh người dùng", "turnstile_site_key": "Khóa trang web (Site Key)", "turnstile_secret_key": "Khóa bí mật", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 1276f809..e854e6bd 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -551,6 +551,8 @@ "save_button": "保存", "save_success": "保存成功", "title": "系统配置", + "turnstile_enabled": "启用人机验证", + "turnstile_enabled_extra": "关闭后,登录、注册和找回密码将不再进行人机验证", "turnstile_site_key": "站点密钥(Site Key)", "turnstile_secret_key": "密钥(Secret Key)", "turnstile_title": "Cloudflare Turnstile", diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index 30ef0721..ab7735c7 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -434,6 +434,8 @@ "save_button": "保存", "save_success": "保存成功", "title": "系統配置", + "turnstile_enabled": "啟用人機驗證", + "turnstile_enabled_extra": "關閉後,登入、註冊和找回密碼將不再進行人機驗證", "turnstile_site_key": "站點密鑰(Site Key)", "turnstile_secret_key": "密鑰(Secret Key)", "turnstile_title": "Cloudflare Turnstile", diff --git a/src/app/[locale]/(main)/admin/system-config/components/SystemConfigClient.tsx b/src/app/[locale]/(main)/admin/system-config/components/SystemConfigClient.tsx index 7873902c..66b8c157 100644 --- a/src/app/[locale]/(main)/admin/system-config/components/SystemConfigClient.tsx +++ b/src/app/[locale]/(main)/admin/system-config/components/SystemConfigClient.tsx @@ -1,7 +1,16 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Card, Form, Input, message, Popconfirm, Progress } from 'antd'; +import { + Button, + Card, + Form, + Input, + message, + Popconfirm, + Progress, + Switch, +} from 'antd'; import { useTranslations } from 'next-intl'; import { LoadingBlock } from '@/components/ui/LoadingBlock'; import { adminService } from '@/lib/api/services/admin'; @@ -123,12 +132,16 @@ export default function SystemConfigClient() { } }; - const handleSave = async (group: string, keys: string[]) => { + const handleSave = async ( + group: string, + keys: string[], + defaults: Record = {}, + ) => { setSaving(group); try { const items: SystemConfigItem[] = keys.map((key) => ({ key, - value: configs[key] || '', + value: configs[key] || defaults[key] || '', })); await adminService.updateSystemConfigs(items); message.success(t('save_success')); @@ -158,12 +171,15 @@ export default function SystemConfigClient() { } const turnstileKeys = [ + 'captcha.turnstile.enabled', 'captcha.turnstile.site_key', 'captcha.turnstile.secret_key', ]; const ucenterKeys = ['ucenter.api', 'ucenter.key', 'ucenter.appid']; const aiKeys = ['ai.base_url', 'ai.api_key', 'ai.model', 'ai.system_prompt']; const githubKeys = ['github.pat']; + // 未配置过开关时后端视为开启 + const turnstileEnabled = configs['captcha.turnstile.enabled'] !== 'false'; const migrateRunning = migrateStatus?.running ?? false; const migrateTotal = migrateStatus?.total ?? 0; @@ -182,6 +198,17 @@ export default function SystemConfigClient() { {/* Turnstile */}
+ + + updateConfig('captcha.turnstile.enabled', String(checked)) + } + /> + handleSave('turnstile', turnstileKeys)} + onClick={() => + handleSave('turnstile', turnstileKeys, { + 'captcha.turnstile.enabled': 'true', + }) + } > {t('save_button')} From 658447edc3dae3f959a3d37bf66ea32cb2c54c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 10 Oct 2026 11:00:40 +0800 Subject: [PATCH 05/12] =?UTF-8?q?fix(admin):=20=E5=A4=84=E7=90=86=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=97=E8=A1=A8=E4=B8=BA=20null=20=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E7=A9=BA=E7=8A=B6=E6=80=81=E8=80=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户名下没有脚本时后端返回 list: null,组件直接读 data.list.length 抛错,被 (main)/error.tsx 接住后整页显示「服务器内部错误」。 --- .../UserProfile/ProcessedScriptsList.test.tsx | 104 ++++++++++++++++++ .../UserProfile/ProcessedScriptsList.tsx | 2 +- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/components/UserProfile/ProcessedScriptsList.test.tsx diff --git a/src/components/UserProfile/ProcessedScriptsList.test.tsx b/src/components/UserProfile/ProcessedScriptsList.test.tsx new file mode 100644 index 00000000..66c7cfcd --- /dev/null +++ b/src/components/UserProfile/ProcessedScriptsList.test.tsx @@ -0,0 +1,104 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from '@testing-library/react'; +import { SWRConfig } from 'swr'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import ProcessedScriptsList from './ProcessedScriptsList'; + +const listUserProcessedScripts = vi.fn(); + +// antd 的 Table 会订阅断点并监听尺寸,jsdom 没有实现 matchMedia / ResizeObserver。 +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +vi.mock('next-intl', () => ({ + useTranslations: () => { + const t = (key: string) => key; + return t; + }, +})); + +vi.mock('@/i18n/routing', () => ({ + Link: ({ href, children }: { href: string; children: React.ReactNode }) => ( + {children} + ), +})); + +vi.mock('@/lib/utils/semdate', () => ({ + useSemDateTime: () => (ts: number) => String(ts), +})); + +vi.mock('@/lib/api/services/admin', () => ({ + adminService: { + listUserProcessedScripts: (...args: unknown[]) => + listUserProcessedScripts(...args), + }, +})); + +afterEach(() => { + cleanup(); + listUserProcessedScripts.mockReset(); +}); + +function renderList() { + return render( + new Map(), dedupingInterval: 0 }}> + + , + ); +} + +describe('ProcessedScriptsList', () => { + /** + * 后端对「该用户名下没有任何脚本」走提前返回,`list` 序列化成 null。 + * 旧实现直接读 `data.list.length`,渲染抛错后被 (main)/error.tsx 接住, + * 整个页面变成「服务器内部错误」。 + */ + it('list 为 null 时显示空状态而不是抛错', async () => { + listUserProcessedScripts.mockResolvedValue({ list: null, total: 0 }); + + renderList(); + + expect(await screen.findByText('processed_empty')).toBeInTheDocument(); + }); + + it('有记录时渲染脚本名', async () => { + listUserProcessedScripts.mockResolvedValue({ + list: [ + { + id: 1, + action: 'script_delete', + operator_id: 1, + operator_name: 'admin', + script_id: 100, + script_name: 'script-A', + script_status: 2, + reason: 'abuse', + createtime: 123, + }, + ], + total: 1, + }); + + renderList(); + + expect(await screen.findByText('script-A')).toBeInTheDocument(); + }); +}); diff --git a/src/components/UserProfile/ProcessedScriptsList.tsx b/src/components/UserProfile/ProcessedScriptsList.tsx index 70494433..b02de81d 100644 --- a/src/components/UserProfile/ProcessedScriptsList.tsx +++ b/src/components/UserProfile/ProcessedScriptsList.tsx @@ -43,7 +43,7 @@ export default function ProcessedScriptsList({ ); } - if (!data || data.list.length === 0) { + if (!data?.list?.length) { return ; } From c6b88da61d3f35b9f8065a3eea8da40cb310aaa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Sat, 10 Oct 2026 11:19:19 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(admin):=20=E5=B0=81=E7=A6=81?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=A2=9E=E5=8A=A0=E3=80=8C=E6=B8=85=E7=90=86?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E5=8F=8A=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E3=80=8D=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 勾选后封禁请求带 clean_issues,由后端软删该用户的反馈及正文回复。 补齐 7 个语种的 ban_clean_issues 文案。 --- public/locales/de-DE/translations.json | 1 + public/locales/en-US/translations.json | 1 + public/locales/ja-JP/translations.json | 1 + public/locales/ru-RU/translations.json | 1 + public/locales/vi-VN/translations.json | 1 + public/locales/zh-CN/translations.json | 1 + public/locales/zh-TW/translations.json | 1 + .../UserProfile/BanUserModal.test.tsx | 95 +++++++++++++++++++ src/components/UserProfile/BanUserModal.tsx | 5 + src/lib/api/services/admin.ts | 1 + 10 files changed, 108 insertions(+) create mode 100644 src/components/UserProfile/BanUserModal.test.tsx diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index 030375f5..c9829f63 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -2546,6 +2546,7 @@ "ban_clean_options": "Cleanup options", "ban_clean_scores": "Remove user ratings", "ban_clean_scripts": "Remove user scripts", + "ban_clean_issues": "Feedback und Antworten des Nutzers entfernen", "ban_success": "User banned", "unban_success": "User unbanned", "ban_confirm_title": "Confirm ban", diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index db312413..3de4d8ae 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -2554,6 +2554,7 @@ "ban_clean_options": "Cleanup options", "ban_clean_scores": "Remove user ratings", "ban_clean_scripts": "Remove user scripts", + "ban_clean_issues": "Remove user feedback and replies", "ban_success": "User banned", "unban_success": "User unbanned", "ban_confirm_title": "Confirm ban", diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 6ae71b4d..e2263380 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -2546,6 +2546,7 @@ "ban_clean_options": "Cleanup options", "ban_clean_scores": "Remove user ratings", "ban_clean_scripts": "Remove user scripts", + "ban_clean_issues": "ユーザーのフィードバックと返信を削除", "ban_success": "User banned", "unban_success": "User unbanned", "ban_confirm_title": "Confirm ban", diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index ceb2274b..b8839a53 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -2546,6 +2546,7 @@ "ban_clean_options": "Cleanup options", "ban_clean_scores": "Remove user ratings", "ban_clean_scripts": "Remove user scripts", + "ban_clean_issues": "Удалить обращения и ответы пользователя", "ban_success": "User banned", "unban_success": "User unbanned", "ban_confirm_title": "Confirm ban", diff --git a/public/locales/vi-VN/translations.json b/public/locales/vi-VN/translations.json index a7e50601..0ad72dde 100644 --- a/public/locales/vi-VN/translations.json +++ b/public/locales/vi-VN/translations.json @@ -2546,6 +2546,7 @@ "ban_clean_options": "Tùy chọn dọn dẹp", "ban_clean_scores": "Xóa đánh giá của người dùng", "ban_clean_scripts": "Xóa script của người dùng", + "ban_clean_issues": "Xóa phản hồi và trả lời của người dùng", "ban_success": "Đã cấm người dùng", "unban_success": "Gỡ cấm thành công", "ban_confirm_title": "Xác nhận cấm", diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index e854e6bd..ddc674c9 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -2553,6 +2553,7 @@ "ban_clean_options": "清理选项", "ban_clean_scores": "清理用户评分", "ban_clean_scripts": "清理用户脚本", + "ban_clean_issues": "清理用户反馈及回复", "ban_success": "封禁成功", "unban_success": "解封成功", "ban_confirm_title": "确认封禁", diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index ab7735c7..ad1649bb 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -2546,6 +2546,7 @@ "ban_clean_options": "Cleanup options", "ban_clean_scores": "Remove user ratings", "ban_clean_scripts": "Remove user scripts", + "ban_clean_issues": "清理使用者回饋及回覆", "ban_success": "User banned", "unban_success": "User unbanned", "ban_confirm_title": "Confirm ban", diff --git a/src/components/UserProfile/BanUserModal.test.tsx b/src/components/UserProfile/BanUserModal.test.tsx new file mode 100644 index 00000000..fa5abab0 --- /dev/null +++ b/src/components/UserProfile/BanUserModal.test.tsx @@ -0,0 +1,95 @@ +/** @vitest-environment jsdom */ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BanUserModal from './BanUserModal'; + +const updateUserStatus = vi.fn(); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); +vi.mock('@/lib/api/services/admin', () => ({ + adminService: { + updateUserStatus: (...a: unknown[]) => updateUserStatus(...a), + }, +})); +vi.mock('./ProcessedScriptsList', () => ({ default: () => null })); +vi.mock('antd', async (orig) => ({ + ...(await orig>()), + message: { success: vi.fn(), error: vi.fn() }, +})); + +const renderModal = (onSuccess = vi.fn()) => + render( + , + ); + +describe('BanUserModal', () => { + beforeEach(() => { + // jsdom 没有 ResizeObserver / matchMedia,antd 浮层与表单需要 + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + vi.clearAllMocks(); + updateUserStatus.mockResolvedValue(undefined); + }); + afterEach(cleanup); + + it('勾选「清理用户反馈及回复」后,封禁请求带 clean_issues: true', async () => { + const onSuccess = vi.fn(); + renderModal(onSuccess); + + fireEvent.click(screen.getByLabelText('ban_clean_issues')); + fireEvent.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(updateUserStatus).toHaveBeenCalledWith( + 7, + 2, + expect.objectContaining({ clean_issues: true }), + ); + }); + + it('默认不勾选:清理项全部为 false,不会误删内容', async () => { + const onSuccess = vi.fn(); + renderModal(onSuccess); + + fireEvent.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + expect(updateUserStatus).toHaveBeenCalledWith(7, 2, { + reason: '', + expire_at: 0, + clean_scores: false, + clean_scripts: false, + clean_issues: false, + }); + }); +}); diff --git a/src/components/UserProfile/BanUserModal.tsx b/src/components/UserProfile/BanUserModal.tsx index 60dcc88b..3ea536ca 100644 --- a/src/components/UserProfile/BanUserModal.tsx +++ b/src/components/UserProfile/BanUserModal.tsx @@ -61,6 +61,7 @@ export default function BanUserModal({ expire_at: expireAt, clean_scores: values.clean_scores || false, clean_scripts: values.clean_scripts || false, + clean_issues: values.clean_issues || false, }); message.success(t('ban_success')); @@ -96,6 +97,7 @@ export default function BanUserModal({ duration: 0, clean_scores: false, clean_scripts: false, + clean_issues: false, }} > @@ -119,6 +121,9 @@ export default function BanUserModal({ {t('ban_clean_scripts')} + + {t('ban_clean_issues')} +
diff --git a/src/lib/api/services/admin.ts b/src/lib/api/services/admin.ts index 3a89d9c1..ee336e92 100644 --- a/src/lib/api/services/admin.ts +++ b/src/lib/api/services/admin.ts @@ -288,6 +288,7 @@ class AdminService { expire_at?: number; clean_scores?: boolean; clean_scripts?: boolean; + clean_issues?: boolean; }, ) { return apiClient.put(`${this.basePath}/users/${id}/status`, { From 8e67855fe9c78d43ad66ba06e0ae42a3d265c480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 12 Oct 2026 10:32:43 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=E4=B8=8A=E6=8A=A5=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E7=AB=AF=E4=B8=8E=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 错误边界只在访客自己的控制台打印,未捕获异常没有任何监听,SSR 报错不带 请求路径——用户发来 "Internal Server Error" 截图时服务端查不到任何痕迹。 - 错误边界、未捕获异常、未处理的 Promise 拒绝上报到后端 /api/v2/system/client-errors;同一错误每次页面加载只报一次,单页最多 5 条 - 错误页显示的错误 ID 即上报的 error_id,拿截图里的 ID 就能在日志里搜到 - 新增 instrumentation.ts:SSR 出错时带路径、真实消息与 digest 上报, 并转发访客的 Cookie / IP / UA - 新增 global-error.tsx 兜住根布局崩溃 - 上报带页面是否被浏览器翻译、构建版本(CI 注入 NEXT_PUBLIC_BUILD_ID) - 上报是旁路:失败静默、不重试,不影响页面 规格见后端仓库 docs/specs/2026-10-12-client-error-reporting.md --- .gitea/workflows/deploy.yaml | 1 + AGENTS.md | 1 + deploy/docker/Dockerfile | 5 +- src/app/[locale]/(main)/error.test.tsx | 58 ++++ src/app/[locale]/(main)/error.tsx | 19 +- src/app/global-error.tsx | 98 ++++++ src/components/ClientErrorReporter.test.tsx | 74 +++++ src/components/ClientErrorReporter.tsx | 37 +++ src/components/ErrorPage/index.tsx | 9 +- .../LocalizedServerThemeWrapper.tsx | 2 + src/instrumentation.ts | 12 + src/lib/api/services/system.ts | 14 + src/lib/error-report/client.ts | 21 ++ src/lib/error-report/core.test.ts | 285 ++++++++++++++++++ src/lib/error-report/core.ts | 171 +++++++++++ src/lib/error-report/server.test.ts | 121 ++++++++ src/lib/error-report/server.ts | 83 +++++ 17 files changed, 1003 insertions(+), 8 deletions(-) create mode 100644 src/app/[locale]/(main)/error.test.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/components/ClientErrorReporter.test.tsx create mode 100644 src/components/ClientErrorReporter.tsx create mode 100644 src/instrumentation.ts create mode 100644 src/lib/error-report/client.ts create mode 100644 src/lib/error-report/core.test.ts create mode 100644 src/lib/error-report/core.ts create mode 100644 src/lib/error-report/server.test.ts create mode 100644 src/lib/error-report/server.ts diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 6207e176..8166deab 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -67,6 +67,7 @@ jobs: NEXT_PUBLIC_APP_URL=https://${{ env.DOMAIN }} APP_API_URL=http://${{ env.INNER_DOMAIN }}.app.svc.cluster.local/api/v2 APP_API_PROXY=http://${{ env.INNER_DOMAIN }}.app.svc.cluster.local/api/v2 + NEXT_PUBLIC_BUILD_ID=${{ env.ENV }}.${{ steps.vars.outputs.sha_short }} - name: Set up kubeconfig # uses: azure/k8s-set-context@v4 diff --git a/AGENTS.md b/AGENTS.md index 7ea31fde..2c29b3af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Data fetching uses SWR with global config: 2s deduping, no focus revalidation, r NEXT_PUBLIC_APP_URL # Client-side app URL APP_API_URL # Server-side API base URL APP_API_PROXY # API proxy destination (used in next.config.ts rewrites) +NEXT_PUBLIC_BUILD_ID # Build identifier (CI sets .); sent with frontend error reports ``` Frontend proxies API calls via Next.js rewrites: `/api/v2/:path*` → `APP_API_PROXY/:path*`. `next.config.ts` also sets global security headers (`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`), immutable long-cache headers for `/assets` and `/monaco`, and `optimizePackageImports` for `antd` / `@ant-design/*` / `@iconify/react`. `pnpm` is pinned via `packageManager` (`pnpm@10.x`); use it rather than npm/yarn. diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 8c879e91..da5dfd83 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -35,13 +35,16 @@ FROM base AS builder ARG NEXT_PUBLIC_APP_URL=https://scriptcat.org ARG APP_API_PROXY=https://scriptcat.org/api/v2 ARG APP_API_URL=https://scriptcat.org/api/v2 +# 前端构建版本,随错误上报写进日志,用来把压缩后的堆栈对回某次构建 +ARG NEXT_PUBLIC_BUILD_ID="" # 设置工作目录 WORKDIR /app ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL} \ APP_API_URL=${APP_API_URL} \ - APP_API_PROXY=${APP_API_PROXY} + APP_API_PROXY=${APP_API_PROXY} \ + NEXT_PUBLIC_BUILD_ID=${NEXT_PUBLIC_BUILD_ID} # 从 deps 阶段复制 node_modules COPY --from=deps /app/node_modules ./node_modules diff --git a/src/app/[locale]/(main)/error.test.tsx b/src/app/[locale]/(main)/error.test.tsx new file mode 100644 index 00000000..f24117c3 --- /dev/null +++ b/src/app/[locale]/(main)/error.test.tsx @@ -0,0 +1,58 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import ErrorBoundaryPage from './error'; + +const reportClientError = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/error-report/client', () => ({ reportClientError })); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock('@/i18n/routing', () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +// antd 的响应式组件会订阅断点,jsdom 没有实现 matchMedia。 +beforeAll(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + cleanup(); + reportClientError.mockReset(); + vi.restoreAllMocks(); +}); + +describe('route error boundary', () => { + it('reports the crash and shows the same error id it reported', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = Object.assign(new Error('removeChild failed'), { + digest: '652515689', + }); + + render( {}} />); + + expect(reportClientError).toHaveBeenCalledTimes(1); + const report = reportClientError.mock.calls[0][0]; + expect(report).toMatchObject({ + kind: 'boundary', + message: 'removeChild failed', + digest: '652515689', + }); + expect(report.stack).toBe(error.stack); + expect(report.errorId).toMatch(/^[0-9A-Z]{6,12}$/); + // 用户截图里的 ID 必须能在日志里搜到 + expect(screen.getByText(new RegExp(report.errorId))).toBeInTheDocument(); + }); +}); diff --git a/src/app/[locale]/(main)/error.tsx b/src/app/[locale]/(main)/error.tsx index f5226c11..5b73a5af 100644 --- a/src/app/[locale]/(main)/error.tsx +++ b/src/app/[locale]/(main)/error.tsx @@ -1,7 +1,9 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import ErrorPage from '@/components/ErrorPage'; +import { reportClientError } from '@/lib/error-report/client'; +import { generateErrorId } from '@/lib/error-report/core'; export default function Error({ error, @@ -9,19 +11,28 @@ export default function Error({ error: Error & { digest?: string }; reset: () => void; }) { + // 页面上显示的 ID 就是上报的 ID:用户截图里有它,就能在日志里搜到这次错误 + const [errorId] = useState(generateErrorId); + useEffect(() => { // 记录错误到控制台 console.error('Application error:', error); - // 这里可以添加错误上报逻辑 - // 例如发送到错误监控服务 - }, [error]); + reportClientError({ + kind: 'boundary', + message: error.message, + stack: error.stack, + digest: error.digest, + errorId, + }); + }, [error, errorId]); // 对于500类错误,显示专门的500错误页面 return ( diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 00000000..bec3901d --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { languageMap } from '@/i18n/routing'; +import { reportClientError } from '@/lib/error-report/client'; +import { generateErrorId, localeFromPath } from '@/lib/error-report/core'; + +// 根布局崩溃时 next-intl 的 Provider 和全局样式都不在了,这里只能自带文案与内联样式。 +// 英文作兜底,再按路径里的语言去取与 500 错误页相同的翻译。 +const FALLBACK_TEXT = { + title: 'Internal Server Error', + description: + 'Our server is temporarily unable to process your request. We have received the error report and are working hard to fix this issue.', + errorId: 'Error ID', + home: 'Back to Home', + refresh: 'Refresh Page', +}; + +export default function GlobalError({ + error, +}: { + error: Error & { digest?: string }; +}) { + const [errorId] = useState(generateErrorId); + // 语言与文案一起在翻译取回后才切换;取不回来就保持英文兜底 + const [view, setView] = useState<{ + locale?: string; + text: typeof FALLBACK_TEXT; + }>({ text: FALLBACK_TEXT }); + const { locale, text } = view; + + useEffect(() => { + console.error('Application error:', error); + + reportClientError({ + kind: 'boundary', + message: error.message, + stack: error.stack, + digest: error.digest, + errorId, + }); + }, [error, errorId]); + + useEffect(() => { + const current = localeFromPath(window.location.pathname); + const tag = current && languageMap[current]?.locale; + if (!current || !tag) { + return; + } + fetch(`/locales/${tag}/translations.json`) + .then((resp) => resp.json()) + .then((messages) => { + const e = messages?.error ?? {}; + setView({ + locale: current, + text: { + title: e['500_title'] ?? FALLBACK_TEXT.title, + description: e['500_description'] ?? FALLBACK_TEXT.description, + errorId: e.error_id ?? FALLBACK_TEXT.errorId, + home: e.back_to_home ?? FALLBACK_TEXT.home, + refresh: e.refresh_page ?? FALLBACK_TEXT.refresh, + }, + }); + }) + .catch(() => {}); + }, []); + + return ( + + +
+

{text.title}

+

{text.description}

+

{`${text.errorId}: ${errorId}`}

+

+ {/* 根布局已崩溃,客户端路由不可用,只能整页跳转 */} + {text.home} + +

+
+ + + ); +} diff --git a/src/components/ClientErrorReporter.test.tsx b/src/components/ClientErrorReporter.test.tsx new file mode 100644 index 00000000..6ce9a22a --- /dev/null +++ b/src/components/ClientErrorReporter.test.tsx @@ -0,0 +1,74 @@ +/** @vitest-environment jsdom */ +import { cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import ClientErrorReporter from './ClientErrorReporter'; + +const reportClientError = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/error-report/client', () => ({ reportClientError })); + +afterEach(() => { + cleanup(); + reportClientError.mockReset(); +}); + +const dispatchRejection = (reason: unknown) => { + const event = new Event('unhandledrejection'); + Object.assign(event, { reason }); + window.dispatchEvent(event); +}; + +describe('ClientErrorReporter', () => { + it('reports uncaught exceptions with their stack and script url', () => { + render(); + const error = new Error('boom'); + + window.dispatchEvent( + new ErrorEvent('error', { + message: 'Uncaught Error: boom', + filename: 'https://scriptcat.org/_next/static/chunks/x.js', + error, + }), + ); + + expect(reportClientError).toHaveBeenCalledWith({ + kind: 'uncaught', + message: 'Uncaught Error: boom', + stack: error.stack, + source: 'https://scriptcat.org/_next/static/chunks/x.js', + }); + }); + + it('reports unhandled promise rejections', () => { + render(); + const error = new Error('rejected'); + + dispatchRejection(error); + + expect(reportClientError).toHaveBeenCalledWith({ + kind: 'rejection', + message: 'rejected', + stack: error.stack, + }); + }); + + it('describes rejections whose reason is not an Error', () => { + render(); + + dispatchRejection('timeout'); + + expect(reportClientError).toHaveBeenCalledWith({ + kind: 'rejection', + message: 'timeout', + stack: undefined, + }); + }); + + it('stops listening once unmounted', () => { + const { unmount } = render(); + unmount(); + + dispatchRejection(new Error('late')); + + expect(reportClientError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/ClientErrorReporter.tsx b/src/components/ClientErrorReporter.tsx new file mode 100644 index 00000000..b238dad0 --- /dev/null +++ b/src/components/ClientErrorReporter.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useEffect } from 'react'; +import { reportClientError } from '@/lib/error-report/client'; + +/** + * 上报错误边界接不到的浏览器端错误:事件回调、定时器、异步流程里抛出的异常。 + */ +export default function ClientErrorReporter() { + useEffect(() => { + const onError = (event: ErrorEvent) => { + reportClientError({ + kind: 'uncaught', + message: event.message || String(event.error), + stack: event.error instanceof Error ? event.error.stack : undefined, + source: event.filename, + }); + }; + const onRejection = (event: PromiseRejectionEvent) => { + const { reason } = event; + reportClientError({ + kind: 'rejection', + message: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : undefined, + }); + }; + + window.addEventListener('error', onError); + window.addEventListener('unhandledrejection', onRejection); + return () => { + window.removeEventListener('error', onError); + window.removeEventListener('unhandledrejection', onRejection); + }; + }, []); + + return null; +} diff --git a/src/components/ErrorPage/index.tsx b/src/components/ErrorPage/index.tsx index fda48c59..08b913f5 100644 --- a/src/components/ErrorPage/index.tsx +++ b/src/components/ErrorPage/index.tsx @@ -13,6 +13,7 @@ import { } from '@ant-design/icons'; import { useRouter } from '@/i18n/routing'; import { useEffect, useMemo, useState } from 'react'; +import { generateErrorId } from '@/lib/error-report/core'; const { Paragraph, Text } = Typography; @@ -24,6 +25,8 @@ interface ErrorPageProps { showErrorDetails?: boolean; showFeedback?: boolean; error?: Error & { digest?: string }; + /** 已上报的错误 ID;不传则只在本页生成一个用于展示 */ + errorId?: string; } export default function ErrorPage({ @@ -33,12 +36,12 @@ export default function ErrorPage({ showErrorDetails = true, showFeedback = true, error, + errorId: reportedErrorId, }: ErrorPageProps) { const t = useTranslations(); const router = useRouter(); - const [errorId] = useState(() => - Math.random().toString(36).substr(2, 9).toUpperCase(), - ); + const [generatedErrorId] = useState(generateErrorId); + const errorId = reportedErrorId ?? generatedErrorId; const errorTime = useMemo(() => new Date().toLocaleString('zh-CN'), []); useEffect(() => { diff --git a/src/components/LocalizedServerThemeWrapper.tsx b/src/components/LocalizedServerThemeWrapper.tsx index 9ff9dd0c..5123f99a 100644 --- a/src/components/LocalizedServerThemeWrapper.tsx +++ b/src/components/LocalizedServerThemeWrapper.tsx @@ -4,6 +4,7 @@ import { UserProvider } from '@/contexts/UserContext'; import type { ThemeMode } from '@/lib/cookies'; import { THEME_COOKIE_NAME, THEME_MODE_COOKIE_NAME } from '@/lib/cookies'; import NavigationProgress from './NavigationProgress'; +import ClientErrorReporter from './ClientErrorReporter'; import { DayjsLocaleProvider } from './DayjsLocaleProvider'; import { SWRProvider } from '@/lib/swr-config'; import { NextIntlClientProvider } from 'next-intl'; @@ -104,6 +105,7 @@ export async function LocalizedServerThemeWrapper({ /> ) : null} + { + // 不 await:上报不能拖慢或改变原请求的响应(reportServerError 自己吞掉所有失败) + void reportServerError(error, request); +}; diff --git a/src/lib/api/services/system.ts b/src/lib/api/services/system.ts index 46b2a167..cd7812cc 100644 --- a/src/lib/api/services/system.ts +++ b/src/lib/api/services/system.ts @@ -1,4 +1,6 @@ import { apiClient } from '../client'; +import type { RequestOptions } from '../client'; +import type { ClientErrorPayload } from '@/lib/error-report/core'; export interface GlobalConfig { turnstile_site_key: string; @@ -13,6 +15,18 @@ class SystemService { async getGlobalConfig() { return apiClient.get('/global-config'); } + + /** 上报一次前端错误(浏览器端或服务端渲染);后端只写日志。 */ + async reportClientError( + payload: ClientErrorPayload, + options?: RequestOptions, + ) { + return apiClient.post>( + `${this.basePath}/client-errors`, + payload, + options, + ); + } } export const systemService = new SystemService(); diff --git a/src/lib/error-report/client.ts b/src/lib/error-report/client.ts new file mode 100644 index 00000000..a52fad64 --- /dev/null +++ b/src/lib/error-report/client.ts @@ -0,0 +1,21 @@ +import { systemService } from '@/lib/api/services/system'; +import { createReporter, isPageTranslated, localeFromPath } from './core'; + +/** + * 浏览器端上报入口。模块级单例:去重与条数上限按一次页面加载计。 + */ +export const reportClientError = createReporter({ + send: (payload) => { + // keepalive:用户看到错误页后往往立刻刷新,请求要能活过页面卸载 + void systemService + .reportClientError(payload, { keepalive: true }) + .catch(() => {}); + }, + getContext: () => ({ + pathname: window.location.pathname, + search: window.location.search, + locale: localeFromPath(window.location.pathname), + build: process.env.NEXT_PUBLIC_BUILD_ID, + translated: isPageTranslated(document), + }), +}); diff --git a/src/lib/error-report/core.test.ts b/src/lib/error-report/core.test.ts new file mode 100644 index 00000000..2eb40cf6 --- /dev/null +++ b/src/lib/error-report/core.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildPayload, + createReporter, + generateErrorId, + isControlFlowError, + isPageTranslated, + localeFromPath, + sanitizeUrl, + shouldIgnoreError, +} from './core'; +import type { ClientErrorPayload, ReportContext } from './core'; + +const context: ReportContext = { + pathname: '/en/script-show-page/8274', + search: '', + locale: 'en', + build: 'prod.c6b88da', + translated: false, +}; + +describe('sanitizeUrl', () => { + it('keeps the path and ordinary query parameters', () => { + expect(sanitizeUrl('/en/search', '?keyword=bilibili&page=2')).toBe( + '/en/search?keyword=bilibili&page=2', + ); + }); + + it('returns the bare path when there is no query', () => { + expect(sanitizeUrl('/en', '')).toBe('/en'); + }); + + it.each(['code', 'token', 'state'])( + 'removes the value of the sensitive parameter %s', + (name) => { + const url = sanitizeUrl('/en/login/oauth', `?${name}=s3cret&next=%2Fen`); + expect(url).not.toContain('s3cret'); + expect(url).toContain('next=%2Fen'); + }, + ); + + it('drops the internal _rsc parameter', () => { + expect(sanitizeUrl('/en/search', '?_rsc=abc123&sort=createtime')).toBe( + '/en/search?sort=createtime', + ); + }); + + it('caps the result at 1000 characters', () => { + expect( + sanitizeUrl('/en/search', `?keyword=${'a'.repeat(2000)}`), + ).toHaveLength(1000); + }); +}); + +describe('shouldIgnoreError', () => { + it('ignores cross-origin "Script error."', () => { + expect(shouldIgnoreError({ message: 'Script error.' })).toBe(true); + }); + + it.each([ + 'chrome-extension://abcdef/content.js', + 'moz-extension://abcdef/content.js', + 'safari-web-extension://abcdef/content.js', + ])('ignores errors thrown from a browser extension (%s)', (url) => { + expect( + shouldIgnoreError({ + message: 'boom', + stack: `Error: boom\n at ${url}:1:1`, + }), + ).toBe(true); + expect(shouldIgnoreError({ message: 'boom', source: url })).toBe(true); + }); + + it('ignores ResizeObserver loop notices', () => { + expect( + shouldIgnoreError({ + message: + 'ResizeObserver loop completed with undelivered notifications.', + }), + ).toBe(true); + }); + + it('keeps errors from the site itself', () => { + expect( + shouldIgnoreError({ + message: "Failed to execute 'removeChild' on 'Node'", + stack: 'at a (https://scriptcat.org/_next/static/chunks/x.js:1:2)', + }), + ).toBe(false); + }); +}); + +describe('isPageTranslated', () => { + const doc = (classes: string[], hasEdgeMarker = false) => ({ + documentElement: { + classList: { contains: (n: string) => classes.includes(n) }, + }, + querySelector: () => (hasEdgeMarker ? {} : null), + }); + + it('detects Chrome page translation', () => { + expect(isPageTranslated(doc(['translated-ltr']))).toBe(true); + expect(isPageTranslated(doc(['translated-rtl']))).toBe(true); + }); + + it('detects Edge page translation', () => { + expect(isPageTranslated(doc([], true))).toBe(true); + }); + + it('is false for an untouched page', () => { + expect(isPageTranslated(doc(['dark']))).toBe(false); + }); +}); + +describe('isControlFlowError', () => { + it.each([ + 'NEXT_REDIRECT;replace;/en/login;307;', + 'NEXT_NOT_FOUND', + 'NEXT_HTTP_ERROR_FALLBACK;404', + ])('treats %s as navigation control flow', (digest) => { + expect(isControlFlowError(Object.assign(new Error('x'), { digest }))).toBe( + true, + ); + }); + + it('treats ordinary errors as errors', () => { + expect(isControlFlowError(new Error('boom'))).toBe(false); + expect( + isControlFlowError( + Object.assign(new Error('x'), { digest: '652515689' }), + ), + ).toBe(false); + }); +}); + +describe('buildPayload', () => { + it('maps the input and the page context onto the request fields', () => { + expect( + buildPayload( + { + kind: 'boundary', + message: 'boom', + stack: 'at a', + digest: '123', + errorId: 'RF2T1JTN8', + }, + { ...context, search: '?code=s3cret', translated: true }, + ), + ).toEqual({ + kind: 'boundary', + message: 'boom', + stack: 'at a', + digest: '123', + error_id: 'RF2T1JTN8', + url: '/en/script-show-page/8274?code=', + locale: 'en', + build: 'prod.c6b88da', + translated: true, + }); + }); + + it('truncates every field to the limit the backend accepts', () => { + const payload = buildPayload( + { + kind: 'uncaught', + message: 'm'.repeat(5000), + stack: 's'.repeat(20000), + digest: 'd'.repeat(200), + errorId: 'e'.repeat(200), + }, + { ...context, locale: 'l'.repeat(50), build: 'b'.repeat(200) }, + ); + expect(payload.message).toHaveLength(1000); + expect(payload.stack).toHaveLength(8000); + expect(payload.digest).toHaveLength(64); + expect(payload.error_id).toHaveLength(32); + expect(payload.locale).toHaveLength(16); + expect(payload.build).toHaveLength(64); + }); + + it('never sends an empty message', () => { + expect( + buildPayload({ kind: 'rejection', message: '' }, context).message, + ).not.toBe(''); + }); +}); + +describe('createReporter', () => { + const setup = (maxReports?: number) => { + const sent: ClientErrorPayload[] = []; + const report = createReporter({ + send: (p) => { + sent.push(p); + }, + getContext: () => context, + maxReports, + }); + return { sent, report }; + }; + + it('sends a report for a new error', () => { + const { sent, report } = setup(); + expect(report({ kind: 'uncaught', message: 'boom', stack: 'at a' })).toBe( + true, + ); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + kind: 'uncaught', + message: 'boom', + url: context.pathname, + }); + }); + + it('reports the same error only once per page load', () => { + const { sent, report } = setup(); + report({ kind: 'uncaught', message: 'boom', stack: 'at a' }); + expect(report({ kind: 'boundary', message: 'boom', stack: 'at a' })).toBe( + false, + ); + expect(sent).toHaveLength(1); + }); + + it('stops after five reports per page load', () => { + const { sent, report } = setup(); + for (let i = 0; i < 8; i++) { + report({ kind: 'uncaught', message: `boom ${i}` }); + } + expect(sent).toHaveLength(5); + }); + + it('does not send ignored errors and does not count them', () => { + const { sent, report } = setup(1); + expect(report({ kind: 'uncaught', message: 'Script error.' })).toBe(false); + expect(report({ kind: 'uncaught', message: 'real' })).toBe(true); + expect(sent).toHaveLength(1); + }); + + it('never throws when sending fails', () => { + const report = createReporter({ + send: () => { + throw new Error('network down'); + }, + getContext: () => context, + }); + expect(() => report({ kind: 'uncaught', message: 'boom' })).not.toThrow(); + }); + + it('never throws when reading the page context fails', () => { + const send = vi.fn(); + const report = createReporter({ + send, + getContext: () => { + throw new Error('no document'); + }, + }); + expect(() => report({ kind: 'uncaught', message: 'boom' })).not.toThrow(); + expect(send).not.toHaveBeenCalled(); + }); +}); + +describe('generateErrorId', () => { + it('produces a short uppercase id that fits the backend limit', () => { + const id = generateErrorId(); + expect(id).toMatch(/^[0-9A-Z]{6,12}$/); + }); + + it('produces a different id each time', () => { + expect(generateErrorId()).not.toBe(generateErrorId()); + }); +}); + +describe('localeFromPath', () => { + it.each([ + ['/en/script-show-page/8274', 'en'], + ['/zh-CN', 'zh-CN'], + ['/zh-TW/search', 'zh-TW'], + ])('reads the locale prefix of %s', (pathname, locale) => { + expect(localeFromPath(pathname)).toBe(locale); + }); + + it('is undefined when the path has no locale prefix', () => { + expect(localeFromPath('/')).toBeUndefined(); + expect(localeFromPath('/script-show-page/8274')).toBeUndefined(); + }); +}); diff --git a/src/lib/error-report/core.ts b/src/lib/error-report/core.ts new file mode 100644 index 00000000..e96ffe19 --- /dev/null +++ b/src/lib/error-report/core.ts @@ -0,0 +1,171 @@ +export type ClientErrorKind = 'boundary' | 'uncaught' | 'rejection' | 'ssr'; + +/** 上报给后端的请求体,字段与长度上限同后端 `ReportClientErrorRequest`。 */ +export interface ClientErrorPayload { + kind: ClientErrorKind; + message: string; + stack?: string; + digest?: string; + error_id?: string; + url: string; + locale?: string; + build?: string; + translated?: boolean; +} + +export interface ErrorReportInput { + kind: ClientErrorKind; + message: string; + stack?: string; + digest?: string; + /** 错误页上显示给用户的错误 ID */ + errorId?: string; + /** 抛出错误的脚本地址(`ErrorEvent.filename`) */ + source?: string; +} + +export interface ReportContext { + pathname: string; + search: string; + locale?: string; + build?: string; + translated?: boolean; +} + +// 与后端 `ReportClientErrorRequest` 的 binding 上限保持一致,超长会被整条拒绝。 +const LIMITS = { + message: 1000, + stack: 8000, + digest: 64, + errorId: 32, + url: 1000, + locale: 16, + build: 64, +} as const; + +const DEFAULT_MAX_REPORTS = 5; + +// 值可能是一次性凭证(OAuth 回调等),只保留参数名 +const SENSITIVE_PARAMS = ['code', 'token', 'state']; + +const EXTENSION_URL = + /\b(?:chrome|moz|safari|safari-web|ms-browser)-extension:\/\//; + +// notFound()/redirect() 等是靠抛错实现的导航控制流,不是错误 +const CONTROL_FLOW_DIGESTS = [ + 'NEXT_REDIRECT', + 'NEXT_NOT_FOUND', + 'NEXT_HTTP_ERROR_FALLBACK', + 'DYNAMIC_SERVER_USAGE', + 'BAILOUT_TO_CLIENT_SIDE_RENDERING', +]; + +const LOCALE_PREFIX = /^\/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)(?:\/|$)/; + +/** 从路径前缀取页面语言;不读 ``,浏览器翻译会改写它。 */ +export function localeFromPath(pathname: string): string | undefined { + return LOCALE_PREFIX.exec(pathname)?.[1]; +} + +export function generateErrorId(): string { + return Math.random().toString(36).slice(2, 11).toUpperCase().padEnd(9, '0'); +} + +export function sanitizeUrl(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete('_rsc'); + for (const name of SENSITIVE_PARAMS) { + if (params.has(name)) { + params.set(name, ''); + } + } + const query = params.toString(); + return (query ? `${pathname}?${query}` : pathname).slice(0, LIMITS.url); +} + +export function shouldIgnoreError( + input: Pick, +): boolean { + // 跨域脚本的错误被浏览器抹成这一句,没有任何可排查的信息 + if (/^Script error\.?$/i.test(input.message.trim())) { + return true; + } + if (/ResizeObserver loop/i.test(input.message)) { + return true; + } + return ( + EXTENSION_URL.test(input.stack ?? '') || + EXTENSION_URL.test(input.source ?? '') + ); +} + +export function isPageTranslated(doc: { + documentElement: { classList: { contains(name: string): boolean } }; + querySelector(selector: string): unknown; +}): boolean { + const { classList } = doc.documentElement; + // Chrome 内置翻译给 加 translated-ltr/rtl;Edge 给被改写的节点加 _msttexthash + return ( + classList.contains('translated-ltr') || + classList.contains('translated-rtl') || + doc.querySelector('[_msttexthash]') !== null + ); +} + +export function isControlFlowError(error: unknown): boolean { + const digest = (error as { digest?: unknown } | null)?.digest; + return ( + typeof digest === 'string' && + CONTROL_FLOW_DIGESTS.some((prefix) => digest.startsWith(prefix)) + ); +} + +export function buildPayload( + input: ErrorReportInput, + context: ReportContext, +): ClientErrorPayload { + return { + kind: input.kind, + message: (input.message || 'Unknown error').slice(0, LIMITS.message), + stack: input.stack?.slice(0, LIMITS.stack), + digest: input.digest?.slice(0, LIMITS.digest), + error_id: input.errorId?.slice(0, LIMITS.errorId), + url: sanitizeUrl(context.pathname, context.search), + locale: context.locale?.slice(0, LIMITS.locale), + build: context.build?.slice(0, LIMITS.build), + translated: context.translated, + }; +} + +/** + * 创建一个按「一次页面加载」计数的上报函数:相同错误只报一次、总数封顶, + * 且任何失败都吞掉——上报是旁路,不能反过来制造新的错误。 + */ +export function createReporter(options: { + send: (payload: ClientErrorPayload) => unknown; + getContext: () => ReportContext; + maxReports?: number; +}): (input: ErrorReportInput) => boolean { + const maxReports = options.maxReports ?? DEFAULT_MAX_REPORTS; + const seen = new Set(); + let sent = 0; + + return (input) => { + try { + if (sent >= maxReports || shouldIgnoreError(input)) { + return false; + } + const key = `${input.message}\n${input.stack ?? ''}`; + if (seen.has(key)) { + return false; + } + const payload = buildPayload(input, options.getContext()); + seen.add(key); + sent += 1; + options.send(payload); + return true; + } catch { + return false; + } + }; +} diff --git a/src/lib/error-report/server.test.ts b/src/lib/error-report/server.test.ts new file mode 100644 index 00000000..27bcf83d --- /dev/null +++ b/src/lib/error-report/server.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildServerErrorReport, reportServerError } from './server'; +import type { ServerRequestInfo } from './server'; + +const reportClientError = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/api/services/system', () => ({ + systemService: { reportClientError }, +})); + +const request = (over: Partial = {}): ServerRequestInfo => ({ + path: '/en/script-show-page/2429/manage', + method: 'GET', + headers: {}, + ...over, +}); + +afterEach(() => { + reportClientError.mockReset(); +}); + +describe('buildServerErrorReport', () => { + it('reports the real message, stack and digest of a render error', () => { + const error = Object.assign(new Error('用户不允许操作'), { + digest: '652515689', + }); + const report = buildServerErrorReport(error, request()); + expect(report?.payload).toMatchObject({ + kind: 'ssr', + message: '用户不允许操作', + digest: '652515689', + url: '/en/script-show-page/2429/manage', + locale: 'en', + }); + expect(report?.payload.stack).toContain('用户不允许操作'); + }); + + it('keeps the query string but not the sensitive or internal parameters', () => { + const report = buildServerErrorReport( + new Error('boom'), + request({ path: '/zh-CN/search?keyword=x&_rsc=abc&token=s3cret' }), + ); + expect(report?.payload.url).toBe('/zh-CN/search?keyword=x&token='); + expect(report?.payload.locale).toBe('zh-CN'); + }); + + it('leaves the locale empty when the path has no locale prefix', () => { + const report = buildServerErrorReport( + new Error('boom'), + request({ path: '/' }), + ); + expect(report?.payload.locale).toBeUndefined(); + }); + + it('describes thrown values that are not Error objects', () => { + const report = buildServerErrorReport('plain failure', request()); + expect(report?.payload.message).toBe('plain failure'); + }); + + it.each(['NEXT_NOT_FOUND', 'NEXT_REDIRECT;replace;/en/login;307;'])( + 'does not report navigation control flow (%s)', + (digest) => { + const error = Object.assign(new Error('x'), { digest }); + expect(buildServerErrorReport(error, request())).toBeNull(); + }, + ); + + it('forwards the visitor identity so the backend attributes the report to them', () => { + const report = buildServerErrorReport( + new Error('boom'), + request({ + headers: { + cookie: 'login_id=a; token=b', + 'x-forwarded-for': '203.0.113.7, 10.42.0.1', + 'x-real-ip': '203.0.113.7', + 'user-agent': 'Mozilla/5.0', + accept: 'text/html', + }, + }), + ); + expect(report?.headers).toEqual({ + Cookie: 'login_id=a; token=b', + 'X-Forwarded-For': '203.0.113.7, 10.42.0.1', + 'X-Real-IP': '203.0.113.7', + 'User-Agent': 'Mozilla/5.0', + }); + }); + + it('omits identity headers the request did not carry', () => { + const report = buildServerErrorReport(new Error('boom'), request()); + expect(report?.headers).toEqual({}); + }); +}); + +describe('reportServerError', () => { + it('sends the report with the forwarded headers', async () => { + reportClientError.mockResolvedValue({}); + await reportServerError( + new Error('boom'), + request({ headers: { 'user-agent': 'Mozilla/5.0' } }), + ); + expect(reportClientError).toHaveBeenCalledTimes(1); + const [payload, options] = reportClientError.mock.calls[0]; + expect(payload).toMatchObject({ kind: 'ssr', message: 'boom' }); + expect(options.headers).toEqual({ 'User-Agent': 'Mozilla/5.0' }); + }); + + it('does not send navigation control flow', async () => { + await reportServerError( + Object.assign(new Error('x'), { digest: 'NEXT_NOT_FOUND' }), + request(), + ); + expect(reportClientError).not.toHaveBeenCalled(); + }); + + it('swallows a failing report', async () => { + reportClientError.mockRejectedValue(new Error('backend down')); + await expect( + reportServerError(new Error('boom'), request()), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/error-report/server.ts b/src/lib/error-report/server.ts new file mode 100644 index 00000000..10423d96 --- /dev/null +++ b/src/lib/error-report/server.ts @@ -0,0 +1,83 @@ +import { systemService } from '@/lib/api/services/system'; +import { buildPayload, isControlFlowError, localeFromPath } from './core'; +import type { ClientErrorPayload } from './core'; + +/** `onRequestError` 给出的请求信息 */ +export interface ServerRequestInfo { + path: string; + method: string; + headers: Record; +} + +export interface ServerErrorReport { + payload: ClientErrorPayload; + headers: Record; +} + +// 转发访客身份:后端的限流与日志才会记在访客头上,而不是前端 pod +const FORWARDED_HEADERS: Record = { + cookie: 'Cookie', + 'x-forwarded-for': 'X-Forwarded-For', + 'x-real-ip': 'X-Real-IP', + 'user-agent': 'User-Agent', +}; + +export function buildServerErrorReport( + error: unknown, + request: ServerRequestInfo, +): ServerErrorReport | null { + if (isControlFlowError(error)) { + return null; + } + + const queryStart = request.path.indexOf('?'); + const pathname = + queryStart === -1 ? request.path : request.path.slice(0, queryStart); + const search = queryStart === -1 ? '' : request.path.slice(queryStart); + const isError = error instanceof Error; + + const headers: Record = {}; + for (const [name, forwardAs] of Object.entries(FORWARDED_HEADERS)) { + const value = request.headers[name]; + if (value) { + headers[forwardAs] = Array.isArray(value) ? value.join(', ') : value; + } + } + + return { + payload: buildPayload( + { + kind: 'ssr', + message: isError ? error.message : String(error), + stack: isError ? error.stack : undefined, + digest: (error as { digest?: string } | null)?.digest, + }, + { + pathname, + search, + locale: localeFromPath(pathname), + build: process.env.NEXT_PUBLIC_BUILD_ID, + }, + ), + headers, + }; +} + +/** 上报一次服务端渲染错误;任何失败都吞掉,不能影响原请求。 */ +export async function reportServerError( + error: unknown, + request: ServerRequestInfo, +): Promise { + try { + const report = buildServerErrorReport(error, request); + if (!report) { + return; + } + await systemService.reportClientError(report.payload, { + headers: report.headers, + timeout: 3000, + }); + } catch { + // 上报是旁路 + } +} From 86a6be69df396c779a17eca051ee2d07590a5e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 12 Oct 2026 12:03:47 +0800 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20=E9=94=99=E8=AF=AF=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E8=BF=87=E6=BB=A4=E9=A2=84=E6=9C=9F=E7=9A=84=204xx?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=A3=80=E6=B5=8B=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=AE=89=E8=A3=85=E7=8A=B6=E6=80=81=E6=97=B6=E7=9A=84?= =?UTF-8?q?=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上线首日 95 条上报里: - 38 条是页面在服务端抛出的「脚本不存在 / 已删除 / 未登录」,3 条是访客 渲染途中断开连接。这些是预期结果,不再上报;后端 5xx、超时与网络错误 照常上报。 - 4 条是 Cannot read properties of undefined (reading 'installed'):有的脚本 管理器在脚本未安装时回调不带参数,checkScriptInstalled 直接读了 res.installed。按未安装处理。 --- src/lib/error-report/server.test.ts | 42 +++++++++++++++++++++++++ src/lib/error-report/server.ts | 24 ++++++++++++++- src/lib/utils/script-manager.test.ts | 46 ++++++++++++++++++++++++++++ src/lib/utils/script-manager.ts | 7 +++-- 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/script-manager.test.ts diff --git a/src/lib/error-report/server.test.ts b/src/lib/error-report/server.test.ts index 27bcf83d..345e125e 100644 --- a/src/lib/error-report/server.test.ts +++ b/src/lib/error-report/server.test.ts @@ -64,6 +64,48 @@ describe('buildServerErrorReport', () => { }, ); + // 「脚本不存在 / 已删除 / 未登录」是页面抛出的预期结果,后端自己有记录, + // 报上来只会把真正的故障淹没(上线首日占了全部上报的四成)。 + it.each([400, 401, 403, 404, 429])( + 'does not report an expected API error (HTTP %i)', + (statusCode) => { + const error = Object.assign(new Error('脚本不存在'), { + name: 'APIError', + statusCode, + code: 101004, + }); + expect(buildServerErrorReport(error, request())).toBeNull(); + }, + ); + + it.each([ + ['a backend failure', 500], + ['a timeout or network error', -1], + ])('still reports an API error that is %s', (_label, statusCode) => { + const error = Object.assign(new Error('请求超时'), { + name: 'APIError', + statusCode, + code: 0, + }); + expect(buildServerErrorReport(error, request())?.payload.message).toBe( + '请求超时', + ); + }); + + it('still reports ordinary errors that happen to carry a 4xx-looking field', () => { + const error = Object.assign(new Error('boom'), { statusCode: 404 }); + expect(buildServerErrorReport(error, request())).not.toBeNull(); + }); + + it('does not report a visitor closing the connection mid-render', () => { + expect( + buildServerErrorReport( + new Error('The destination stream closed early.'), + request(), + ), + ).toBeNull(); + }); + it('forwards the visitor identity so the backend attributes the report to them', () => { const report = buildServerErrorReport( new Error('boom'), diff --git a/src/lib/error-report/server.ts b/src/lib/error-report/server.ts index 10423d96..9d3467e3 100644 --- a/src/lib/error-report/server.ts +++ b/src/lib/error-report/server.ts @@ -22,11 +22,33 @@ const FORWARDED_HEADERS: Record = { 'user-agent': 'User-Agent', }; +/** + * 不值得上报的服务端渲染「错误」: + * - 页面抛出的 4xx 业务错误(脚本不存在 / 已删除 / 未登录)是预期结果,后端自己有记录; + * - 访客在渲染途中断开连接。 + * 按 name 判断而不用 instanceof:instrumentation 与页面可能各打包了一份 APIError。 + */ +function isExpectedServerError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if (error.message === 'The destination stream closed early.') { + return true; + } + const { statusCode } = error as { statusCode?: unknown }; + return ( + error.name === 'APIError' && + typeof statusCode === 'number' && + statusCode >= 400 && + statusCode < 500 + ); +} + export function buildServerErrorReport( error: unknown, request: ServerRequestInfo, ): ServerErrorReport | null { - if (isControlFlowError(error)) { + if (isControlFlowError(error) || isExpectedServerError(error)) { return null; } diff --git a/src/lib/utils/script-manager.test.ts b/src/lib/utils/script-manager.test.ts new file mode 100644 index 00000000..dc240aa6 --- /dev/null +++ b/src/lib/utils/script-manager.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { checkScriptInstalled } from './script-manager'; + +type IsInstalled = ( + name: string, + namespace: string, + callback: (res: unknown) => void, +) => void; + +const stubManager = (isInstalled: IsInstalled) => { + vi.stubGlobal('window', { external: { Scriptcat: { isInstalled } } }); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('checkScriptInstalled', () => { + it('reports the installed version from the script manager', async () => { + stubManager((_n, _ns, cb) => cb({ installed: true, version: '1.2.0' })); + await expect(checkScriptInstalled('demo', 'ns')).resolves.toEqual({ + installed: true, + version: '1.2.0', + }); + }); + + // 线上上报:有的脚本管理器在脚本未安装时回调不带任何参数, + // 直接读 res.installed 会抛 TypeError。 + it.each([undefined, null])( + 'treats a callback without a result (%s) as not installed', + async (result) => { + stubManager((_n, _ns, cb) => cb(result)); + await expect(checkScriptInstalled('demo', 'ns')).resolves.toEqual({ + installed: false, + version: undefined, + }); + }, + ); + + it('is not installed when there is no script manager', async () => { + vi.stubGlobal('window', {}); + await expect(checkScriptInstalled('demo')).resolves.toEqual({ + installed: false, + }); + }); +}); diff --git a/src/lib/utils/script-manager.ts b/src/lib/utils/script-manager.ts index 180d4ce1..40acc5d2 100644 --- a/src/lib/utils/script-manager.ts +++ b/src/lib/utils/script-manager.ts @@ -6,7 +6,7 @@ export interface ScriptManagerAPI { isInstalled: ( name: string, namespace: string, - callback: (res: { installed: boolean; version: string }) => void, + callback: (res?: { installed: boolean; version: string } | null) => void, ) => void; } @@ -55,9 +55,10 @@ export function checkScriptInstalled( try { api.isInstalled(name, namespace, (res) => { + // 有的脚本管理器在脚本未安装时回调不带任何参数 resolve({ - installed: res.installed || false, - version: res.version, + installed: res?.installed || false, + version: res?.version, }); }); } catch (error) { From a0abda8281be76d7eba9ed2dc586bab9a731627d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 12 Oct 2026 14:33:18 +0800 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20=E6=97=B6=E9=97=B4=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=9C=A8=E6=B0=B4=E5=90=88=E6=97=B6=E4=B8=8E=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E4=B8=8D=E4=B8=80=E8=87=B4=EF=BC=88React=20#?= =?UTF-8?q?418=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上 100 分钟内 28 条 #418,集中在版本、反馈详情、评论页。useSemDateTime 的 输出取决于「现在」和时区,这两样服务端(UTC)与访客浏览器都不同: - 一年以上的时间显示日期:UTC 的 2023年11月26日,东八区是 27 日 - 一年以内显示相对时间:爬虫拿到 HTML 很久后才执行脚本,「1 天前」变「2 天前」 React 发现文本不一致后丢弃整段服务端 HTML 在浏览器重渲染。 水合那一遍改用服务端下发的 now 与时区复现服务端输出,水合完成后再换成访客 本地时间;语言显式传给 dayjs,不再依赖全局设置。调用方不用改。 --- .../[id]/manage/access/page.test.tsx | 5 + src/i18n/request.ts | 2 + src/lib/utils/dayjs-config.ts | 11 +- src/lib/utils/semdate.test.tsx | 120 ++++++++++++++++++ src/lib/utils/semdate.ts | 73 +++++++++-- 5 files changed, 199 insertions(+), 12 deletions(-) create mode 100644 src/lib/utils/semdate.test.tsx diff --git a/src/app/[locale]/(main)/script-show-page/[id]/manage/access/page.test.tsx b/src/app/[locale]/(main)/script-show-page/[id]/manage/access/page.test.tsx index 2412c34d..69d96524 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/manage/access/page.test.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/manage/access/page.test.tsx @@ -25,6 +25,11 @@ vi.mock('next-intl', () => ({ useLocale: () => 'zh-CN', })); +// 时间文本不是这里要验证的;useSemDateTime 还依赖 next-intl 的 now / 时区 +vi.mock('@/lib/utils/semdate', () => ({ + useSemDateTime: () => () => '2026-01-01', +})); + vi.mock('@/i18n/routing', () => ({ Link: ({ children, ...rest }: any) => {children}, })); diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 816f84e4..fd08920d 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -21,6 +21,8 @@ export default getRequestConfig(async ({ requestLocale }) => { return { locale: finalLocale, + // 下发给客户端组件,水合时用同一个「现在」算相对时间(见 useSemDateTime) + now: new Date(), messages: ( await import( `../../public/locales/${languageMap[finalLocale].locale}/translations.json` diff --git a/src/lib/utils/dayjs-config.ts b/src/lib/utils/dayjs-config.ts index 16ae5cf5..c2c0b785 100644 --- a/src/lib/utils/dayjs-config.ts +++ b/src/lib/utils/dayjs-config.ts @@ -37,14 +37,19 @@ export const dayjsLocaleMapping: Record = { 'ach-ug': 'en', // 回退到英语 }; +/** 把页面语言换成 dayjs 的语言包名,未知语言回退到英语 */ +export function resolveDayjsLocale(locale: string): string { + return ( + dayjsLocaleMapping[locale.toLocaleLowerCase()] || dayjsLocaleMapping['en'] + ); +} + /** * 设置 dayjs 的语言环境 * @param locale 语言代码 */ export function setDayjsLocale(locale: string): void { - const dayjsLocale = - dayjsLocaleMapping[locale.toLocaleLowerCase()] || dayjsLocaleMapping['en']; - dayjs.locale(dayjsLocale); + dayjs.locale(resolveDayjsLocale(locale)); } export default dayjs; diff --git a/src/lib/utils/semdate.test.tsx b/src/lib/utils/semdate.test.tsx new file mode 100644 index 00000000..ec116906 --- /dev/null +++ b/src/lib/utils/semdate.test.tsx @@ -0,0 +1,120 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { NextIntlClientProvider } from 'next-intl'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { formatSemDateTime, useSemDateTime } from './semdate'; + +// 线上 React #418 的复现:服务端(UTC)渲染出的时间文本,与访客浏览器 +// (别的时区、晚一些的时钟)水合时算出来的不一样。 +const SERVER_NOW = new Date('2026-10-12T06:00:00Z'); +// UTC 是 2023-11-26,东八区已经是 27 日 +const OLD = Date.UTC(2023, 10, 26, 16, 30) / 1000; +// 距服务端渲染 30 小时 +const RECENT = SERVER_NOW.getTime() / 1000 - 30 * 3600; + +function Times({ times }: { times: number[] }) { + const format = useSemDateTime(); + return ( +
    + {times.map((time) => ( +
  • {format(time)}
  • + ))} +
+ ); +} + +const app = (times: number[]) => ( + + + +); + +const originalTZ = process.env.TZ; + +const hydrate = async (html: string, times: number[]) => { + const container = document.createElement('div'); + container.innerHTML = html; + document.body.appendChild(container); + const recoverable: unknown[] = []; + await act(async () => { + hydrateRoot(container, app(times), { + onRecoverableError: (error) => recoverable.push(error), + }); + }); + return { + recoverable, + texts: [...container.querySelectorAll('li')].map((li) => li.textContent), + }; +}; + +beforeEach(() => { + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(SERVER_NOW); + process.env.TZ = 'UTC'; +}); + +afterEach(() => { + vi.useRealTimers(); + process.env.TZ = originalTZ; + document.body.innerHTML = ''; +}); + +describe('useSemDateTime hydration', () => { + it('hydrates without a mismatch in a browser whose timezone differs from the server', async () => { + const html = renderToString(app([OLD])); + expect(html).toContain('2023年11月26日'); + + process.env.TZ = 'Asia/Shanghai'; + const { recoverable, texts } = await hydrate(html, [OLD]); + + expect(recoverable).toEqual([]); + // 水合完成后换成访客本地时区的日期 + expect(texts).toEqual(['2023年11月27日']); + }); + + it('hydrates without a mismatch when the page is rendered long after the server responded', async () => { + const html = renderToString(app([RECENT])); + expect(html).toContain('1 天前'); + + // 爬虫常常拿到 HTML 很久之后才执行脚本 + vi.setSystemTime(new Date(SERVER_NOW.getTime() + 30 * 3600 * 1000)); + const { recoverable, texts } = await hydrate(html, [RECENT]); + + expect(recoverable).toEqual([]); + expect(texts).toEqual(['3 天前']); + }); +}); + +describe('formatSemDateTime', () => { + const now = SERVER_NOW.getTime() / 1000; + + it('shows the calendar date for times more than a year old, in the given timezone', () => { + const base = { now, timeFormat: 'YYYY-MM-DD', locale: 'en' }; + expect(formatSemDateTime(OLD, { ...base, timeZone: 'UTC' })).toBe( + '2023-11-26', + ); + expect(formatSemDateTime(OLD, { ...base, timeZone: 'Asia/Shanghai' })).toBe( + '2023-11-27', + ); + }); + + it('shows recent times relative to the given moment, in the given language', () => { + const base = { now, timeFormat: 'YYYY-MM-DD' }; + expect(formatSemDateTime(RECENT, { ...base, locale: 'en' })).toBe( + 'a day ago', + ); + expect(formatSemDateTime(RECENT, { ...base, locale: 'de' })).toBe( + 'vor einem Tag', + ); + }); +}); diff --git a/src/lib/utils/semdate.ts b/src/lib/utils/semdate.ts index 1606a0b1..2ae94244 100644 --- a/src/lib/utils/semdate.ts +++ b/src/lib/utils/semdate.ts @@ -1,6 +1,48 @@ +import { useSyncExternalStore } from 'react'; import { getTranslations } from 'next-intl/server'; -import { useTranslations } from 'next-intl'; -import dayjs from './dayjs-config'; +import { useLocale, useNow, useTimeZone, useTranslations } from 'next-intl'; +import dayjs, { resolveDayjsLocale } from './dayjs-config'; + +const ONE_YEAR = 365 * 24 * 60 * 60; + +export interface SemDateTimeOptions { + /** 「现在」的秒级时间戳 */ + now: number; + /** 超过一年时使用的日期格式 */ + timeFormat: string; + locale?: string; + /** 不传则用运行环境的本地时区 */ + timeZone?: string; +} + +/** 超过一年显示年月日,否则显示相对时间;结果只取决于入参。 */ +export function formatSemDateTime( + time: number, + { now, timeFormat, locale, timeZone }: SemDateTimeOptions, +): string { + let date = dayjs.unix(time); + if (timeZone) { + date = date.tz(timeZone); + } + if (locale) { + date = date.locale(resolveDayjsLocale(locale)); + } + if (time < now - ONE_YEAR) { + return date.format(timeFormat); + } + return date.from(dayjs.unix(now)); +} + +const subscribeNever = () => () => {}; + +/** 服务端渲染与水合那一遍为 false,水合完成后为 true */ +function useHydrated(): boolean { + return useSyncExternalStore( + subscribeNever, + () => true, + () => false, + ); +} export async function semDateTime(time: number): Promise { const t = await getTranslations('utils'); @@ -13,14 +55,27 @@ export async function semDateTime(time: number): Promise { export function useSemDateTime() { const t = useTranslations('utils'); + const locale = useLocale(); + const serverNow = useNow(); + const serverTimeZone = useTimeZone(); + const hydrated = useHydrated(); + const timeFormat = t('time_format'); - return (time: number): string => { - // 如果大于一年,显示年月日 - if (time < new Date().getTime() / 1000 - 365 * 24 * 60 * 60) { - return dayjs(new Date(time * 1000)).format(t('time_format')); - } - return dayjs(new Date(time * 1000)).fromNow(); - }; + // 时间文本取决于「现在」和时区,这两样服务端与访客浏览器都不同。水合那一遍必须 + // 复现服务端的输出(用服务端下发的 now 与时区),否则 React 报 #418 并丢弃整段 + // 服务端 HTML;水合完成后再换成访客本地的时间。 + return (time: number): string => + formatSemDateTime( + time, + hydrated + ? { now: Date.now() / 1000, timeFormat, locale } + : { + now: serverNow.getTime() / 1000, + timeFormat, + locale, + timeZone: serverTimeZone, + }, + ); } // 将数字按照千分位格式化 From 91f5d47c5973d2a8410844e2660fe14743957277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 12 Oct 2026 14:33:18 +0800 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=E8=AF=B7=E6=B1=82=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E9=94=99=E8=AF=AF=E4=B8=8A=E6=8A=A5=E5=B8=A6?= =?UTF-8?q?=E4=B8=8A=E6=8E=A5=E5=8F=A3=E3=80=81=E7=8A=B6=E6=80=81=E7=A0=81?= =?UTF-8?q?=E4=B8=8E=E8=80=97=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上约 110 条浏览器端「请求超时 / 解析响应数据失败 / Failed to fetch」堆栈只到 apiClient._request,看不出是哪个接口。 - APIError 记下失败请求的方法、路径(不含查询串)、耗时与响应类型 - 上报时拼在消息后:请求超时 [GET /scripts/1/state status=-1 20003ms]; 不参与去重,同一错误仍只报一次 - Promise 拒绝、两个错误边界与 SSR 上报都带 新日志暴露的第一个来源:AdSlot 的曝光上报没有 catch,请求被广告拦截器掐掉 或超时就成了未处理的 Promise 拒绝。曝光统计是旁路,失败时静默。 --- src/app/[locale]/(main)/error.test.tsx | 17 ++++ src/app/[locale]/(main)/error.tsx | 3 +- src/app/global-error.tsx | 7 +- src/components/AdSlot/AdSlot.test.tsx | 23 +++++ src/components/AdSlot/index.tsx | 5 +- src/components/ClientErrorReporter.test.tsx | 18 ++++ src/components/ClientErrorReporter.tsx | 2 + src/lib/api/client.request.test.ts | 95 +++++++++++++++++++++ src/lib/api/client.ts | 32 +++++-- src/lib/error-report/core.test.ts | 68 +++++++++++++++ src/lib/error-report/core.ts | 34 +++++++- src/lib/error-report/server.test.ts | 15 ++++ src/lib/error-report/server.ts | 8 +- src/types/api.ts | 12 +++ 14 files changed, 329 insertions(+), 10 deletions(-) create mode 100644 src/lib/api/client.request.test.ts diff --git a/src/app/[locale]/(main)/error.test.tsx b/src/app/[locale]/(main)/error.test.tsx index f24117c3..28407dd0 100644 --- a/src/app/[locale]/(main)/error.test.tsx +++ b/src/app/[locale]/(main)/error.test.tsx @@ -55,4 +55,21 @@ describe('route error boundary', () => { // 用户截图里的 ID 必须能在日志里搜到 expect(screen.getByText(new RegExp(report.errorId))).toBeInTheDocument(); }); + + it('names the failed request when an API call crashed the page', () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const error = Object.assign(new Error('network error'), { + name: 'APIError', + statusCode: -1, + request: { method: 'GET', endpoint: '/scripts', duration: 12 }, + }); + + render( {}} />); + + expect(reportClientError.mock.calls[0][0]).toMatchObject({ + kind: 'boundary', + message: 'network error', + detail: 'GET /scripts status=-1 12ms', + }); + }); }); diff --git a/src/app/[locale]/(main)/error.tsx b/src/app/[locale]/(main)/error.tsx index 5b73a5af..a11c215e 100644 --- a/src/app/[locale]/(main)/error.tsx +++ b/src/app/[locale]/(main)/error.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import ErrorPage from '@/components/ErrorPage'; import { reportClientError } from '@/lib/error-report/client'; -import { generateErrorId } from '@/lib/error-report/core'; +import { generateErrorId, requestDetail } from '@/lib/error-report/core'; export default function Error({ error, @@ -24,6 +24,7 @@ export default function Error({ stack: error.stack, digest: error.digest, errorId, + detail: requestDetail(error), }); }, [error, errorId]); diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index bec3901d..3f0c435e 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -3,7 +3,11 @@ import { useEffect, useState } from 'react'; import { languageMap } from '@/i18n/routing'; import { reportClientError } from '@/lib/error-report/client'; -import { generateErrorId, localeFromPath } from '@/lib/error-report/core'; +import { + generateErrorId, + localeFromPath, + requestDetail, +} from '@/lib/error-report/core'; // 根布局崩溃时 next-intl 的 Provider 和全局样式都不在了,这里只能自带文案与内联样式。 // 英文作兜底,再按路径里的语言去取与 500 错误页相同的翻译。 @@ -38,6 +42,7 @@ export default function GlobalError({ stack: error.stack, digest: error.digest, errorId, + detail: requestDetail(error), }); }, [error, errorId]); diff --git a/src/components/AdSlot/AdSlot.test.tsx b/src/components/AdSlot/AdSlot.test.tsx index 3f0fced8..817c8899 100644 --- a/src/components/AdSlot/AdSlot.test.tsx +++ b/src/components/AdSlot/AdSlot.test.tsx @@ -149,6 +149,29 @@ describe('AdSlot 图片广告分支', () => { ); }); + it('曝光上报失败时静默,不产生未处理的 Promise 拒绝', async () => { + // 广告拦截器会直接掐掉 /advertise/*/impression,线上表现为一条条 unhandledrejection 上报 + // 用真实的 service、只让 fetch 失败:vi.fn 会给自己返回的 Promise 挂处理器,测不出「没人接」 + vi.mocked(advertiseService.reportImpression).mockRestore(); + const fetchMock = vi.fn(() => + Promise.reject(new TypeError('Failed to fetch')), + ); + vi.stubGlobal('fetch', fetchMock); + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + try { + setAd(imageAd); + renderSlot('banner'); + enterViewport(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + process.off('unhandledRejection', unhandled); + } + }); + it('card 变体披露文案带素材标题', () => { setAd(imageAd); const { container } = renderSlot('card', 'search-sidebar'); diff --git a/src/components/AdSlot/index.tsx b/src/components/AdSlot/index.tsx index 6248bb7a..712549cd 100644 --- a/src/components/AdSlot/index.tsx +++ b/src/components/AdSlot/index.tsx @@ -52,7 +52,10 @@ export default function AdSlot({ const ob = new IntersectionObserver((entries) => { if (entries.some((e) => e.isIntersecting)) { reportedAdId.current = currentId; - void advertiseService.reportImpression(currentId, slot, locale); + // 曝光统计是旁路:被广告拦截器掐掉或超时都不该变成未处理的 Promise 拒绝 + advertiseService + .reportImpression(currentId, slot, locale) + .catch(() => {}); ob.disconnect(); } }); diff --git a/src/components/ClientErrorReporter.test.tsx b/src/components/ClientErrorReporter.test.tsx index 6ce9a22a..47141e14 100644 --- a/src/components/ClientErrorReporter.test.tsx +++ b/src/components/ClientErrorReporter.test.tsx @@ -51,6 +51,24 @@ describe('ClientErrorReporter', () => { }); }); + it('names the failed request when an API call is rejected', () => { + render(); + const error = Object.assign(new Error('请求超时'), { + name: 'APIError', + statusCode: -1, + request: { method: 'GET', endpoint: '/scripts/1/state', duration: 20003 }, + }); + + dispatchRejection(error); + + expect(reportClientError).toHaveBeenCalledWith({ + kind: 'rejection', + message: '请求超时', + stack: error.stack, + detail: 'GET /scripts/1/state status=-1 20003ms', + }); + }); + it('describes rejections whose reason is not an Error', () => { render(); diff --git a/src/components/ClientErrorReporter.tsx b/src/components/ClientErrorReporter.tsx index b238dad0..be7bbb5e 100644 --- a/src/components/ClientErrorReporter.tsx +++ b/src/components/ClientErrorReporter.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { reportClientError } from '@/lib/error-report/client'; +import { requestDetail } from '@/lib/error-report/core'; /** * 上报错误边界接不到的浏览器端错误:事件回调、定时器、异步流程里抛出的异常。 @@ -22,6 +23,7 @@ export default function ClientErrorReporter() { kind: 'rejection', message: reason instanceof Error ? reason.message : String(reason), stack: reason instanceof Error ? reason.stack : undefined, + detail: requestDetail(reason), }); }; diff --git a/src/lib/api/client.request.test.ts b/src/lib/api/client.request.test.ts new file mode 100644 index 00000000..5b77cb15 --- /dev/null +++ b/src/lib/api/client.request.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { apiClient } from './client'; +import { APIError } from '@/types/api'; + +// 上报的请求失败要能归因到具体接口:APIError 需带上请求的方法、路径与耗时。 +const catchError = async (promise: Promise): Promise => { + try { + await promise; + } catch (error) { + return error as APIError; + } + throw new Error('expected the request to fail'); +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('apiClient request failures', () => { + it('records which request timed out', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.reject( + Object.assign(new Error('aborted'), { name: 'AbortError' }), + ), + ), + ); + + const error = await catchError( + apiClient.get('/scripts/8274/state', { keyword: 'secret' }), + ); + + expect(error).toBeInstanceOf(APIError); + expect(error.message).toBe('请求超时'); + expect(error.request).toMatchObject({ + method: 'GET', + endpoint: '/scripts/8274/state', + }); + expect(error.request?.duration).toBeGreaterThanOrEqual(0); + }); + + it('records the request behind a network error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))), + ); + + const error = await catchError( + apiClient.post('/scripts/1/watch', { a: 1 }), + ); + + expect(error.message).toBe('网络错误: Failed to fetch'); + expect(error.request).toMatchObject({ + method: 'POST', + endpoint: '/scripts/1/watch', + }); + }); + + it('records the status and content type of a non-JSON response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + new Response('502 Bad Gateway', { + status: 502, + headers: { 'content-type': 'text/html' }, + }), + ), + ), + ); + + const error = await catchError(apiClient.get('/scripts/1')); + + expect(error.message).toBe('解析响应数据失败'); + expect(error.statusCode).toBe(502); + expect(error.request).toMatchObject({ + method: 'GET', + endpoint: '/scripts/1', + contentType: 'text/html', + }); + }); + + it('strips a query string written into the endpoint', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))), + ); + + const error = await catchError(apiClient.get('/login/oauth?code=s3cret')); + + expect(error.request?.endpoint).toBe('/login/oauth'); + }); +}); diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 1e29c6be..8a028f22 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -248,10 +248,33 @@ class APIClient { } } + /** + * 发起请求并解析响应;失败时把这次请求记到 APIError 上,错误上报才能归因到接口 + */ + private async send( + endpoint: string, + options: RequestOptions = {}, + ): Promise { + const startedAt = Date.now(); + let response: Response | undefined; + try { + response = await this._request(endpoint, options); + return await this.handleResponse(response); + } catch (error) { + if (error instanceof APIError) { + error.request = { + method: (options.method ?? 'GET').toUpperCase(), + endpoint: endpoint.split('?')[0], + duration: Date.now() - startedAt, + contentType: response?.headers.get('content-type') ?? undefined, + }; + } + throw error; + } + } + async request(endpoint: string, options: RequestOptions = {}): Promise { - return this._request(endpoint, options).then((resp) => - this.handleResponse(resp), - ); + return this.send(endpoint, options); } async requestWithCookie( @@ -290,8 +313,7 @@ class APIClient { } options.headers = headers; - const resp = await this._request(endpoint, options); - return this.handleResponse(resp); + return this.send(endpoint, options); } return this.request(endpoint, options); } diff --git a/src/lib/error-report/core.test.ts b/src/lib/error-report/core.test.ts index 2eb40cf6..c7d27f0b 100644 --- a/src/lib/error-report/core.test.ts +++ b/src/lib/error-report/core.test.ts @@ -6,6 +6,7 @@ import { isControlFlowError, isPageTranslated, localeFromPath, + requestDetail, sanitizeUrl, shouldIgnoreError, } from './core'; @@ -178,6 +179,18 @@ describe('buildPayload', () => { expect(payload.build).toHaveLength(64); }); + it('appends the detail to the message', () => { + const payload = buildPayload( + { + kind: 'rejection', + message: '请求超时', + detail: 'GET /scripts/1 status=-1 20003ms', + }, + context, + ); + expect(payload.message).toBe('请求超时 [GET /scripts/1 status=-1 20003ms]'); + }); + it('never sends an empty message', () => { expect( buildPayload({ kind: 'rejection', message: '' }, context).message, @@ -185,6 +198,46 @@ describe('buildPayload', () => { }); }); +describe('requestDetail', () => { + const apiError = (over: Record) => + Object.assign(new Error('请求超时'), { name: 'APIError', ...over }); + + it('describes the failed request: method, endpoint, status and duration', () => { + const error = apiError({ + statusCode: -1, + request: { + method: 'GET', + endpoint: '/scripts/8274/state', + duration: 20003, + }, + }); + expect(requestDetail(error)).toBe( + 'GET /scripts/8274/state status=-1 20003ms', + ); + }); + + it('adds the content type when the response was not JSON', () => { + const error = apiError({ + statusCode: 502, + request: { + method: 'GET', + endpoint: '/scripts/1', + duration: 31, + contentType: 'text/html', + }, + }); + expect(requestDetail(error)).toBe( + 'GET /scripts/1 status=502 31ms content-type=text/html', + ); + }); + + it('is undefined for errors that did not come from a request', () => { + expect(requestDetail(new Error('boom'))).toBeUndefined(); + expect(requestDetail('boom')).toBeUndefined(); + expect(requestDetail(null)).toBeUndefined(); + }); +}); + describe('createReporter', () => { const setup = (maxReports?: number) => { const sent: ClientErrorPayload[] = []; @@ -220,6 +273,21 @@ describe('createReporter', () => { expect(sent).toHaveLength(1); }); + it('treats the same error as a duplicate even when its detail differs', () => { + const { report, sent } = setup(); + report({ + kind: 'rejection', + message: '请求超时', + detail: 'GET /a 20001ms', + }); + report({ + kind: 'rejection', + message: '请求超时', + detail: 'GET /a 20007ms', + }); + expect(sent).toHaveLength(1); + }); + it('stops after five reports per page load', () => { const { sent, report } = setup(); for (let i = 0; i < 8; i++) { diff --git a/src/lib/error-report/core.ts b/src/lib/error-report/core.ts index e96ffe19..49f9ae23 100644 --- a/src/lib/error-report/core.ts +++ b/src/lib/error-report/core.ts @@ -1,3 +1,5 @@ +import type { APIRequestInfo } from '@/types/api'; + export type ClientErrorKind = 'boundary' | 'uncaught' | 'rejection' | 'ssr'; /** 上报给后端的请求体,字段与长度上限同后端 `ReportClientErrorRequest`。 */ @@ -22,6 +24,8 @@ export interface ErrorReportInput { errorId?: string; /** 抛出错误的脚本地址(`ErrorEvent.filename`) */ source?: string; + /** 附在消息后的补充信息(如失败的接口);不参与去重 */ + detail?: string; } export interface ReportContext { @@ -120,13 +124,41 @@ export function isControlFlowError(error: unknown): boolean { ); } +/** + * 请求失败的错误只有「请求超时」这类消息,看不出是哪个接口; + * 取出 `APIError.request` 里记下的方法、路径、状态码与耗时。 + */ +export function requestDetail(error: unknown): string | undefined { + const { request, statusCode } = (error ?? {}) as { + request?: APIRequestInfo; + statusCode?: number; + }; + if (!request) { + return undefined; + } + const parts = [ + request.method, + request.endpoint, + `status=${statusCode}`, + `${request.duration}ms`, + ]; + if (request.contentType && !request.contentType.includes('json')) { + parts.push(`content-type=${request.contentType}`); + } + return parts.join(' '); +} + export function buildPayload( input: ErrorReportInput, context: ReportContext, ): ClientErrorPayload { + const message = input.message || 'Unknown error'; return { kind: input.kind, - message: (input.message || 'Unknown error').slice(0, LIMITS.message), + message: (input.detail ? `${message} [${input.detail}]` : message).slice( + 0, + LIMITS.message, + ), stack: input.stack?.slice(0, LIMITS.stack), digest: input.digest?.slice(0, LIMITS.digest), error_id: input.errorId?.slice(0, LIMITS.errorId), diff --git a/src/lib/error-report/server.test.ts b/src/lib/error-report/server.test.ts index 345e125e..c126cc3f 100644 --- a/src/lib/error-report/server.test.ts +++ b/src/lib/error-report/server.test.ts @@ -34,6 +34,21 @@ describe('buildServerErrorReport', () => { expect(report?.payload.stack).toContain('用户不允许操作'); }); + it('names the backend request that failed during rendering', () => { + const error = Object.assign(new Error('请求超时'), { + name: 'APIError', + statusCode: -1, + request: { method: 'GET', endpoint: '/scripts', duration: 20004 }, + }); + const report = buildServerErrorReport( + error, + request({ path: '/zh-CN/search?keyword=x' }), + ); + expect(report?.payload.message).toBe( + '请求超时 [GET /scripts status=-1 20004ms]', + ); + }); + it('keeps the query string but not the sensitive or internal parameters', () => { const report = buildServerErrorReport( new Error('boom'), diff --git a/src/lib/error-report/server.ts b/src/lib/error-report/server.ts index 9d3467e3..f4e69291 100644 --- a/src/lib/error-report/server.ts +++ b/src/lib/error-report/server.ts @@ -1,5 +1,10 @@ import { systemService } from '@/lib/api/services/system'; -import { buildPayload, isControlFlowError, localeFromPath } from './core'; +import { + buildPayload, + isControlFlowError, + localeFromPath, + requestDetail, +} from './core'; import type { ClientErrorPayload } from './core'; /** `onRequestError` 给出的请求信息 */ @@ -73,6 +78,7 @@ export function buildServerErrorReport( message: isError ? error.message : String(error), stack: isError ? error.stack : undefined, digest: (error as { digest?: string } | null)?.digest, + detail: requestDetail(error), }, { pathname, diff --git a/src/types/api.ts b/src/types/api.ts index 943f6a0c..07a940e8 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -21,8 +21,20 @@ export interface PageRequest { size?: number; } +/** 失败的那次请求,供错误上报归因到具体接口 */ +export interface APIRequestInfo { + method: string; + /** 不含查询串的接口路径 */ + endpoint: string; + /** 从发起到失败的毫秒数 */ + duration: number; + contentType?: string; +} + // API错误类型 export class APIError extends Error { + request?: APIRequestInfo; + constructor( public statusCode: number, public code: number, From 21ff06c8a882f4f79fd0d634344b9327ed2f1f52 Mon Sep 17 00:00:00 2001 From: CodFrm Date: Sun, 18 Oct 2026 11:52:08 +0800 Subject: [PATCH 11/12] perf(script-detail): lazy load thumbnails and detail tabs --- .../components/ScriptRatingClient.test.tsx | 5 + .../[id]/components/ScriptDetailClient.tsx | 157 ++----------- .../[id]/components/ScriptDetailTabs.test.tsx | 101 +++++++++ .../[id]/components/ScriptDetailTabs.tsx | 207 ++++++++++++++++++ .../(main)/script-show-page/[id]/page.tsx | 75 ++----- .../(main)/script-show-page/[id]/utils.ts | 23 +- .../components/ScriptVersionsClient.test.tsx | 34 +++ .../components/ScriptVersionsClient.tsx | 151 +++++++++---- src/components/ScriptIcon.test.tsx | 54 +++++ src/components/ScriptIcon.tsx | 19 +- 10 files changed, 580 insertions(+), 246 deletions(-) create mode 100644 src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.test.tsx create mode 100644 src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.tsx create mode 100644 src/components/ScriptIcon.test.tsx diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx index 21a0cdaa..c2c41f63 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx @@ -142,6 +142,11 @@ describe('ScriptRatingClient 的初始数据', () => { ); expect(lastListProps?.ratings).toHaveLength(0); + expect(useScoreListMock).toHaveBeenCalledWith( + 1, + expect.objectContaining({ page: 1, size: 10 }), + null, + ); }); }); diff --git a/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailClient.tsx b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailClient.tsx index 59dac106..64889569 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailClient.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailClient.tsx @@ -21,7 +21,6 @@ import { Badge, Input, Select, - Tabs, } from 'antd'; import { DownloadOutlined, @@ -47,12 +46,6 @@ import { useScript, useScriptState } from './ScriptContext'; import { useUser } from '@/contexts/UserContext'; import { useSemDateTime, formatCompactNumber } from '@/lib/utils/semdate'; import { Link, useRouter } from '@/i18n/routing'; -import dynamic from 'next/dynamic'; -import MarkdownViewLoading from '@/components/MarkdownView/MarkdownViewLoading'; -// 不给 `loading` 的话 fallback 是 `null`,客户端导航时整段正文会塌成 0px。 -const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { - loading: () => , -}); import { ScriptUtils } from '../utils'; import { getLicenseDisplay } from '@/lib/license'; import { copyToClipboard, hashColor } from '@/lib/utils/utils'; @@ -64,16 +57,14 @@ import { useScriptWatch, useScriptFavorite } from '@/lib/api/hooks'; import { WatchLevel } from '../types'; import { scriptService } from '@/lib/api/services/scripts'; import { aiReviewService } from '@/lib/api/services/aiReview'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; import ActionMenu from '@/components/ActionMenu'; import AdSlot from '@/components/AdSlot'; import SideRails from '@/components/AdSlot/SideRails'; import { railContainerProps } from '@/components/AdSlot/railLayout'; import ScriptIcon from '@/components/ScriptIcon'; import type { AdSlotItem } from '@/lib/api/services/advertise'; -import ScriptVersionsClient from '../version/components/ScriptVersionsClient'; -import ScriptRatingClient from '../comment/components/ScriptRatingClient'; -import ScriptMetadataExplainer from './ScriptMetadataExplainer'; +import ScriptDetailTabs from './ScriptDetailTabs'; import ScriptPermissionsCard from './overview/ScriptPermissionsCard'; import type { ScoreListItem, @@ -81,9 +72,8 @@ import type { VersionStatResponse, } from '@/lib/api/services/scripts/scripts'; import { APIError } from '@/types/api'; -import type { ListData, LocalizedSummary } from '@/types/api'; +import type { ListData } from '@/types/api'; import type { RatingStats } from '../comment/components/rating'; -import { selectLocalizedSummary } from '../localizedSummary'; const { Title, Text, Paragraph } = Typography; @@ -109,51 +99,6 @@ interface ScriptDetailClientProps { }; } -function CountChip({ value }: { value: number }) { - return ( - - {value} - - ); -} - -function AISummary({ summary }: { summary: LocalizedSummary }) { - const t = useTranslations('script.detail'); - const locale = useLocale(); - const [expanded, setExpanded] = useState(true); - const text = selectLocalizedSummary(summary, locale); - if (!text.trim()) return null; - return ( -
- - {expanded && ( -
- {text} -
- )} -
- ); -} - // 生成@require链接的函数 function genRequire( scriptId: number, @@ -743,81 +688,6 @@ export default function ScriptDetailClient({ ], ); - const detailTabs = useMemo( - () => [ - { - key: 'description', - label: t('tabs.description'), - children: ( -
- {script.summary && } -
- -
-
- ), - }, - { - key: 'versions', - label: ( - - {t('tabs.versions')} - - - ), - children: ( - - ), - }, - { - key: 'ratings', - label: ( - - {t('tabs.ratings')} - - - ), - children: ( - - ), - }, - { - key: 'metadata', - label: t('tabs.metadata'), - children: , - }, - ], - [ - content, - initialRatingStats, - initialScoreList, - initialVersionData, - script.id, - script.summary, - script.script.meta_json, - t, - versionError, - versionStat, - ], - ); - // 数据统计卡(桌面侧栏与移动端共用,移动端展示在描述上方) const statsCard = ( - {/* 内容 Tab 卡 */} - - - +
diff --git a/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.test.tsx b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.test.tsx new file mode 100644 index 00000000..913b6dd5 --- /dev/null +++ b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.test.tsx @@ -0,0 +1,101 @@ +/** @vitest-environment jsdom */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import ScriptDetailTabs from './ScriptDetailTabs'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'zh-CN', +})); + +beforeAll(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + unobserve() {} + }, + ); +}); + +const script = { + id: 7, + name: 'demo', + summary: {}, + script: { meta_json: { name: ['demo'] } }, +}; + +vi.mock('./ScriptContext', () => ({ + useScript: () => ({ script }), +})); + +vi.mock('@/components/MarkdownView', () => ({ + default: () =>
, +})); + +vi.mock('./ScriptMetadataExplainer', () => ({ + default: () =>
, +})); + +vi.mock('../version/components/ScriptVersionsClient', () => ({ + default: () =>
, +})); + +vi.mock('../comment/components/ScriptRatingClient', () => ({ + default: () =>
, +})); + +// Keep the test focused on the tab's mount boundary; the production file still +// uses next/dynamic to load these two panels. +vi.mock('next/dynamic', () => ({ + default: () => { + return function MockDynamicPanel(props: { id?: string }) { + return props.id === 'readme' ? ( +
+ ) : ( +
+ ); + }; + }, +})); + +const commonProps = { + content: 'readme', + initialVersionData: null, + versionStat: null, + versionError: undefined, + initialScoreList: null, + initialRatingStats: { + averageRating: 0, + totalRatings: 12, + distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }, + }, +}; + +describe('ScriptDetailTabs lazy panels', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(cleanup); + + it('does not mount version or rating panels while description is active', () => { + render(); + + expect(screen.getByTestId('description-content')).toBeInTheDocument(); + expect(screen.queryByTestId('dynamic-panel')).not.toBeInTheDocument(); + }); + + it('mounts the selected panel only after its tab is activated', () => { + render(); + + fireEvent.click(screen.getByText('tabs.versions')); + expect(screen.getByTestId('dynamic-panel')).toBeInTheDocument(); + }); +}); diff --git a/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.tsx b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.tsx new file mode 100644 index 00000000..fae2f8c6 --- /dev/null +++ b/src/app/[locale]/(main)/script-show-page/[id]/components/ScriptDetailTabs.tsx @@ -0,0 +1,207 @@ +'use client'; + +import dynamic from 'next/dynamic'; +import { useMemo, useState } from 'react'; +import { Card, Tabs } from 'antd'; +import { Icon } from '@iconify/react'; +import { useLocale, useTranslations } from 'next-intl'; +import MarkdownViewLoading from '@/components/MarkdownView/MarkdownViewLoading'; +import type { + ScoreListItem, + VersionListResponse, + VersionStatResponse, +} from '@/lib/api/services/scripts/scripts'; +import type { ListData } from '@/types/api'; +import type { RatingStats } from '../comment/components/rating'; +import { useScript } from './ScriptContext'; +import { selectLocalizedSummary } from '../localizedSummary'; +import ScriptMetadataExplainer from './ScriptMetadataExplainer'; + +const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { + loading: () => , +}); +const LazyScriptVersionsClient = dynamic( + () => import('../version/components/ScriptVersionsClient'), + { loading: () => }, +); +const LazyScriptRatingClient = dynamic( + () => import('../comment/components/ScriptRatingClient'), + { loading: () => }, +); + +function CountChip({ value }: { value?: number }) { + if (value === undefined) return null; + return ( + + {value} + + ); +} + +function AISummary({ + summary, +}: { + summary: NonNullable['script']['summary']>; +}) { + const t = useTranslations('script.detail'); + const locale = useLocale(); + const [expanded, setExpanded] = useState(true); + const text = selectLocalizedSummary(summary, locale) || ''; + if (!text.trim()) return null; + return ( +
+ + {expanded && ( +
+ {text} +
+ )} +
+ ); +} + +interface ScriptDetailTabsProps { + content: string; + contentBaseUrl?: string; + contentRootUrl?: string; + initialVersionData: VersionListResponse | null; + versionStat: VersionStatResponse | null; + versionError?: string; + initialScoreList: ListData | null; + initialRatingStats: RatingStats; +} + +export default function ScriptDetailTabs({ + content, + contentBaseUrl, + contentRootUrl, + initialVersionData, + versionStat, + versionError, + initialScoreList, + initialRatingStats, +}: ScriptDetailTabsProps) { + const { script } = useScript(); + const t = useTranslations('script.detail'); + const [activeKey, setActiveKey] = useState('description'); + const [versionTotal, setVersionTotal] = useState( + initialVersionData?.total, + ); + + const detailTabs = useMemo( + () => [ + { + key: 'description', + label: t('tabs.description'), + children: ( +
+ {script.summary && } +
+ +
+
+ ), + }, + { + key: 'versions', + label: ( + + {t('tabs.versions')} + + + ), + children: + activeKey === 'versions' ? ( + + ) : null, + }, + { + key: 'ratings', + label: ( + + {t('tabs.ratings')} + + + ), + children: + activeKey === 'ratings' ? ( + + ) : null, + }, + { + key: 'metadata', + label: t('tabs.metadata'), + children: , + }, + ], + [ + activeKey, + content, + contentBaseUrl, + contentRootUrl, + initialRatingStats, + initialScoreList, + initialVersionData, + script.id, + script.script.meta_json, + script.summary, + t, + versionError, + versionStat, + versionTotal, + ], + ); + + return ( + + + + ); +} diff --git a/src/app/[locale]/(main)/script-show-page/[id]/page.tsx b/src/app/[locale]/(main)/script-show-page/[id]/page.tsx index 7c44b1e6..a132e9e8 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/page.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/page.tsx @@ -7,7 +7,6 @@ import { generateScriptMetadata } from './metadata'; import scriptService from '@/lib/api/services/scripts'; import { prefetchAd } from '@/lib/api/services/advertise'; import { prefetchRails } from '@/components/AdSlot/railLayout'; -import { calculateRatingStats } from './comment/components/rating/utils'; export default async function ScriptDetailPage({ params, @@ -15,34 +14,17 @@ export default async function ScriptDetailPage({ const { id, locale } = await params; // Fetch content separately - React.cache deduplicates with layout's call const script = await scriptService.infoCached(id); - const scriptId = parseInt(id, 10); - const [ - versionListResult, - versionStatResult, - scoreStateResult, - scoreListResult, - sidebarAdResult, - bannerAdResult, - railsResult, - ] = await Promise.allSettled([ - scriptService.getVersionListCached(scriptId, { page: 1, size: 10 }), - scriptService.getVersionStatCached(scriptId), - scriptService.getScoreState(scriptId), - scriptService.getScoreList(scriptId, { - page: 1, - size: 10, - sort: 'createtime', - order: 'desc', - }), - prefetchAd('script-detail-sidebar', locale), - prefetchAd('script-detail-banner', locale), - prefetchRails( - 'script-detail-rail-left', - 'script-detail-rail-right', - locale, - ), - ]); + const [sidebarAdResult, bannerAdResult, railsResult] = + await Promise.allSettled([ + prefetchAd('script-detail-sidebar', locale), + prefetchAd('script-detail-banner', locale), + prefetchRails( + 'script-detail-rail-left', + 'script-detail-rail-right', + locale, + ), + ]); const sidebarAd = sidebarAdResult.status === 'fulfilled' ? sidebarAdResult.value : undefined; @@ -54,29 +36,20 @@ export default async function ScriptDetailPage({ ? railsResult.value : { left: undefined, right: undefined, hasAd: false }; - const initialVersionData = - versionListResult.status === 'fulfilled' ? versionListResult.value : null; - const versionStat = - versionStatResult.status === 'fulfilled' ? versionStatResult.value : null; - const initialScoreList = - scoreListResult.status === 'fulfilled' ? scoreListResult.value : null; - const ratingStats = - scoreStateResult.status === 'fulfilled' - ? calculateRatingStats(scoreStateResult.value) - : { - averageRating: - script.score && script.score_num - ? parseFloat((script.score / script.score_num / 10).toFixed(1)) - : 0, - totalRatings: script.score_num, - distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }, - }; - const versionError = - versionListResult.status === 'rejected' - ? versionListResult.reason?.message - : versionStatResult.status === 'rejected' - ? versionStatResult.reason?.message - : undefined; + // 版本与评分只在用户打开对应 Tab 后请求,避免详情首屏等待两组重数据。 + const initialVersionData = null; + const versionStat = null; + const initialScoreList = null; + // 脚本详情 DTO 自带评分均值和评分数;用它初始化概览,避免未知计数伪装成 0。 + const ratingStats = { + averageRating: + script.score && script.score_num + ? parseFloat((script.score / script.score_num / 10).toFixed(1)) + : 0, + totalRatings: script.score_num, + distribution: { 5: 0, 4: 0, 3: 0, 2: 0, 1: 0 }, + }; + const versionError = undefined; // 访问统计是一次写请求,`await` 它等于把 TTFB 押在统计接口上。 // 交给 `after()` 在响应发出之后再跑:既不阻塞 JSX 返回,也不会像裸 Promise diff --git a/src/app/[locale]/(main)/script-show-page/[id]/utils.ts b/src/app/[locale]/(main)/script-show-page/[id]/utils.ts index c1dc8561..106ec69f 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/utils.ts +++ b/src/app/[locale]/(main)/script-show-page/[id]/utils.ts @@ -73,15 +73,24 @@ export class ScriptUtils { } /** - * 返回图标的后端代理 URL。 + * 返回图标的后端缩略图代理 URL。 * - * 图标一律经 /api/v2/scripts/:id/icon 走 scriptcat.org,浏览器不再直连 - * 作者填的第三方站点。meta_json 里的原始值只用来判断「有没有图标」, - * URL 由 id + updatetime 重建,因此对已 slim 过的数据同样幂等。 - * - * 没有图标时返回 null,调用方渲染兜底(不必请求接口)。 + * 只使用后端已确认的 64/128 两个 preset:小于等于 64 的渲染尺寸 + * 使用 64,其他尺寸使用 128,避免把任意用户输入拼到 URL。 */ - static icon(script: ScriptIconSource): string | null { + static icon(script: ScriptIconSource, size: number = 64): string | null { + const meta = script.script?.meta_json; + const raw = meta?.icon?.[0] ?? meta?.iconURL?.[0]; + if (!raw) { + return null; + } + const preset = size <= 64 ? 64 : 128; + const t = script.updatetime ? `?t=${script.updatetime}` : ''; + return `/api/v2/scripts/${script.id}/icon/thumb/${preset}${t}`; + } + + /** 缩略图不可用时使用的原图代理 URL。 */ + static iconOriginal(script: ScriptIconSource): string | null { const meta = script.script?.meta_json; const raw = meta?.icon?.[0] ?? meta?.iconURL?.[0]; if (!raw) { diff --git a/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.test.tsx b/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.test.tsx index 302eb4db..8b7db9b5 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.test.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.test.tsx @@ -77,6 +77,7 @@ vi.mock('@/lib/api/hooks/script', () => ({ })); const getVersionListMock = vi.fn(); +const getVersionStatMock = vi.fn(); vi.mock('@/lib/api/services/scripts/scripts', () => ({ EnablePreRelease: { EnablePreReleaseScript: 1, @@ -84,6 +85,7 @@ vi.mock('@/lib/api/services/scripts/scripts', () => ({ }, scriptService: { getVersionList: (...args: unknown[]) => getVersionListMock(...args), + getVersionStat: (...args: unknown[]) => getVersionStatMock(...args), updateVersion: vi.fn(), deleteVersion: vi.fn(), }, @@ -115,6 +117,7 @@ beforeEach(() => { isLoading: false, }); getVersionListMock.mockReset(); + getVersionStatMock.mockReset(); scriptMock.public = 1; }); @@ -134,6 +137,37 @@ function renderClient() { ); } +describe('ScriptVersionsClient 的冷加载', () => { + it( + '没有 SSR 数据时首次挂载请求列表和统计,并显示加载态', + async () => { + getVersionListMock.mockReturnValue(new Promise(() => {})); + getVersionStatMock.mockReturnValue(new Promise(() => {})); + + render( + , + ); + + await waitFor(() => { + expect(getVersionListMock).toHaveBeenCalledWith(7, { + page: 1, + size: 10, + }); + expect(getVersionStatMock).toHaveBeenCalledWith(7); + expect(screen.getByTestId('version-list-skeleton')).toBeInTheDocument(); + }); + + expect(screen.queryByText(/^version_count:.*0/)).not.toBeInTheDocument(); + }, + SLOW, + ); +}); + /** * 翻页时整块组件被一个居中的 `` 顶替: * 标题、统计条、列表、以及用户刚点下去的分页器全部卸载, diff --git a/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.tsx b/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.tsx index e11b8acd..aec8b9dd 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/version/components/ScriptVersionsClient.tsx @@ -139,6 +139,7 @@ interface ScriptVersionsClientProps { initialPageSize?: number; embedded?: boolean; initialError?: string; + onTotalChange?: (total: number) => void; } export default function ScriptVersionsClient({ @@ -148,6 +149,7 @@ export default function ScriptVersionsClient({ initialPageSize = 10, embedded = false, initialError, + onTotalChange, }: ScriptVersionsClientProps) { const { script } = useScript(); const t = useTranslations('script.version'); @@ -172,11 +174,15 @@ export default function ScriptVersionsClient({ // 版本对比状态管理 const [selectedVersions, setSelectedVersions] = useState([]); - // 使用传入的版本数据 - const [versionData, setVersionData] = useState( - initialVersionData || { list: [], total: 0 }, + // 使用传入的版本数据;null 表示尚未加载,不能当成空列表或 0 条。 + const [versionData, setVersionData] = useState( + initialVersionData, + ); + const [versionStatData, setVersionStatData] = + useState(versionStat); + const [isLoading, setIsLoading] = useState( + initialVersionData === null && !initialError, ); - const [isLoading, setIsLoading] = useState(false); // 每次请求领一个序号,只有最后发出的那个有权改状态。 // 否则慢的旧响应后到,会把新一页的结果盖回去。 const requestSeqRef = React.useRef(0); @@ -185,17 +191,74 @@ export default function ScriptVersionsClient({ ); const semDateTime = useSemDateTime(); - const versions = versionData?.list || []; - const totalVersions = versionData?.total || 0; - const releaseCount = versionStat?.release_num || 0; - const preReleaseCount = versionStat?.pre_release_num || 0; + React.useEffect(() => { + if (initialError || (initialVersionData !== null && versionStat !== null)) { + return; + } + + let cancelled = false; + const seq = ++requestSeqRef.current; + setIsLoading(true); + setError(null); + + void Promise.allSettled([ + scriptService.getVersionList(script.id, { + page: initialPage, + size: initialPageSize, + }), + scriptService.getVersionStat(script.id), + ]) + .then(([listResult, statResult]) => { + if (cancelled || seq !== requestSeqRef.current) return; + + if (listResult.status === 'fulfilled') { + setVersionData(listResult.value); + onTotalChange?.(listResult.value.total); + } + if (statResult.status === 'fulfilled') { + setVersionStatData(statResult.value); + } + + const rejected = [listResult, statResult].find( + (result) => result.status === 'rejected', + ); + if (rejected?.status === 'rejected') { + setError( + rejected.reason instanceof Error + ? rejected.reason + : new Error(String(rejected.reason)), + ); + } + }) + .finally(() => { + if (!cancelled && seq === requestSeqRef.current) { + setIsLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + initialError, + initialPage, + initialPageSize, + initialVersionData, + onTotalChange, + script.id, + versionStat, + ]); + + const versions = versionData?.list ?? []; + const totalVersions = versionData?.total ?? 0; + const releaseCount = versionStatData?.release_num ?? 0; + const preReleaseCount = versionStatData?.pre_release_num ?? 0; // 管理员的版本列表包含已删除版本,而 release/pre-release 计数只数存活的; // 把差额显式标出来,头部三个数字才对得上。 - const deletedCount = deletedVersionCount( - totalVersions, - releaseCount, - preReleaseCount, - ); + const hasVersionStats = versionData !== null && versionStatData !== null; + const deletedCount = hasVersionStats + ? deletedVersionCount(totalVersions, releaseCount, preReleaseCount) + : 0; const latestReleaseIndex = findLatestReleaseIndex(versions, currentPage); // 刷新数据的函数 @@ -210,6 +273,7 @@ export default function ScriptVersionsClient({ }); if (seq !== requestSeqRef.current) return; setVersionData(newData); + onTotalChange?.(newData.total); } catch (err) { if (seq !== requestSeqRef.current) return; setError(err as Error); @@ -242,6 +306,7 @@ export default function ScriptVersionsClient({ }); if (seq !== requestSeqRef.current) return; setVersionData(newData); + onTotalChange?.(newData.total); } catch (err) { if (seq !== requestSeqRef.current) return; setError(err as Error); @@ -377,7 +442,11 @@ export default function ScriptVersionsClient({ } // 如果没有版本数据,显示空状态。加载中不走这里 —— 「空」和「正在加载」是两件事。 - if (!isLoading && (!versions || versions.length === 0)) { + if ( + !isLoading && + versionData !== null && + (!versions || versions.length === 0) + ) { const emptyContent = (
-
- - {t('version_count', { count: totalVersions })} - - - - {t('release_chip', { count: releaseCount })} - - - - {t('prerelease_chip', { count: preReleaseCount })} - - {deletedCount > 0 && ( - - - {t('deleted_chip', { count: deletedCount })} + {hasVersionStats && ( +
+
+ + {t('version_count', { count: totalVersions })} - )} + + + {t('release_chip', { count: releaseCount })} + + + + {t('prerelease_chip', { count: preReleaseCount })} + + {deletedCount > 0 && ( + + + {t('deleted_chip', { count: deletedCount })} + + )} +
+ + {t('pagination_info', { + start: (currentPage - 1) * pageSize + 1, + end: Math.min(currentPage * pageSize, totalVersions), + total: totalVersions, + })} +
- - {t('pagination_info', { - start: (currentPage - 1) * pageSize + 1, - end: Math.min(currentPage * pageSize, totalVersions), - total: totalVersions, - })} - -
+ )} {/* 版本列表 */} {isLoading ? ( diff --git a/src/components/ScriptIcon.test.tsx b/src/components/ScriptIcon.test.tsx new file mode 100644 index 00000000..27441036 --- /dev/null +++ b/src/components/ScriptIcon.test.tsx @@ -0,0 +1,54 @@ +/** @vitest-environment jsdom */ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import ScriptIcon from './ScriptIcon'; +import { ScriptUtils } from '@/app/[locale]/(main)/script-show-page/[id]/utils'; + +const script = { + id: 42, + updatetime: 1700000000, + script: { + meta_json: { + name: ['demo'], + description: [''], + icon: ['https://example.com/icon.png'], + }, + }, +}; + +describe('Script icon thumbnail URLs', () => { + it('maps rendered sizes to the fixed 64/128 thumbnail presets', () => { + expect(ScriptUtils.icon(script, 24)).toBe( + '/api/v2/scripts/42/icon/thumb/64?t=1700000000', + ); + expect(ScriptUtils.icon(script, 64)).toBe( + '/api/v2/scripts/42/icon/thumb/64?t=1700000000', + ); + expect(ScriptUtils.icon(script, 96)).toBe( + '/api/v2/scripts/42/icon/thumb/128?t=1700000000', + ); + expect(ScriptUtils.icon(script, 128)).toBe( + '/api/v2/scripts/42/icon/thumb/128?t=1700000000', + ); + }); + + it('tries the original proxy after a thumbnail 404, then uses the existing fallback', () => { + render(); + + const image = screen.getByRole('img'); + expect(image).toHaveAttribute( + 'src', + '/api/v2/scripts/42/icon/thumb/64?t=1700000000', + ); + + fireEvent.error(image); + expect(screen.getByRole('img')).toHaveAttribute( + 'src', + '/api/v2/scripts/42/icon?t=1700000000', + ); + + fireEvent.error(screen.getByRole('img')); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + expect(screen.getByText('D')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ScriptIcon.tsx b/src/components/ScriptIcon.tsx index 337e0bd2..9c012484 100644 --- a/src/components/ScriptIcon.tsx +++ b/src/components/ScriptIcon.tsx @@ -54,10 +54,17 @@ export default function ScriptIcon({ fallback, className = '', }: ScriptIconProps) { - const [hasError, setHasError] = useState(false); - const iconUrl = ScriptUtils.icon(script); + const [iconSource, setIconSource] = useState< + 'thumbnail' | 'original' | 'fallback' + >('thumbnail'); + const iconUrl = + iconSource === 'thumbnail' + ? ScriptUtils.icon(script, size) + : iconSource === 'original' + ? ScriptUtils.iconOriginal(script) + : null; - if (iconUrl && !hasError) { + if (iconUrl) { return ( // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text setHasError(true)} + onError={() => + setIconSource((source) => + source === 'thumbnail' ? 'original' : 'fallback', + ) + } /> ); } From ba5f7b53b5a5becbfecf2a75b242f23766056910 Mon Sep 17 00:00:00 2001 From: CodFrm Date: Sun, 18 Oct 2026 12:00:45 +0800 Subject: [PATCH 12/12] fix(script-detail): load rating distribution on tab open --- .../components/ScriptRatingClient.test.tsx | 30 +++++++++++++++++-- .../comment/components/ScriptRatingClient.tsx | 6 ++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx index c2c41f63..a581ce88 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.test.tsx @@ -1,5 +1,5 @@ /** @vitest-environment jsdom */ -import { act, cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import { renderToStaticMarkup } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ScoreListItem } from '@/lib/api/services/scripts/scripts'; @@ -16,6 +16,7 @@ vi.mock('@/contexts/UserContext', () => ({ const useScoreListMock = vi.fn(); const useMyScoreMock = vi.fn(); +const getScoreStateMock = vi.fn(); vi.mock('@/lib/api/hooks/script', () => ({ useScoreList: (...args: unknown[]) => useScoreListMock(...args), @@ -24,7 +25,7 @@ vi.mock('@/lib/api/hooks/script', () => ({ vi.mock('@/lib/api/services/scripts/scripts', () => ({ scriptService: { - getScoreState: vi.fn(), + getScoreState: (...args: unknown[]) => getScoreStateMock(...args), submitScore: vi.fn(), deleteScore: vi.fn(), submitCommentReply: vi.fn(), @@ -82,6 +83,11 @@ beforeEach(() => { lastListProps = null; useScoreListMock.mockReset(); useMyScoreMock.mockReset(); + getScoreStateMock.mockReset(); + getScoreStateMock.mockResolvedValue({ + score_group: { 5: 1 }, + score_user_count: 1, + }); useMyScoreMock.mockReturnValue({ data: undefined, mutate: vi.fn() }); useScoreListMock.mockReturnValue({ data: initialData, @@ -148,6 +154,26 @@ describe('ScriptRatingClient 的初始数据', () => { null, ); }); + + it('没有 initialData 时首次挂载会加载评分分布', async () => { + useScoreListMock.mockReturnValue({ + data: undefined, + error: undefined, + mutate: vi.fn(), + isLoading: true, + isValidating: true, + }); + + render( + , + ); + + await waitFor(() => expect(getScoreStateMock).toHaveBeenCalledWith(1)); + }); }); /** diff --git a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.tsx b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.tsx index 0c84de81..294e1855 100644 --- a/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.tsx +++ b/src/app/[locale]/(main)/script-show-page/[id]/comment/components/ScriptRatingClient.tsx @@ -71,6 +71,12 @@ export default function ScriptRatingClient({ } }, [scriptId]); + // 详情页不再为首屏预取评分状态;进入评分 Tab 后补齐分布数据。 + useEffect(() => { + if (initialData !== null) return; + void updateRatingStats(); + }, [initialData, updateRatingStats]); + // 构建查询参数 const queryParams = useMemo(() => { const sortMap: Record =