From fd4a47f416414f895a42157d5d95374fb68e20e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 13 Apr 2026 14:49:29 +0800 Subject: [PATCH 01/25] update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 21a78190..c2c4d310 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,6 @@ next-env.d.ts /public/assets/prismjs/* .claude - +.omc CLAUDE.md + From 519e45b9fc033bd9a8061718ec073fbd8d738a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 09:40:04 +0800 Subject: [PATCH 02/25] feat(similarity): scaffold admin similarity dashboard route + menu Add DiffOutlined menu entry and active-key detection for /admin/similarity. Create similarity page, SimilarityDashboardClient tabs shell, and five placeholder tab components (PairsTable, SuspectsTable, IntegrityReviewTable, IntegrityWhitelistTable, PairWhitelistTable). --- .../(main)/admin/components/AdminLayout.tsx | 7 +++ .../components/IntegrityReviewTable.tsx | 4 ++ .../components/IntegrityWhitelistTable.tsx | 4 ++ .../components/PairWhitelistTable.tsx | 4 ++ .../similarity/components/PairsTable.tsx | 4 ++ .../components/SimilarityDashboardClient.tsx | 45 +++++++++++++++++++ .../similarity/components/SuspectsTable.tsx | 4 ++ .../[locale]/(main)/admin/similarity/page.tsx | 5 +++ 8 files changed, 77 insertions(+) create mode 100644 src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/page.tsx diff --git a/src/app/[locale]/(main)/admin/components/AdminLayout.tsx b/src/app/[locale]/(main)/admin/components/AdminLayout.tsx index fa12ab86..af68bb74 100644 --- a/src/app/[locale]/(main)/admin/components/AdminLayout.tsx +++ b/src/app/[locale]/(main)/admin/components/AdminLayout.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react'; import { Card, Layout, Menu, theme } from 'antd'; import { ApiOutlined, + DiffOutlined, LoginOutlined, NotificationOutlined, UserOutlined, @@ -40,6 +41,7 @@ export default function AdminLayout({ children }: AdminLayoutProps) { if (pathname.includes('/admin/system-config')) return 'system-config'; if (pathname.includes('/admin/oidc-providers')) return 'oidc-providers'; if (pathname.includes('/admin/oauth-apps')) return 'oauth-apps'; + if (pathname.includes('/admin/similarity')) return 'similarity'; return 'users'; }; @@ -54,6 +56,11 @@ export default function AdminLayout({ children }: AdminLayoutProps) { icon: , label: {t('scripts')}, }, + { + key: 'similarity', + icon: , + label: {t('similarity')}, + }, { key: 'feedbacks', icon: , diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx new file mode 100644 index 00000000..0206fe1f --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -0,0 +1,4 @@ +'use client'; +export default function IntegrityReviewTable() { + return
TODO
; +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx new file mode 100644 index 00000000..5f1c5d27 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx @@ -0,0 +1,4 @@ +'use client'; +export default function IntegrityWhitelistTable() { + return
TODO
; +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx new file mode 100644 index 00000000..c33438cc --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx @@ -0,0 +1,4 @@ +'use client'; +export default function PairWhitelistTable() { + return
TODO
; +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx new file mode 100644 index 00000000..a8575a08 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx @@ -0,0 +1,4 @@ +'use client'; +export default function PairsTable() { + return
TODO
; +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx b/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx new file mode 100644 index 00000000..71978cdd --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { Tabs } from 'antd'; +import { useTranslations } from 'next-intl'; +import PairsTable from './PairsTable'; +import SuspectsTable from './SuspectsTable'; +import IntegrityReviewTable from './IntegrityReviewTable'; +import IntegrityWhitelistTable from './IntegrityWhitelistTable'; +import PairWhitelistTable from './PairWhitelistTable'; + +export default function SimilarityDashboardClient() { + const t = useTranslations('admin.similarity'); + const [activeKey, setActiveKey] = useState('pairs'); + + return ( + }, + { + key: 'suspects', + label: t('tab_suspects'), + children: , + }, + { + key: 'reviews', + label: t('tab_integrity_reviews'), + children: , + }, + { + key: 'pair-whitelist', + label: t('tab_pair_whitelist'), + children: , + }, + { + key: 'int-whitelist', + label: t('tab_integrity_whitelist'), + children: , + }, + ]} + /> + ); +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx new file mode 100644 index 00000000..842fb769 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx @@ -0,0 +1,4 @@ +'use client'; +export default function SuspectsTable() { + return
TODO
; +} diff --git a/src/app/[locale]/(main)/admin/similarity/page.tsx b/src/app/[locale]/(main)/admin/similarity/page.tsx new file mode 100644 index 00000000..4979f3ae --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/page.tsx @@ -0,0 +1,5 @@ +import SimilarityDashboardClient from './components/SimilarityDashboardClient'; + +export default function AdminSimilarityPage() { + return ; +} From d5e6d9e1d690f137482e6dc3b387719dd62c9245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 09:40:10 +0800 Subject: [PATCH 03/25] feat(similarity): add frontend similarity API service client Typed service class wrapping all admin + public similarity endpoints: pairs list/detail, suspects list, pair whitelist, integrity reviews, integrity whitelist, and evidence pair detail. --- src/lib/api/services/similarity.ts | 231 +++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/lib/api/services/similarity.ts diff --git a/src/lib/api/services/similarity.ts b/src/lib/api/services/similarity.ts new file mode 100644 index 00000000..d488fb1c --- /dev/null +++ b/src/lib/api/services/similarity.ts @@ -0,0 +1,231 @@ +import type { ListData } from '@/types/api'; +import { apiClient } from '../client'; + +export interface ScriptBrief { + id: number; + name: string; + user_id: number; + username: string; + public: number; + createtime: number; +} + +export interface ScriptFullInfo extends ScriptBrief { + script_code_id: number; + version: string; + code_created_at: number; +} + +export interface TopSource { + script_id: number; + script_name: string; + jaccard: number; + contribution_pct: number; +} + +export interface MatchSegment { + a_start: number; + a_end: number; + b_start: number; + b_end: number; +} + +export interface SimilarPairItem { + id: number; + script_a: ScriptBrief; + script_b: ScriptBrief; + jaccard: number; + common_count: number; + earlier_side: 'A' | 'B' | 'same'; + status: number; + detected_at: number; + integrity_score?: number; +} + +export interface SuspectScriptItem { + script: ScriptBrief; + max_jaccard: number; + coverage: number; + top_sources: TopSource[]; + pair_count: number; + detected_at: number; + integrity_score?: number; +} + +export interface AdminActions { + can_whitelist: boolean; +} + +export interface PairDetail { + id: number; + script_a: ScriptFullInfo; + script_b: ScriptFullInfo; + jaccard: number; + common_count: number; + a_fp_count: number; + b_fp_count: number; + earlier_side: 'A' | 'B' | 'same'; + detected_at: number; + code_a: string; + code_b: string; + match_segments: MatchSegment[]; + status?: number; + review_note?: string; + admin_actions?: AdminActions; +} + +export interface IntegritySubScores { + cat_a: number; + cat_b: number; + cat_c: number; + cat_d: number; +} + +export interface IntegrityHitSignal { + name: string; + value: number; + threshold: number; +} + +export interface IntegrityReviewItem { + id: number; + script: ScriptBrief; + script_code_id: number; + score: number; + status: number; + createtime: number; +} + +export interface IntegrityReviewDetail extends IntegrityReviewItem { + sub_scores: IntegritySubScores; + hit_signals: IntegrityHitSignal[]; + code: string; + reviewed_by?: number; + reviewed_at?: number; + review_note?: string; +} + +export interface IntegrityWhitelistItem { + id: number; + script: ScriptBrief; + reason: string; + added_by: number; + added_by_name: string; + createtime: number; +} + +export interface PairWhitelistItem { + id: number; + script_a: ScriptBrief; + script_b: ScriptBrief; + reason: string; + added_by: number; + added_by_name: string; + createtime: number; +} + +class SimilarityService { + private readonly adminBase = '/admin/similarity'; + private readonly publicBase = '/similarity'; + + listPairs(params: { + page?: number; + size?: number; + status?: number; + min_jaccard?: number; + script_id?: number; + }) { + return apiClient.get>( + `${this.adminBase}/pairs`, + params, + ); + } + + listSuspects(params: { + page?: number; + size?: number; + min_jaccard?: number; + min_coverage?: number; + status?: number; + }) { + return apiClient.get>( + `${this.adminBase}/suspects`, + params, + ); + } + + getPairDetail(id: number) { + return apiClient.get<{ detail: PairDetail }>( + `${this.adminBase}/pairs/${id}`, + ); + } + + addPairWhitelist(id: number, reason: string) { + return apiClient.post(`${this.adminBase}/pairs/${id}/whitelist`, { + reason, + }); + } + + removePairWhitelist(id: number) { + return apiClient.delete(`${this.adminBase}/pairs/${id}/whitelist`); + } + + listPairWhitelist(params: { page?: number; size?: number }) { + return apiClient.get>( + `${this.adminBase}/whitelist`, + params, + ); + } + + listIntegrityReviews(params: { + page?: number; + size?: number; + status?: number; + }) { + return apiClient.get>( + `${this.adminBase}/integrity/reviews`, + params, + ); + } + + getIntegrityReview(id: number) { + return apiClient.get<{ detail: IntegrityReviewDetail }>( + `${this.adminBase}/integrity/reviews/${id}`, + ); + } + + resolveIntegrityReview(id: number, status: 1 | 2, note: string) { + return apiClient.post( + `${this.adminBase}/integrity/reviews/${id}/resolve`, + { status, note }, + ); + } + + listIntegrityWhitelist(params: { page?: number; size?: number }) { + return apiClient.get>( + `${this.adminBase}/integrity/whitelist`, + params, + ); + } + + addIntegrityWhitelist(script_id: number, reason: string) { + return apiClient.post(`${this.adminBase}/integrity/whitelist`, { + script_id, + reason, + }); + } + + removeIntegrityWhitelist(scriptID: number) { + return apiClient.delete( + `${this.adminBase}/integrity/whitelist/${scriptID}`, + ); + } + + getEvidencePair(id: number) { + return apiClient.get<{ detail: PairDetail }>( + `${this.publicBase}/pair/${id}`, + ); + } +} + +export const similarityService = new SimilarityService(); From 4ac68c3f94743670e0357a97e90e9f5d04eaa682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 09:40:16 +0800 Subject: [PATCH 04/25] feat(similarity): add zh-CN translations for Phase 3 admin panel Merge admin.navigation.similarity, admin.similarity (tabs, columns, status labels, action labels, drawer/modal strings), similarity.evidence (disclaimer), and errors.integrity_rejected into the zh-CN locale file. --- public/locales/zh-CN/translations.json | 65 +++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 406b6f04..4e3a25b5 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -66,7 +66,8 @@ "scores": "评分管理", "scripts": "脚本管理", "system_config": "系统配置", - "users": "用户管理" + "users": "用户管理", + "similarity": "相似度检测" }, "no_permission": "您没有管理员权限访问此页面。", "reports": { @@ -263,6 +264,56 @@ "title": "用户管理", "unban_confirm": "确定要解封此用户吗?", "unban_success": "用户解封成功" + }, + "similarity": { + "tab_pairs": "相似对", + "tab_suspects": "嫌疑脚本", + "tab_integrity_reviews": "完整性警告", + "tab_pair_whitelist": "相似对白名单", + "tab_integrity_whitelist": "完整性豁免", + "col_id": "ID", + "col_script_a": "脚本 A", + "col_script_b": "脚本 B", + "col_jaccard": "Jaccard", + "col_common": "共同指纹", + "col_earlier": "较早方", + "col_status": "状态", + "col_integrity": "完整性评分", + "col_actions": "操作", + "col_script": "脚本", + "col_max_jaccard": "最高 Jaccard", + "col_coverage": "外源覆盖率", + "col_pair_count": "相似对数", + "col_detected_at": "检测时间", + "col_score": "分数", + "col_createtime": "时间", + "status_pending": "待审查", + "status_whitelisted": "已白名单", + "status_resolved": "已处理", + "review_pending": "待审", + "review_ok": "正常", + "review_violated": "违规", + "action_detail": "详情", + "action_resolve": "处理", + "action_whitelist": "加入白名单", + "modal_resolve_title": "标记完整性警告", + "label_decision": "判定", + "label_note": "备注", + "decision_ok": "正常", + "decision_violated": "违规", + "msg_review_resolved": "标记成功", + "msg_whitelisted": "已加入白名单", + "drawer_review_detail": "完整性警告详情", + "label_score": "总分", + "label_sub_scores": "分类分数", + "label_hit_signals": "命中信号", + "label_jaccard": "Jaccard", + "label_common": "共同指纹", + "label_earlier": "较早方", + "label_detected_at": "检测时间", + "label_script_a": "脚本 A", + "label_script_b": "脚本 B", + "label_code_diff": "代码差异" } }, "auth": { @@ -1932,5 +1983,17 @@ }, "utils": { "time_format": "YYYY年MM月DD日" + }, + "similarity": { + "evidence": { + "disclaimer_title": "初步判定,非最终结论", + "disclaimer_body": "此页面为系统自动检测的相似代码证据,仅供参考,请勿据此对作者做出结论性判断。" + } + }, + "errors": { + "integrity_rejected": { + "title": "代码未通过完整性检查", + "help": "如为误判,请通过站点 FAQ 的「管理员联系方式」申请豁免。" + } } } From b97717062712224e9e480badc0de912e10a56ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 09:54:33 +0800 Subject: [PATCH 05/25] feat(similarity): implement PairsTable tab --- .../similarity/components/PairsTable.tsx | 118 +++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx index a8575a08..a7123221 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx @@ -1,4 +1,120 @@ 'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Button, Space, Table, Tag, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import { similarityService } from '@/lib/api/services/similarity'; +import type { SimilarPairItem } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; + +const PAGE_SIZE = 20; + export default function PairsTable() { - return
TODO
; + const t = useTranslations('admin.similarity'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + + const load = useCallback(async (p: number) => { + setLoading(true); + try { + const resp = await similarityService.listPairs({ + page: p, + size: PAGE_SIZE, + }); + setData(resp.list ?? []); + setTotal(resp.total ?? 0); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page); + }, [page, load]); + + const columns: ColumnsType = [ + { title: t('col_id'), dataIndex: 'id', width: 70 }, + { + title: t('col_script_a'), + render: (_, r) => ( + + {r.script_a.name} + + ), + }, + { + title: t('col_script_b'), + render: (_, r) => ( + + {r.script_b.name} + + ), + }, + { + title: t('col_jaccard'), + dataIndex: 'jaccard', + render: (v: number) => v.toFixed(3), + sorter: (a, b) => a.jaccard - b.jaccard, + }, + { title: t('col_common'), dataIndex: 'common_count' }, + { + title: t('col_earlier'), + dataIndex: 'earlier_side', + render: (s: string) => ( + {s} + ), + }, + { + title: t('col_status'), + dataIndex: 'status', + render: (s: number) => { + const map: Record = { + 0: { color: 'warning', label: t('status_pending') }, + 1: { color: 'default', label: t('status_whitelisted') }, + 2: { color: 'success', label: t('status_resolved') }, + }; + const m = map[s] ?? map[0]; + return {m.label}; + }, + }, + { + title: t('col_integrity'), + dataIndex: 'integrity_score', + render: (v?: number) => (v ? v.toFixed(2) : '-'), + }, + { + title: t('col_actions'), + render: (_, r) => ( + + + + + + ), + }, + ]; + + return ( + + ); } From 6a8d525e9b6928aa37e065bf4ba86c2fae8a7dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 09:54:36 +0800 Subject: [PATCH 06/25] feat(similarity): implement SuspectsTable tab --- .../similarity/components/SuspectsTable.tsx | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx index 842fb769..d85e5a02 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx @@ -1,4 +1,102 @@ 'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import { similarityService } from '@/lib/api/services/similarity'; +import type { SuspectScriptItem } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; +import { message } from 'antd'; + +const PAGE_SIZE = 20; + export default function SuspectsTable() { - return
TODO
; + const t = useTranslations('admin.similarity'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + + const load = useCallback(async (p: number) => { + setLoading(true); + try { + const resp = await similarityService.listSuspects({ + page: p, + size: PAGE_SIZE, + }); + setData(resp.list ?? []); + setTotal(resp.total ?? 0); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page); + }, [page, load]); + + const columns: ColumnsType = [ + { + title: t('col_script'), + render: (_, r) => ( + {r.script.name} + ), + }, + { + title: t('col_max_jaccard'), + dataIndex: 'max_jaccard', + render: (v: number) => v.toFixed(3), + }, + { + title: t('col_coverage'), + dataIndex: 'coverage', + render: (v: number) => v.toFixed(3), + }, + { title: t('col_pair_count'), dataIndex: 'pair_count' }, + { + title: t('col_integrity'), + dataIndex: 'integrity_score', + render: (v?: number) => (v ? v.toFixed(2) : '-'), + }, + { + title: t('col_detected_at'), + dataIndex: 'detected_at', + render: (ts: number) => new Date(ts * 1000).toLocaleString(), + }, + ]; + + return ( +
record.script.id} + columns={columns} + dataSource={data} + loading={loading} + pagination={{ + current: page, + pageSize: PAGE_SIZE, + total, + onChange: setPage, + showSizeChanger: false, + }} + expandable={{ + expandedRowRender: (row) => ( +
    + {row.top_sources.map((s) => ( +
  • + + {s.script_name} + + {' — '}Jaccard {s.jaccard.toFixed(3)} / contrib{' '} + {(s.contribution_pct * 100).toFixed(1)}% +
  • + ))} +
+ ), + }} + /> + ); } From 5fc30af44e32df51d45834b1511ff09864dd4ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 10:01:26 +0800 Subject: [PATCH 07/25] fix(similarity): integrity_score 0 rendering and consolidate antd imports - integrity_score === 0 now renders as 0.00 instead of - - merge split antd imports in SuspectsTable --- .../(main)/admin/similarity/components/PairsTable.tsx | 2 +- .../(main)/admin/similarity/components/SuspectsTable.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx index a7123221..848f07ab 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx @@ -86,7 +86,7 @@ export default function PairsTable() { { title: t('col_integrity'), dataIndex: 'integrity_score', - render: (v?: number) => (v ? v.toFixed(2) : '-'), + render: (v?: number) => (v != null ? v.toFixed(2) : '-'), }, { title: t('col_actions'), diff --git a/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx index d85e5a02..45f159d1 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/SuspectsTable.tsx @@ -1,14 +1,13 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import { Table } from 'antd'; +import { message, Table } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/routing'; import { similarityService } from '@/lib/api/services/similarity'; import type { SuspectScriptItem } from '@/lib/api/services/similarity'; import { APIError } from '@/types/api'; -import { message } from 'antd'; const PAGE_SIZE = 20; @@ -60,7 +59,7 @@ export default function SuspectsTable() { { title: t('col_integrity'), dataIndex: 'integrity_score', - render: (v?: number) => (v ? v.toFixed(2) : '-'), + render: (v?: number) => (v != null ? v.toFixed(2) : '-'), }, { title: t('col_detected_at'), From 5dedecb5039589410f0d88e3b3d927b8d7f8d3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 11:39:38 +0800 Subject: [PATCH 08/25] feat(similarity): implement integrity review queue tab --- .../components/IntegrityReviewTable.tsx | 164 +++++++++++++++++- .../components/ResolveReviewModal.tsx | 75 ++++++++ 2 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx index 0206fe1f..b7f01773 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -1,4 +1,166 @@ 'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Button, Drawer, message, Space, Table, Tag, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import MonacoEditor from '@/components/MonacoEditor'; +import { similarityService } from '@/lib/api/services/similarity'; +import type { + IntegrityReviewDetail, + IntegrityReviewItem, +} from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; +import ResolveReviewModal from './ResolveReviewModal'; + +const PAGE_SIZE = 20; + export default function IntegrityReviewTable() { - return
TODO
; + const t = useTranslations('admin.similarity'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const [resolveTarget, setResolveTarget] = useState(null); + + const load = useCallback(async (p: number) => { + setLoading(true); + try { + const resp = await similarityService.listIntegrityReviews({ + page: p, + size: PAGE_SIZE, + }); + setData(resp.list ?? []); + setTotal(resp.total ?? 0); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page); + }, [page, load]); + + const openDetail = async (id: number) => { + try { + const resp = await similarityService.getIntegrityReview(id); + setDetail(resp.detail); + setDrawerOpen(true); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } + }; + + const columns: ColumnsType = [ + { title: t('col_id'), dataIndex: 'id', width: 70 }, + { title: t('col_script'), render: (_, r) => r.script.name }, + { + title: t('col_score'), + dataIndex: 'score', + render: (v: number) => v.toFixed(3), + }, + { + title: t('col_status'), + dataIndex: 'status', + render: (s: number) => { + const map: Record = { + 0: { color: 'warning', label: t('review_pending') }, + 1: { color: 'success', label: t('review_ok') }, + 2: { color: 'error', label: t('review_violated') }, + }; + const m = map[s] ?? map[0]; + return {m.label}; + }, + }, + { + title: t('col_createtime'), + dataIndex: 'createtime', + render: (ts: number) => new Date(ts * 1000).toLocaleString(), + }, + { + title: t('col_actions'), + render: (_, r) => ( + + + {r.status === 0 && ( + + )} + + ), + }, + ]; + + return ( + <> +
+ setDrawerOpen(false)} + width={960} + title={t('drawer_review_detail')} + destroyOnClose + > + {detail && ( +
+ + {t('label_score')}: {detail.score.toFixed(3)} + + + {t('label_sub_scores')}: A= + {detail.sub_scores.cat_a.toFixed(2)} B= + {detail.sub_scores.cat_b.toFixed(2)} C= + {detail.sub_scores.cat_c.toFixed(2)} D= + {detail.sub_scores.cat_d.toFixed(2)} + + + {t('label_hit_signals')}: +
    + {detail.hit_signals.map((h) => ( +
  • + {h.name}: {h.value.toFixed(3)} (threshold{' '} + {h.threshold.toFixed(3)}) +
  • + ))} +
+
+ +
+ )} +
+ setResolveTarget(null)} + onResolved={() => load(page)} + /> + + ); } diff --git a/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx b/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx new file mode 100644 index 00000000..308aeec5 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { useState } from 'react'; +import { Form, Input, message, Modal, Radio } from 'antd'; +import { useTranslations } from 'next-intl'; +import { similarityService } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; + +interface Props { + reviewID: number | null; + open: boolean; + onClose: () => void; + onResolved: () => void; +} + +export default function ResolveReviewModal({ + reviewID, + open, + onClose, + onResolved, +}: Props) { + const t = useTranslations('admin.similarity'); + const [form] = Form.useForm<{ status: 1 | 2; note: string }>(); + const [submitting, setSubmitting] = useState(false); + + const handleOK = async () => { + if (reviewID == null) return; + const values = await form.validateFields(); + setSubmitting(true); + try { + await similarityService.resolveIntegrityReview( + reviewID, + values.status, + values.note ?? '', + ); + message.success(t('msg_review_resolved')); + onResolved(); + onClose(); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setSubmitting(false); + } + }; + + return ( + +
+ + + {t('decision_ok')} + {t('decision_violated')} + + + + + + +
+ ); +} From 2249164aaf23cc546b3faa4c756831adb7fc4e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 11:41:07 +0800 Subject: [PATCH 09/25] fix(similarity): use non-deprecated Drawer props (size/destroyOnHidden) --- .../admin/similarity/components/IntegrityReviewTable.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx index b7f01773..dd21a30a 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -119,9 +119,9 @@ export default function IntegrityReviewTable() { setDrawerOpen(false)} - width={960} + size="large" title={t('drawer_review_detail')} - destroyOnClose + destroyOnHidden > {detail && (
From 8606147e2f237d1b61c7ef1e10548a2c30919a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 11:43:07 +0800 Subject: [PATCH 10/25] fix(similarity): destroy ResolveReviewModal form on close to prevent state leak --- .../(main)/admin/similarity/components/ResolveReviewModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx b/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx index 308aeec5..f7d97dcf 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/ResolveReviewModal.tsx @@ -50,6 +50,7 @@ export default function ResolveReviewModal({ onCancel={onClose} onOk={handleOK} confirmLoading={submitting} + destroyOnHidden >
Date: Tue, 14 Apr 2026 11:59:54 +0800 Subject: [PATCH 11/25] feat(similarity): add removePairWhitelistByID API client method --- src/lib/api/services/similarity.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/api/services/similarity.ts b/src/lib/api/services/similarity.ts index d488fb1c..77278dc6 100644 --- a/src/lib/api/services/similarity.ts +++ b/src/lib/api/services/similarity.ts @@ -170,6 +170,10 @@ class SimilarityService { return apiClient.delete(`${this.adminBase}/pairs/${id}/whitelist`); } + removePairWhitelistByID(whitelistID: number) { + return apiClient.delete(`${this.adminBase}/whitelist/${whitelistID}`); + } + listPairWhitelist(params: { page?: number; size?: number }) { return apiClient.get>( `${this.adminBase}/whitelist`, From db34b6093cfb1036071f0f2cb6741efcd581712b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:12:30 +0800 Subject: [PATCH 12/25] feat(similarity): implement whitelist management tabs --- public/locales/zh-CN/translations.json | 11 +- .../components/IntegrityWhitelistTable.tsx | 164 +++++++++++++++++- .../components/PairWhitelistTable.tsx | 111 +++++++++++- 3 files changed, 283 insertions(+), 3 deletions(-) diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 4e3a25b5..3133ca86 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -313,7 +313,16 @@ "label_detected_at": "检测时间", "label_script_a": "脚本 A", "label_script_b": "脚本 B", - "label_code_diff": "代码差异" + "label_code_diff": "代码差异", + "col_reason": "原因", + "col_added_by": "添加人", + "action_remove": "移除", + "confirm_remove_whitelist": "确认移除该白名单?", + "msg_removed": "已移除", + "modal_add_int_whitelist": "添加完整性豁免", + "label_script_id": "脚本 ID", + "label_reason": "原因", + "btn_add": "添加" } }, "auth": { diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx index 5f1c5d27..7c0fbeb9 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityWhitelistTable.tsx @@ -1,4 +1,166 @@ 'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Form, + Input, + InputNumber, + message, + Modal, + Space, + Table, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import type { IntegrityWhitelistItem } from '@/lib/api/services/similarity'; +import { similarityService } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; + +const PAGE_SIZE = 20; + export default function IntegrityWhitelistTable() { - return
TODO
; + const t = useTranslations('admin.similarity'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [addOpen, setAddOpen] = useState(false); + const [addSubmitting, setAddSubmitting] = useState(false); + const [form] = Form.useForm<{ script_id: number; reason: string }>(); + + const load = useCallback(async (p: number) => { + setLoading(true); + try { + const resp = await similarityService.listIntegrityWhitelist({ + page: p, + size: PAGE_SIZE, + }); + setData(resp.list ?? []); + setTotal(resp.total ?? 0); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page); + }, [page, load]); + + const handleRemove = (row: IntegrityWhitelistItem) => { + Modal.confirm({ + title: t('confirm_remove_whitelist'), + onOk: async () => { + try { + await similarityService.removeIntegrityWhitelist(row.script.id); + message.success(t('msg_removed')); + load(page); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } + }, + }); + }; + + const handleAdd = async () => { + const values = await form.validateFields(); + setAddSubmitting(true); + try { + await similarityService.addIntegrityWhitelist( + values.script_id, + values.reason, + ); + message.success(t('msg_whitelisted')); + setAddOpen(false); + form.resetFields(); + load(page); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setAddSubmitting(false); + } + }; + + const columns: ColumnsType = [ + { title: t('col_id'), dataIndex: 'id', width: 70 }, + { + title: t('col_script'), + render: (_, r) => ( + {r.script.name} + ), + }, + { title: t('col_reason'), dataIndex: 'reason' }, + { title: t('col_added_by'), dataIndex: 'added_by_name' }, + { + title: t('col_createtime'), + dataIndex: 'createtime', + render: (ts: number) => new Date(ts * 1000).toLocaleString(), + }, + { + title: t('col_actions'), + render: (_, r) => ( + + + + ), + }, + ]; + + return ( + <> +
+ +
+
+ setAddOpen(false)} + onOk={handleAdd} + confirmLoading={addSubmitting} + destroyOnHidden + > + + + + + + + + + + + ); } diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx index c33438cc..bb57f490 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/PairWhitelistTable.tsx @@ -1,4 +1,113 @@ 'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { Button, message, Modal, Space, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { useTranslations } from 'next-intl'; +import { Link } from '@/i18n/routing'; +import type { PairWhitelistItem } from '@/lib/api/services/similarity'; +import { similarityService } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; + +const PAGE_SIZE = 20; + export default function PairWhitelistTable() { - return
TODO
; + const t = useTranslations('admin.similarity'); + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + + const load = useCallback(async (p: number) => { + setLoading(true); + try { + const resp = await similarityService.listPairWhitelist({ + page: p, + size: PAGE_SIZE, + }); + setData(resp.list ?? []); + setTotal(resp.total ?? 0); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(page); + }, [page, load]); + + const handleRemove = (row: PairWhitelistItem) => { + Modal.confirm({ + title: t('confirm_remove_whitelist'), + onOk: async () => { + try { + await similarityService.removePairWhitelistByID(row.id); + message.success(t('msg_removed')); + load(page); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } + }, + }); + }; + + const columns: ColumnsType = [ + { title: t('col_id'), dataIndex: 'id', width: 70 }, + { + title: t('col_script_a'), + render: (_, r) => ( + + {r.script_a.name} + + ), + }, + { + title: t('col_script_b'), + render: (_, r) => ( + + {r.script_b.name} + + ), + }, + { title: t('col_reason'), dataIndex: 'reason' }, + { title: t('col_added_by'), dataIndex: 'added_by_name' }, + { + title: t('col_createtime'), + dataIndex: 'createtime', + render: (ts: number) => new Date(ts * 1000).toLocaleString(), + }, + { + title: t('col_actions'), + render: (_, r) => ( + + + + ), + }, + ]; + + return ( +
+ ); } From 59d57b948f8459765d88de9a7ab04857b6e3fb3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:21:22 +0800 Subject: [PATCH 13/25] feat(similarity): add admin pair detail page with Monaco diff viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 17 — pair detail page under admin/similarity/pairs/[pairId] with metadata descriptions, whitelist action button, and a CodeDiffViewer component that uses @monaco-editor/react DiffEditor with per-segment line decorations via createDecorationsCollection. --- .../[pairId]/components/CodeDiffViewer.tsx | 112 ++++++++++++++++++ .../[pairId]/components/PairDetailClient.tsx | 105 ++++++++++++++++ .../admin/similarity/pairs/[pairId]/page.tsx | 10 ++ 3 files changed, 227 insertions(+) create mode 100644 src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx create mode 100644 src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx new file mode 100644 index 00000000..7f77914f --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { DiffEditor } from '@monaco-editor/react'; +import type { + DiffOnMount, + Monaco, + MonacoDiffEditor, +} from '@monaco-editor/react'; +import type { editor as MonacoEditorNS } from 'monaco-editor'; +import { useTheme } from '@/contexts/ThemeClientContext'; +import type { MatchSegment } from '@/lib/api/services/similarity'; + +interface Props { + codeA: string; + codeB: string; + segments: MatchSegment[]; +} + +interface MountState { + editor: MonacoDiffEditor; + monaco: Monaco; +} + +function byteOffsetToLine(code: string, byteOffset: number): number { + let line = 1; + for (let i = 0; i < byteOffset && i < code.length; i++) { + if (code.charCodeAt(i) === 10) line++; + } + return line; +} + +export default function CodeDiffViewer({ codeA, codeB, segments }: Props) { + const { themeMode } = useTheme(); + const mountRef = useRef(null); + const decorationsRef = useRef<{ + a: MonacoEditorNS.IEditorDecorationsCollection | null; + b: MonacoEditorNS.IEditorDecorationsCollection | null; + }>({ a: null, b: null }); + + const applyDecorations = () => { + const state = mountRef.current; + if (!state) return; + const { editor, monaco } = state; + const orig = editor.getOriginalEditor(); + const mod = editor.getModifiedEditor(); + + decorationsRef.current.a?.clear(); + decorationsRef.current.b?.clear(); + + if (!segments.length) { + decorationsRef.current = { a: null, b: null }; + return; + } + + const aDecos = segments.map((s) => ({ + range: new monaco.Range( + byteOffsetToLine(codeA, s.a_start), + 1, + byteOffsetToLine(codeA, s.a_end), + 1, + ), + options: { + isWholeLine: true, + className: 'bg-yellow-100 dark:bg-yellow-800/30', + }, + })); + const bDecos = segments.map((s) => ({ + range: new monaco.Range( + byteOffsetToLine(codeB, s.b_start), + 1, + byteOffsetToLine(codeB, s.b_end), + 1, + ), + options: { + isWholeLine: true, + className: 'bg-yellow-100 dark:bg-yellow-800/30', + }, + })); + + decorationsRef.current = { + a: orig.createDecorationsCollection(aDecos), + b: mod.createDecorationsCollection(bDecos), + }; + }; + + const onMount: DiffOnMount = (editor, monaco) => { + mountRef.current = { editor, monaco }; + applyDecorations(); + }; + + useEffect(() => { + applyDecorations(); + }, [codeA, codeB, segments]); + + return ( + + ); +} diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx new file mode 100644 index 00000000..da108f38 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { + Button, + Card, + Descriptions, + message, + Skeleton, + Space, + Tag, +} from 'antd'; +import { useTranslations } from 'next-intl'; +import { similarityService } from '@/lib/api/services/similarity'; +import type { PairDetail } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; +import CodeDiffViewer from './CodeDiffViewer'; + +interface Props { + pairID: number; + source: 'admin' | 'evidence'; +} + +export default function PairDetailClient({ pairID, source }: Props) { + const t = useTranslations('admin.similarity'); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + try { + const resp = + source === 'admin' + ? await similarityService.getPairDetail(pairID) + : await similarityService.getEvidencePair(pairID); + setDetail(resp.detail); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, [pairID, source]); + + useEffect(() => { + load(); + }, [load]); + + const whitelist = async () => { + try { + await similarityService.addPairWhitelist(pairID, 'admin whitelist'); + message.success(t('msg_whitelisted')); + load(); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } + }; + + if (loading) return ; + if (!detail) return null; + + return ( +
+ + + + {detail.jaccard.toFixed(3)} + + + {detail.common_count} + + + {detail.earlier_side} + + + {new Date(detail.detected_at * 1000).toLocaleString()} + + + {detail.script_a.name} @ {detail.script_a.version} + + + {detail.script_b.name} @ {detail.script_b.version} + + + + {source === 'admin' && detail.admin_actions && ( + + + + )} + + + +
+ ); +} diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx new file mode 100644 index 00000000..8dcb2554 --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx @@ -0,0 +1,10 @@ +import PairDetailClient from './components/PairDetailClient'; + +export default async function PairDetailPage({ + params, +}: { + params: Promise<{ pairId: string }>; +}) { + const { pairId } = await params; + return ; +} From d02c1231f923c7c58761642b631e9832d7d1e562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:21:28 +0800 Subject: [PATCH 14/25] feat(similarity): add semi-public pair evidence page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 18 — public-facing evidence page at similarity/pair/[id] that wraps PairDetailClient with a warning Alert disclaimer banner, sourced from getEvidencePair API endpoint. --- .../[id]/components/EvidencePageClient.tsx | 19 +++++++++++++++++++ .../(main)/similarity/pair/[id]/page.tsx | 10 ++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx create mode 100644 src/app/[locale]/(main)/similarity/pair/[id]/page.tsx diff --git a/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx new file mode 100644 index 00000000..c7a77dc0 --- /dev/null +++ b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Alert } from 'antd'; +import { useTranslations } from 'next-intl'; +import PairDetailClient from '@/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient'; + +export default function EvidencePageClient({ pairID }: { pairID: number }) { + const t = useTranslations('similarity.evidence'); + return ( +
+ + +
+ ); +} diff --git a/src/app/[locale]/(main)/similarity/pair/[id]/page.tsx b/src/app/[locale]/(main)/similarity/pair/[id]/page.tsx new file mode 100644 index 00000000..3ad41e69 --- /dev/null +++ b/src/app/[locale]/(main)/similarity/pair/[id]/page.tsx @@ -0,0 +1,10 @@ +import EvidencePageClient from './components/EvidencePageClient'; + +export default async function EvidencePage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} From 8e038be1681f82457e408d43f710a4636dd6c4a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:22:49 +0800 Subject: [PATCH 15/25] fix(similarity): use Alert.title instead of deprecated message prop --- .../similarity/pair/[id]/components/EvidencePageClient.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx index c7a77dc0..d94861fb 100644 --- a/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx +++ b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx @@ -10,7 +10,7 @@ export default function EvidencePageClient({ pairID }: { pairID: number }) {
From 183f844f336226ce2da6ecd1dedf40c6001c6b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:26:55 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix(similarity):=20use=20real=20CSS=20cla?= =?UTF-8?q?ss=20for=20diff=20highlights=20and=20correct=20byte=E2=86=92lin?= =?UTF-8?q?e=20conversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - replace Tailwind class names (which Tailwind purges from non-JSX strings) with similarity-match-highlight defined in globals.css - convert UTF-8 byte offsets via TextEncoder to handle non-ASCII source code correctly, instead of iterating JS UTF-16 code units --- .../[pairId]/components/CodeDiffViewer.tsx | 43 ++++++++++++++----- src/app/globals.css | 7 +++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx index 7f77914f..8cb9d9e2 100644 --- a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx +++ b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx @@ -22,12 +22,30 @@ interface MountState { monaco: Monaco; } -function byteOffsetToLine(code: string, byteOffset: number): number { - let line = 1; - for (let i = 0; i < byteOffset && i < code.length; i++) { - if (code.charCodeAt(i) === 10) line++; +// Backend match-segment offsets are UTF-8 byte offsets, but JS string indices +// are UTF-16 code units. Build a sorted list of (byteOffset, line) pairs from +// the encoded source so each segment endpoint maps to the correct line. +function buildByteToLineIndex(code: string): number[] { + const bytes = new TextEncoder().encode(code); + const newlineByteOffsets: number[] = []; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 10) newlineByteOffsets.push(i); } - return line; + return newlineByteOffsets; +} + +function byteOffsetToLine( + newlineByteOffsets: number[], + byteOffset: number, +): number { + let lo = 0; + let hi = newlineByteOffsets.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (newlineByteOffsets[mid] < byteOffset) lo = mid + 1; + else hi = mid; + } + return lo + 1; } export default function CodeDiffViewer({ codeA, codeB, segments }: Props) { @@ -53,28 +71,31 @@ export default function CodeDiffViewer({ codeA, codeB, segments }: Props) { return; } + const aIndex = buildByteToLineIndex(codeA); + const bIndex = buildByteToLineIndex(codeB); + const aDecos = segments.map((s) => ({ range: new monaco.Range( - byteOffsetToLine(codeA, s.a_start), + byteOffsetToLine(aIndex, s.a_start), 1, - byteOffsetToLine(codeA, s.a_end), + byteOffsetToLine(aIndex, s.a_end), 1, ), options: { isWholeLine: true, - className: 'bg-yellow-100 dark:bg-yellow-800/30', + className: 'similarity-match-highlight', }, })); const bDecos = segments.map((s) => ({ range: new monaco.Range( - byteOffsetToLine(codeB, s.b_start), + byteOffsetToLine(bIndex, s.b_start), 1, - byteOffsetToLine(codeB, s.b_end), + byteOffsetToLine(bIndex, s.b_end), 1, ), options: { isWholeLine: true, - className: 'bg-yellow-100 dark:bg-yellow-800/30', + className: 'similarity-match-highlight', }, })); diff --git a/src/app/globals.css b/src/app/globals.css index 0751fa8b..5050f145 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -148,4 +148,11 @@ html[data-theme='dark'] { .border-start-radius-0 .ant-select-selector { border-start-start-radius: 0 !important; border-end-start-radius: 0 !important; +} + +.similarity-match-highlight { + background: rgba(253, 224, 71, 0.35); +} +html[data-theme='dark'] .similarity-match-highlight { + background: rgba(133, 77, 14, 0.35); } \ No newline at end of file From d0ac8a00279139cf7fd2be950cc4e9641110e8b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 12:30:59 +0800 Subject: [PATCH 17/25] feat(similarity): show integrity rejection alert on script publish/update --- .../IntegrityErrorAlert.tsx | 27 +++++++++++++++++++ src/components/ScriptEditor/index.tsx | 11 +++++++- src/lib/api/errorCodes.ts | 3 +++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/components/IntegrityErrorAlert/IntegrityErrorAlert.tsx create mode 100644 src/lib/api/errorCodes.ts diff --git a/src/components/IntegrityErrorAlert/IntegrityErrorAlert.tsx b/src/components/IntegrityErrorAlert/IntegrityErrorAlert.tsx new file mode 100644 index 00000000..caea4ff5 --- /dev/null +++ b/src/components/IntegrityErrorAlert/IntegrityErrorAlert.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { Alert, Typography } from 'antd'; +import { useTranslations } from 'next-intl'; + +interface Props { + message: string; +} + +export default function IntegrityErrorAlert({ message }: Props) { + const t = useTranslations('errors.integrity_rejected'); + return ( + + {message} + + {t('help')} + +
+ } + /> + ); +} diff --git a/src/components/ScriptEditor/index.tsx b/src/components/ScriptEditor/index.tsx index 9a29595e..9ea2c9f1 100644 --- a/src/components/ScriptEditor/index.tsx +++ b/src/components/ScriptEditor/index.tsx @@ -43,6 +43,8 @@ import { import type { ScriptInfo } from '@/app/[locale]/(main)/script-show-page/[id]/types'; import { useCategoryList } from '@/lib/api/hooks'; import { APIError } from '@/types/api'; +import IntegrityErrorAlert from '@/components/IntegrityErrorAlert/IntegrityErrorAlert'; +import { ErrorCodes } from '@/lib/api/errorCodes'; const { Text, Link } = Typography; @@ -55,6 +57,7 @@ export default function ScriptEditor({ script, onSubmit }: ScriptEditorProps) { const t = useTranslations('script.editor'); const [form] = Form.useForm(); const [loading, setLoading] = useState(false); + const [integrityError, setIntegrityError] = useState(null); const parseTimeoutRef = useRef(null); const { data: category, isLoading: isCategoryLoading } = useCategoryList(); @@ -160,6 +163,7 @@ export default function ScriptEditor({ script, onSubmit }: ScriptEditorProps) { try { setLoading(true); + setIntegrityError(null); values.detailedDescription = mkEditorRef.current?.getValue(); await onSubmit(values); message.success( @@ -170,7 +174,11 @@ export default function ScriptEditor({ script, onSubmit }: ScriptEditorProps) { } catch (error: any) { console.error('Submit failed:', error); if (error instanceof APIError) { - message.error(`${t('messages.submit_failed')} ${error.msg}`); + if (error.code === ErrorCodes.SimilarityIntegrityRejected) { + setIntegrityError(error.msg); + } else { + message.error(`${t('messages.submit_failed')} ${error.msg}`); + } } else { message.error( `${t('messages.submit_failed')} ${ @@ -187,6 +195,7 @@ export default function ScriptEditor({ script, onSubmit }: ScriptEditorProps) { return (
+ {integrityError && } {/* 提示信息 */} Date: Tue, 14 Apr 2026 13:41:08 +0800 Subject: [PATCH 18/25] feat(similarity): add Phase 3 translations for all supported locales - en-US: proper English translations - zh-TW: Traditional Chinese translations - ja-JP / de-DE / ru-RU: English stopgap (pending Crowdin) --- public/locales/de-DE/translations.json | 74 +++++++++++++++++++++++++- public/locales/en-US/translations.json | 74 +++++++++++++++++++++++++- public/locales/ja-JP/translations.json | 74 +++++++++++++++++++++++++- public/locales/ru-RU/translations.json | 74 +++++++++++++++++++++++++- public/locales/zh-TW/translations.json | 74 +++++++++++++++++++++++++- 5 files changed, 365 insertions(+), 5 deletions(-) diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index cad2992f..3f914718 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -38,7 +38,8 @@ "scores": "Bewertungsverwaltung", "scripts": "Skriptverwaltung", "system_config": "Systemkonfiguration", - "users": "Benutzerverwaltung" + "users": "Benutzerverwaltung", + "similarity": "Similarity Detection" }, "no_permission": "Sie haben keine Administratorberechtigung für den Zugriff auf diese Seite.", "oauth_apps": { @@ -211,6 +212,65 @@ "title": "Benutzerverwaltung", "unban_confirm": "Möchten Sie diesen Benutzer wirklich entsperren?", "unban_success": "Benutzer erfolgreich entsperrt" + }, + "similarity": { + "tab_pairs": "Pairs", + "tab_suspects": "Suspects", + "tab_integrity_reviews": "Integrity Reviews", + "tab_pair_whitelist": "Pair Whitelist", + "tab_integrity_whitelist": "Integrity Exemptions", + "col_id": "ID", + "col_script_a": "Script A", + "col_script_b": "Script B", + "col_jaccard": "Jaccard", + "col_common": "Common Fingerprints", + "col_earlier": "Earlier Side", + "col_status": "Status", + "col_integrity": "Integrity Score", + "col_actions": "Actions", + "col_script": "Script", + "col_max_jaccard": "Max Jaccard", + "col_coverage": "External Coverage", + "col_pair_count": "Pair Count", + "col_detected_at": "Detected At", + "col_score": "Score", + "col_createtime": "Created", + "col_reason": "Reason", + "col_added_by": "Added By", + "status_pending": "Pending", + "status_whitelisted": "Whitelisted", + "status_resolved": "Resolved", + "review_pending": "Pending", + "review_ok": "OK", + "review_violated": "Violation", + "action_detail": "Details", + "action_resolve": "Resolve", + "action_whitelist": "Whitelist", + "action_remove": "Remove", + "confirm_remove_whitelist": "Remove this whitelist entry?", + "msg_removed": "Removed", + "modal_add_int_whitelist": "Add Integrity Exemption", + "label_script_id": "Script ID", + "label_reason": "Reason", + "btn_add": "Add", + "modal_resolve_title": "Mark Integrity Review", + "label_decision": "Decision", + "label_note": "Note", + "decision_ok": "OK", + "decision_violated": "Violation", + "msg_review_resolved": "Marked successfully", + "msg_whitelisted": "Added to whitelist", + "drawer_review_detail": "Integrity Review Details", + "label_score": "Total Score", + "label_sub_scores": "Category Scores", + "label_hit_signals": "Hit Signals", + "label_jaccard": "Jaccard", + "label_common": "Common Fingerprints", + "label_earlier": "Earlier Side", + "label_detected_at": "Detected At", + "label_script_a": "Script A", + "label_script_b": "Script B", + "label_code_diff": "Code Diff" } }, "auth": { @@ -1841,5 +1901,17 @@ }, "utils": { "time_format": "DD.MM.YYYY" + }, + "similarity": { + "evidence": { + "disclaimer_title": "Preliminary Finding, Not a Final Verdict", + "disclaimer_body": "This page shows automatically detected similar-code evidence. It is informational only. Please do not draw definitive conclusions about the author from this data." + } + }, + "errors": { + "integrity_rejected": { + "title": "Code Failed Integrity Check", + "help": "If this is a false positive, please apply for an exemption via the admin contact listed in the site FAQ." + } } } diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 96d18b99..a4efa0bc 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -65,7 +65,8 @@ "scores": "Score Management", "scripts": "Script Management", "system_config": "System Config", - "users": "User Management" + "users": "User Management", + "similarity": "Similarity Detection" }, "no_permission": "You do not have admin permission to access this page.", "oauth_apps": { @@ -250,6 +251,65 @@ "title": "User Management", "unban_confirm": "Are you sure you want to unban this user?", "unban_success": "User unbanned successfully" + }, + "similarity": { + "tab_pairs": "Pairs", + "tab_suspects": "Suspects", + "tab_integrity_reviews": "Integrity Reviews", + "tab_pair_whitelist": "Pair Whitelist", + "tab_integrity_whitelist": "Integrity Exemptions", + "col_id": "ID", + "col_script_a": "Script A", + "col_script_b": "Script B", + "col_jaccard": "Jaccard", + "col_common": "Common Fingerprints", + "col_earlier": "Earlier Side", + "col_status": "Status", + "col_integrity": "Integrity Score", + "col_actions": "Actions", + "col_script": "Script", + "col_max_jaccard": "Max Jaccard", + "col_coverage": "External Coverage", + "col_pair_count": "Pair Count", + "col_detected_at": "Detected At", + "col_score": "Score", + "col_createtime": "Created", + "col_reason": "Reason", + "col_added_by": "Added By", + "status_pending": "Pending", + "status_whitelisted": "Whitelisted", + "status_resolved": "Resolved", + "review_pending": "Pending", + "review_ok": "OK", + "review_violated": "Violation", + "action_detail": "Details", + "action_resolve": "Resolve", + "action_whitelist": "Whitelist", + "action_remove": "Remove", + "confirm_remove_whitelist": "Remove this whitelist entry?", + "msg_removed": "Removed", + "modal_add_int_whitelist": "Add Integrity Exemption", + "label_script_id": "Script ID", + "label_reason": "Reason", + "btn_add": "Add", + "modal_resolve_title": "Mark Integrity Review", + "label_decision": "Decision", + "label_note": "Note", + "decision_ok": "OK", + "decision_violated": "Violation", + "msg_review_resolved": "Marked successfully", + "msg_whitelisted": "Added to whitelist", + "drawer_review_detail": "Integrity Review Details", + "label_score": "Total Score", + "label_sub_scores": "Category Scores", + "label_hit_signals": "Hit Signals", + "label_jaccard": "Jaccard", + "label_common": "Common Fingerprints", + "label_earlier": "Earlier Side", + "label_detected_at": "Detected At", + "label_script_a": "Script A", + "label_script_b": "Script B", + "label_code_diff": "Code Diff" } }, "auth": { @@ -1883,5 +1943,17 @@ }, "utils": { "time_format": "YYYY-MM-DD" + }, + "similarity": { + "evidence": { + "disclaimer_title": "Preliminary Finding, Not a Final Verdict", + "disclaimer_body": "This page shows automatically detected similar-code evidence. It is informational only. Please do not draw definitive conclusions about the author from this data." + } + }, + "errors": { + "integrity_rejected": { + "title": "Code Failed Integrity Check", + "help": "If this is a false positive, please apply for an exemption via the admin contact listed in the site FAQ." + } } } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 66b08038..c318dbcb 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -38,7 +38,8 @@ "scores": "評価管理", "scripts": "スクリプト管理", "system_config": "システム設定", - "users": "ユーザー管理" + "users": "ユーザー管理", + "similarity": "Similarity Detection" }, "no_permission": "このページにアクセスする管理者権限がありません。", "oauth_apps": { @@ -211,6 +212,65 @@ "title": "ユーザー管理", "unban_confirm": "このユーザーの禁止を解除してもよろしいですか?", "unban_success": "ユーザーの禁止を解除しました" + }, + "similarity": { + "tab_pairs": "Pairs", + "tab_suspects": "Suspects", + "tab_integrity_reviews": "Integrity Reviews", + "tab_pair_whitelist": "Pair Whitelist", + "tab_integrity_whitelist": "Integrity Exemptions", + "col_id": "ID", + "col_script_a": "Script A", + "col_script_b": "Script B", + "col_jaccard": "Jaccard", + "col_common": "Common Fingerprints", + "col_earlier": "Earlier Side", + "col_status": "Status", + "col_integrity": "Integrity Score", + "col_actions": "Actions", + "col_script": "Script", + "col_max_jaccard": "Max Jaccard", + "col_coverage": "External Coverage", + "col_pair_count": "Pair Count", + "col_detected_at": "Detected At", + "col_score": "Score", + "col_createtime": "Created", + "col_reason": "Reason", + "col_added_by": "Added By", + "status_pending": "Pending", + "status_whitelisted": "Whitelisted", + "status_resolved": "Resolved", + "review_pending": "Pending", + "review_ok": "OK", + "review_violated": "Violation", + "action_detail": "Details", + "action_resolve": "Resolve", + "action_whitelist": "Whitelist", + "action_remove": "Remove", + "confirm_remove_whitelist": "Remove this whitelist entry?", + "msg_removed": "Removed", + "modal_add_int_whitelist": "Add Integrity Exemption", + "label_script_id": "Script ID", + "label_reason": "Reason", + "btn_add": "Add", + "modal_resolve_title": "Mark Integrity Review", + "label_decision": "Decision", + "label_note": "Note", + "decision_ok": "OK", + "decision_violated": "Violation", + "msg_review_resolved": "Marked successfully", + "msg_whitelisted": "Added to whitelist", + "drawer_review_detail": "Integrity Review Details", + "label_score": "Total Score", + "label_sub_scores": "Category Scores", + "label_hit_signals": "Hit Signals", + "label_jaccard": "Jaccard", + "label_common": "Common Fingerprints", + "label_earlier": "Earlier Side", + "label_detected_at": "Detected At", + "label_script_a": "Script A", + "label_script_b": "Script B", + "label_code_diff": "Code Diff" } }, "auth": { @@ -1841,5 +1901,17 @@ }, "utils": { "time_format": "YYYY年MM月DD日" + }, + "similarity": { + "evidence": { + "disclaimer_title": "Preliminary Finding, Not a Final Verdict", + "disclaimer_body": "This page shows automatically detected similar-code evidence. It is informational only. Please do not draw definitive conclusions about the author from this data." + } + }, + "errors": { + "integrity_rejected": { + "title": "Code Failed Integrity Check", + "help": "If this is a false positive, please apply for an exemption via the admin contact listed in the site FAQ." + } } } diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index 1bbe5a80..8ce68dec 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -38,7 +38,8 @@ "scores": "Управление оценками", "scripts": "Управление скриптами", "system_config": "Настройки системы", - "users": "Управление пользователями" + "users": "Управление пользователями", + "similarity": "Similarity Detection" }, "no_permission": "У вас нет прав администратора для доступа к этой странице.", "oauth_apps": { @@ -211,6 +212,65 @@ "title": "Управление пользователями", "unban_confirm": "Вы уверены, что хотите разблокировать этого пользователя?", "unban_success": "Пользователь успешно разблокирован" + }, + "similarity": { + "tab_pairs": "Pairs", + "tab_suspects": "Suspects", + "tab_integrity_reviews": "Integrity Reviews", + "tab_pair_whitelist": "Pair Whitelist", + "tab_integrity_whitelist": "Integrity Exemptions", + "col_id": "ID", + "col_script_a": "Script A", + "col_script_b": "Script B", + "col_jaccard": "Jaccard", + "col_common": "Common Fingerprints", + "col_earlier": "Earlier Side", + "col_status": "Status", + "col_integrity": "Integrity Score", + "col_actions": "Actions", + "col_script": "Script", + "col_max_jaccard": "Max Jaccard", + "col_coverage": "External Coverage", + "col_pair_count": "Pair Count", + "col_detected_at": "Detected At", + "col_score": "Score", + "col_createtime": "Created", + "col_reason": "Reason", + "col_added_by": "Added By", + "status_pending": "Pending", + "status_whitelisted": "Whitelisted", + "status_resolved": "Resolved", + "review_pending": "Pending", + "review_ok": "OK", + "review_violated": "Violation", + "action_detail": "Details", + "action_resolve": "Resolve", + "action_whitelist": "Whitelist", + "action_remove": "Remove", + "confirm_remove_whitelist": "Remove this whitelist entry?", + "msg_removed": "Removed", + "modal_add_int_whitelist": "Add Integrity Exemption", + "label_script_id": "Script ID", + "label_reason": "Reason", + "btn_add": "Add", + "modal_resolve_title": "Mark Integrity Review", + "label_decision": "Decision", + "label_note": "Note", + "decision_ok": "OK", + "decision_violated": "Violation", + "msg_review_resolved": "Marked successfully", + "msg_whitelisted": "Added to whitelist", + "drawer_review_detail": "Integrity Review Details", + "label_score": "Total Score", + "label_sub_scores": "Category Scores", + "label_hit_signals": "Hit Signals", + "label_jaccard": "Jaccard", + "label_common": "Common Fingerprints", + "label_earlier": "Earlier Side", + "label_detected_at": "Detected At", + "label_script_a": "Script A", + "label_script_b": "Script B", + "label_code_diff": "Code Diff" } }, "auth": { @@ -1841,5 +1901,17 @@ }, "utils": { "time_format": "DD.MM.YYYY" + }, + "similarity": { + "evidence": { + "disclaimer_title": "Preliminary Finding, Not a Final Verdict", + "disclaimer_body": "This page shows automatically detected similar-code evidence. It is informational only. Please do not draw definitive conclusions about the author from this data." + } + }, + "errors": { + "integrity_rejected": { + "title": "Code Failed Integrity Check", + "help": "If this is a false positive, please apply for an exemption via the admin contact listed in the site FAQ." + } } } diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index f239d2db..b6c46f95 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -38,7 +38,8 @@ "scores": "評分管理", "scripts": "腳本管理", "system_config": "系統配置", - "users": "用戶管理" + "users": "用戶管理", + "similarity": "相似度檢測" }, "no_permission": "您沒有管理員權限存取此頁面。", "oauth_apps": { @@ -211,6 +212,65 @@ "title": "用戶管理", "unban_confirm": "確定要解封此用戶吗?", "unban_success": "用戶解封成功" + }, + "similarity": { + "tab_pairs": "相似對", + "tab_suspects": "嫌疑腳本", + "tab_integrity_reviews": "完整性警告", + "tab_pair_whitelist": "相似對白名單", + "tab_integrity_whitelist": "完整性豁免", + "col_id": "ID", + "col_script_a": "腳本 A", + "col_script_b": "腳本 B", + "col_jaccard": "Jaccard", + "col_common": "共同指紋", + "col_earlier": "較早方", + "col_status": "狀態", + "col_integrity": "完整性評分", + "col_actions": "操作", + "col_script": "腳本", + "col_max_jaccard": "最高 Jaccard", + "col_coverage": "外源覆蓋率", + "col_pair_count": "相似對數", + "col_detected_at": "檢測時間", + "col_score": "分數", + "col_createtime": "時間", + "col_reason": "原因", + "col_added_by": "添加人", + "status_pending": "待審查", + "status_whitelisted": "已白名單", + "status_resolved": "已處理", + "review_pending": "待審", + "review_ok": "正常", + "review_violated": "違規", + "action_detail": "詳情", + "action_resolve": "處理", + "action_whitelist": "加入白名單", + "action_remove": "移除", + "confirm_remove_whitelist": "確認移除該白名單?", + "msg_removed": "已移除", + "modal_add_int_whitelist": "添加完整性豁免", + "label_script_id": "腳本 ID", + "label_reason": "原因", + "btn_add": "添加", + "modal_resolve_title": "標記完整性警告", + "label_decision": "判定", + "label_note": "備註", + "decision_ok": "正常", + "decision_violated": "違規", + "msg_review_resolved": "標記成功", + "msg_whitelisted": "已加入白名單", + "drawer_review_detail": "完整性警告詳情", + "label_score": "總分", + "label_sub_scores": "分類分數", + "label_hit_signals": "命中信號", + "label_jaccard": "Jaccard", + "label_common": "共同指紋", + "label_earlier": "較早方", + "label_detected_at": "檢測時間", + "label_script_a": "腳本 A", + "label_script_b": "腳本 B", + "label_code_diff": "程式碼差異" } }, "auth": { @@ -1841,5 +1901,17 @@ }, "utils": { "time_format": "YYYY年MM月DD日" + }, + "similarity": { + "evidence": { + "disclaimer_title": "初步判定,非最終結論", + "disclaimer_body": "此頁面為系統自動檢測的相似程式碼證據,僅供參考,請勿據此對作者做出結論性判斷。" + } + }, + "errors": { + "integrity_rejected": { + "title": "程式碼未通過完整性檢查", + "help": "如為誤判,請透過網站 FAQ 中的「管理員聯絡方式」申請豁免。" + } } } From 9e8574ebe378b62391b824c5abf47bf4d3ce15f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 14 Apr 2026 15:42:04 +0800 Subject: [PATCH 19/25] feat(similarity): Phase 4 backfill control panel + stop-fp refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the admin UI for §8.5 bootstrap operations: - BackfillControl component in a new "回填与重扫" dashboard tab, polling /admin/similarity/backfill/status every 5s while running. Shows total/cursor/progress bar/started_at/finished_at in a Descriptions panel with start + restart-from-zero (§8.5 step 9) + refresh buttons gated by running flag. - Manual per-script rescan card with script ID input. - Stop-fingerprint refresh card with warning copy ("通常不需要手动 触发"), invoking POST /admin/similarity/stop-fp/refresh — used at §8.5 step 8 after the first full backfill completes. - similarityService adds triggerBackfill / getBackfillStatus / manualScan / refreshStopFp methods and BackfillStatus type. - New admin.similarity.tab_backfill + admin.similarity.backfill.* translation keys in zh-CN. --- public/locales/zh-CN/translations.json | 33 ++- .../similarity/components/BackfillControl.tsx | 215 ++++++++++++++++++ .../components/SimilarityDashboardClient.tsx | 6 + src/lib/api/services/similarity.ts | 27 +++ 4 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 src/app/[locale]/(main)/admin/similarity/components/BackfillControl.tsx diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 3133ca86..79e709d3 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -322,7 +322,38 @@ "modal_add_int_whitelist": "添加完整性豁免", "label_script_id": "脚本 ID", "label_reason": "原因", - "btn_add": "添加" + "btn_add": "添加", + "tab_backfill": "回填与重扫", + "backfill": { + "help_title": "历史脚本回填", + "help_body": "系统上线后,只有新发布或更新的脚本会自动进入相似度扫描。要让历史脚本也参与比对,需要手动触发一次回填。回填会为每个脚本投递一条扫描消息,由后台 consumer 异步处理,过程中可安全离开此页面。", + "status_title": "回填状态", + "label_running": "状态", + "label_total": "总数", + "label_cursor": "进度", + "label_progress": "完成度", + "label_started_at": "开始时间", + "label_finished_at": "结束时间", + "state_running": "进行中", + "state_idle": "空闲", + "btn_start": "启动回填", + "btn_restart": "从头回填", + "btn_refresh": "刷新状态", + "confirm_start_title": "确认启动回填?", + "confirm_start_body": "将从上次暂停的游标继续投递扫描消息。", + "confirm_restart_title": "确认从头回填?", + "confirm_restart_body": "游标会重置为 0,全库脚本都会被重新扫描一次。通常只在首次上线或 stop-fp 列表刷新后需要这么做。", + "msg_started": "回填任务已启动", + "manual_scan_title": "手动重扫单个脚本", + "manual_scan_placeholder": "输入脚本 ID", + "btn_manual_scan": "发送扫描", + "msg_manual_scan_published": "扫描消息已投递", + "stop_fp_title": "Stop-fingerprint 刷新", + "stop_fp_warn_title": "通常不需要手动触发", + "stop_fp_warn_body": "Stop-fp 列表每小时由定时任务自动刷新。仅在首次上线完成全库回填后(§8.5 第 8 步)手动触发一次,使 Jaccard 计算过滤掉公共模板代码。", + "btn_stop_fp_refresh": "立即刷新", + "msg_stop_fp_refreshed": "Stop-fingerprint 集合已刷新" + } } }, "auth": { diff --git a/src/app/[locale]/(main)/admin/similarity/components/BackfillControl.tsx b/src/app/[locale]/(main)/admin/similarity/components/BackfillControl.tsx new file mode 100644 index 00000000..2c4f916b --- /dev/null +++ b/src/app/[locale]/(main)/admin/similarity/components/BackfillControl.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Card, + Descriptions, + InputNumber, + Modal, + Progress, + Space, + Tag, + message, +} from 'antd'; +import { useTranslations } from 'next-intl'; +import { similarityService } from '@/lib/api/services/similarity'; +import type { BackfillStatus } from '@/lib/api/services/similarity'; +import { APIError } from '@/types/api'; + +const POLL_INTERVAL_MS = 5000; + +function formatUnix(ts: number) { + if (!ts) return '-'; + return new Date(ts * 1000).toLocaleString(); +} + +export default function BackfillControl() { + const t = useTranslations('admin.similarity'); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [scanId, setScanId] = useState(null); + const pollTimer = useRef | null>(null); + + const loadStatus = useCallback(async () => { + try { + const resp = await similarityService.getBackfillStatus(); + setStatus(resp); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } + }, []); + + // Poll every 5s while the backfill is running, stop otherwise. + useEffect(() => { + loadStatus(); + return () => { + if (pollTimer.current) clearTimeout(pollTimer.current); + }; + }, [loadStatus]); + + useEffect(() => { + if (pollTimer.current) clearTimeout(pollTimer.current); + if (status?.running) { + pollTimer.current = setTimeout(loadStatus, POLL_INTERVAL_MS); + } + return () => { + if (pollTimer.current) clearTimeout(pollTimer.current); + }; + }, [status, loadStatus]); + + const trigger = useCallback( + async (reset: boolean) => { + setLoading(true); + try { + const resp = await similarityService.triggerBackfill(reset); + setStatus(resp); + message.success(t('backfill.msg_started')); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, + [t], + ); + + const confirmStart = useCallback(() => { + Modal.confirm({ + title: t('backfill.confirm_start_title'), + content: t('backfill.confirm_start_body'), + onOk: () => trigger(false), + }); + }, [t, trigger]); + + const confirmRestart = useCallback(() => { + Modal.confirm({ + title: t('backfill.confirm_restart_title'), + content: t('backfill.confirm_restart_body'), + okButtonProps: { danger: true }, + onOk: () => trigger(true), + }); + }, [t, trigger]); + + const runManualScan = useCallback(async () => { + if (!scanId || scanId <= 0) return; + setLoading(true); + try { + await similarityService.manualScan(scanId); + message.success(t('backfill.msg_manual_scan_published')); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, [scanId, t]); + + const refreshStopFp = useCallback(async () => { + setLoading(true); + try { + await similarityService.refreshStopFp(); + message.success(t('backfill.msg_stop_fp_refreshed')); + } catch (err) { + if (err instanceof APIError) message.error(err.msg); + } finally { + setLoading(false); + } + }, [t]); + + const percent = + status && status.total > 0 + ? Math.min(100, Math.round((status.cursor / status.total) * 100)) + : 0; + + return ( + + + + + + {status?.running ? ( + {t('backfill.state_running')} + ) : ( + {t('backfill.state_idle')} + )} + + + {status?.total ?? 0} + + + {status?.cursor ?? 0} + + + + + + {formatUnix(status?.started_at ?? 0)} + + + {formatUnix(status?.finished_at ?? 0)} + + + + + + + + + + + + setScanId(typeof v === 'number' ? v : null)} + min={1} + style={{ width: 200 }} + /> + + + + + + + + + + ); +} diff --git a/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx b/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx index 71978cdd..814d19a9 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/SimilarityDashboardClient.tsx @@ -8,6 +8,7 @@ import SuspectsTable from './SuspectsTable'; import IntegrityReviewTable from './IntegrityReviewTable'; import IntegrityWhitelistTable from './IntegrityWhitelistTable'; import PairWhitelistTable from './PairWhitelistTable'; +import BackfillControl from './BackfillControl'; export default function SimilarityDashboardClient() { const t = useTranslations('admin.similarity'); @@ -39,6 +40,11 @@ export default function SimilarityDashboardClient() { label: t('tab_integrity_whitelist'), children: , }, + { + key: 'backfill', + label: t('tab_backfill'), + children: , + }, ]} /> ); diff --git a/src/lib/api/services/similarity.ts b/src/lib/api/services/similarity.ts index 77278dc6..05762886 100644 --- a/src/lib/api/services/similarity.ts +++ b/src/lib/api/services/similarity.ts @@ -230,6 +230,33 @@ class SimilarityService { `${this.publicBase}/pair/${id}`, ); } + + // Phase 4: backfill / manual scan + triggerBackfill(reset: boolean) { + return apiClient.post(`${this.adminBase}/backfill`, { + reset, + }); + } + + getBackfillStatus() { + return apiClient.get(`${this.adminBase}/backfill/status`); + } + + manualScan(scriptID: number) { + return apiClient.post(`${this.adminBase}/scan/${scriptID}`, {}); + } + + refreshStopFp() { + return apiClient.post(`${this.adminBase}/stop-fp/refresh`, {}); + } +} + +export interface BackfillStatus { + running: boolean; + cursor: number; + total: number; + started_at: number; + finished_at: number; } export const similarityService = new SimilarityService(); From b570ceb2327b34cf9a1a3a39de9898fd7de9ade4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 15 Apr 2026 11:09:09 +0800 Subject: [PATCH 20/25] fix(similarity): flag deleted scripts in pair list with toggle filter Backend now marks each ScriptBrief with is_deleted and accepts an exclude_deleted query param. Render deleted scripts with a strikethrough link and red tag, and add a Switch above the table that lets admins hide any pair whose either side has been soft-deleted. --- public/locales/zh-CN/translations.json | 2 + .../similarity/components/PairsTable.tsx | 83 +++++++++++++------ src/lib/api/services/similarity.ts | 2 + 3 files changed, 63 insertions(+), 24 deletions(-) diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index 79e709d3..cacd2a07 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -290,6 +290,8 @@ "status_pending": "待审查", "status_whitelisted": "已白名单", "status_resolved": "已处理", + "script_deleted": "已删除", + "filter_exclude_deleted": "隐藏含已删除脚本的对", "review_pending": "待审", "review_ok": "正常", "review_violated": "违规", diff --git a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx index 848f07ab..c9aec6c0 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/PairsTable.tsx @@ -1,29 +1,54 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; -import { Button, Space, Table, Tag, message } from 'antd'; +import { Button, Space, Switch, Table, Tag, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/routing'; import { similarityService } from '@/lib/api/services/similarity'; -import type { SimilarPairItem } from '@/lib/api/services/similarity'; +import type { + ScriptBrief, + SimilarPairItem, +} from '@/lib/api/services/similarity'; import { APIError } from '@/types/api'; const PAGE_SIZE = 20; +function ScriptCell({ + script, + deletedLabel, +}: { + script: ScriptBrief; + deletedLabel: string; +}) { + return ( + + + {script.name} + + {script.is_deleted ? {deletedLabel} : null} + + ); +} + export default function PairsTable() { const t = useTranslations('admin.similarity'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); + const [excludeDeleted, setExcludeDeleted] = useState(false); - const load = useCallback(async (p: number) => { + const load = useCallback(async (p: number, exclude: boolean) => { setLoading(true); try { const resp = await similarityService.listPairs({ page: p, size: PAGE_SIZE, + exclude_deleted: exclude || undefined, }); setData(resp.list ?? []); setTotal(resp.total ?? 0); @@ -35,25 +60,23 @@ export default function PairsTable() { }, []); useEffect(() => { - load(page); - }, [page, load]); + load(page, excludeDeleted); + }, [page, excludeDeleted, load]); + + const deletedLabel = t('script_deleted'); const columns: ColumnsType = [ { title: t('col_id'), dataIndex: 'id', width: 70 }, { title: t('col_script_a'), render: (_, r) => ( - - {r.script_a.name} - + ), }, { title: t('col_script_b'), render: (_, r) => ( - - {r.script_b.name} - + ), }, { @@ -103,18 +126,30 @@ export default function PairsTable() { ]; return ( -
+ + + {t('filter_exclude_deleted')} + { + setPage(1); + setExcludeDeleted(v); + }} + /> + +
+ ); } diff --git a/src/lib/api/services/similarity.ts b/src/lib/api/services/similarity.ts index 05762886..8e5d69e9 100644 --- a/src/lib/api/services/similarity.ts +++ b/src/lib/api/services/similarity.ts @@ -8,6 +8,7 @@ export interface ScriptBrief { username: string; public: number; createtime: number; + is_deleted: boolean; } export interface ScriptFullInfo extends ScriptBrief { @@ -134,6 +135,7 @@ class SimilarityService { status?: number; min_jaccard?: number; script_id?: number; + exclude_deleted?: boolean; }) { return apiClient.get>( `${this.adminBase}/pairs`, From d0a6b7ca4a01d3c34bec2eeb75beff2f9da0b145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 16 Apr 2026 11:24:42 +0800 Subject: [PATCH 21/25] feat(similarity): show signal descriptions in integrity review table --- .../components/IntegrityReviewTable.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx index dd21a30a..6f4d7026 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -15,6 +15,20 @@ import ResolveReviewModal from './ResolveReviewModal'; const PAGE_SIZE = 20; +const SIGNAL_DESCRIPTIONS: Record = { + avg_line_length: '平均行长度过长(代码可能被压缩为少量长行)', + max_line_length: '最大行长度过长(存在超长代码行)', + whitespace_ratio: '空白字符比例过低(代码缺少正常的空格和缩进)', + comment_ratio: '注释比例过低(代码几乎没有注释)', + single_char_ident_ratio: '单字符变量名比例过高(变量名被缩短为单个字符)', + hex_ident_ratio: '十六进制变量名比例过高(使用了 _0x 开头的混淆变量名)', + large_string_array: '检测到大型字符串数组(常见于混淆工具的字符串表)', + dean_edwards_packer: '检测到 Dean Edwards 打包器', + aa_encode: '检测到 AAEncode 编码', + jj_encode: '检测到 JJEncode 编码', + eval_density: 'eval/动态执行调用密度过高', +}; + export default function IntegrityReviewTable() { const t = useTranslations('admin.similarity'); const [data, setData] = useState([]); @@ -140,8 +154,11 @@ export default function IntegrityReviewTable() {
    {detail.hit_signals.map((h) => (
  • - {h.name}: {h.value.toFixed(3)} (threshold{' '} - {h.threshold.toFixed(3)}) + {SIGNAL_DESCRIPTIONS[h.name] ?? h.name} +
    + + {h.name}: {h.value.toFixed(3)} / {h.threshold.toFixed(3)} +
  • ))}
From 33667a41cd7f1871e14de712046f3e6d8cb046f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 16 Apr 2026 16:09:23 +0800 Subject: [PATCH 22/25] refactor(similarity): move SIGNAL_DESCRIPTIONS hardcoded Chinese strings to i18n Replace the hardcoded SIGNAL_DESCRIPTIONS constant in IntegrityReviewTable with next-intl t() calls under admin.similarity.signal_desc.*, adding translations for all 6 locales (zh-CN, en-US, de-DE, ja-JP, ru-RU, zh-TW). --- public/locales/de-DE/translations.json | 15 ++++++++++++++- public/locales/en-US/translations.json | 15 ++++++++++++++- public/locales/ja-JP/translations.json | 15 ++++++++++++++- public/locales/ru-RU/translations.json | 15 ++++++++++++++- public/locales/zh-CN/translations.json | 13 +++++++++++++ public/locales/zh-TW/translations.json | 15 ++++++++++++++- .../components/IntegrityReviewTable.tsx | 16 +--------------- 7 files changed, 84 insertions(+), 20 deletions(-) diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index 3f914718..173350a2 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -270,7 +270,20 @@ "label_detected_at": "Detected At", "label_script_a": "Script A", "label_script_b": "Script B", - "label_code_diff": "Code Diff" + "label_code_diff": "Code Diff", + "signal_desc": { + "avg_line_length": "Durchschnittliche Zeilenlänge zu hoch (Code möglicherweise in wenige lange Zeilen komprimiert)", + "max_line_length": "Maximale Zeilenlänge zu hoch (enthält extrem lange Zeilen)", + "whitespace_ratio": "Leerzeichenanteil zu niedrig (Code fehlt normale Einrückung)", + "comment_ratio": "Kommentaranteil zu niedrig (Code hat fast keine Kommentare)", + "single_char_ident_ratio": "Anteil einstelliger Bezeichner zu hoch (Variablennamen auf einzelne Zeichen verkürzt)", + "hex_ident_ratio": "Anteil hexadezimaler Bezeichner zu hoch (verwendet _0x-verschleierte Variablennamen)", + "large_string_array": "Großes String-Array erkannt (typisch für Verschleierungswerkzeuge)", + "dean_edwards_packer": "Dean Edwards Packer erkannt", + "aa_encode": "AAEncode-Kodierung erkannt", + "jj_encode": "JJEncode-Kodierung erkannt", + "eval_density": "eval/dynamische Ausführungsdichte zu hoch" + } } }, "auth": { diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index a4efa0bc..4adfd08b 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -309,7 +309,20 @@ "label_detected_at": "Detected At", "label_script_a": "Script A", "label_script_b": "Script B", - "label_code_diff": "Code Diff" + "label_code_diff": "Code Diff", + "signal_desc": { + "avg_line_length": "Average line length too high (code may be compressed into few long lines)", + "max_line_length": "Maximum line length too high (contains extremely long lines)", + "whitespace_ratio": "Whitespace ratio too low (code lacks normal spacing and indentation)", + "comment_ratio": "Comment ratio too low (code has almost no comments)", + "single_char_ident_ratio": "Single-character identifier ratio too high (variable names shortened to single characters)", + "hex_ident_ratio": "Hex identifier ratio too high (uses _0x prefixed obfuscated variable names)", + "large_string_array": "Large string array detected (common in obfuscation tool string tables)", + "dean_edwards_packer": "Dean Edwards packer detected", + "aa_encode": "AAEncode encoding detected", + "jj_encode": "JJEncode encoding detected", + "eval_density": "eval/dynamic execution call density too high" + } } }, "auth": { diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index c318dbcb..e749b05f 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -270,7 +270,20 @@ "label_detected_at": "Detected At", "label_script_a": "Script A", "label_script_b": "Script B", - "label_code_diff": "Code Diff" + "label_code_diff": "Code Diff", + "signal_desc": { + "avg_line_length": "平均行長が長すぎます(コードが少数の長い行に圧縮されている可能性)", + "max_line_length": "最大行長が長すぎます(非常に長い行が存在)", + "whitespace_ratio": "空白比率が低すぎます(コードに通常のスペースやインデントがない)", + "comment_ratio": "コメント比率が低すぎます(コードにほとんどコメントがない)", + "single_char_ident_ratio": "1文字識別子の割合が高すぎます(変数名が1文字に短縮)", + "hex_ident_ratio": "16進識別子の割合が高すぎます(_0xプレフィックスの難読化変数名を使用)", + "large_string_array": "大きな文字列配列を検出(難読化ツールの文字列テーブルに多い)", + "dean_edwards_packer": "Dean Edwardsパッカーを検出", + "aa_encode": "AAEncodeエンコーディングを検出", + "jj_encode": "JJEncodeエンコーディングを検出", + "eval_density": "eval/動的実行呼び出し密度が高すぎます" + } } }, "auth": { diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index 8ce68dec..03ebe61d 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -270,7 +270,20 @@ "label_detected_at": "Detected At", "label_script_a": "Script A", "label_script_b": "Script B", - "label_code_diff": "Code Diff" + "label_code_diff": "Code Diff", + "signal_desc": { + "avg_line_length": "Средняя длина строки слишком велика (код может быть сжат в несколько длинных строк)", + "max_line_length": "Максимальная длина строки слишком велика (содержит чрезмерно длинные строки)", + "whitespace_ratio": "Доля пробелов слишком мала (в коде нет нормальных отступов)", + "comment_ratio": "Доля комментариев слишком мала (в коде почти нет комментариев)", + "single_char_ident_ratio": "Доля однобуквенных идентификаторов слишком велика (имена переменных сокращены до одного символа)", + "hex_ident_ratio": "Доля шестнадцатеричных идентификаторов слишком велика (используются обфусцированные имена с _0x)", + "large_string_array": "Обнаружен большой массив строк (характерно для инструментов обфускации)", + "dean_edwards_packer": "Обнаружен упаковщик Dean Edwards", + "aa_encode": "Обнаружена кодировка AAEncode", + "jj_encode": "Обнаружена кодировка JJEncode", + "eval_density": "Плотность вызовов eval/динамического выполнения слишком высока" + } } }, "auth": { diff --git a/public/locales/zh-CN/translations.json b/public/locales/zh-CN/translations.json index cacd2a07..8f2ce974 100644 --- a/public/locales/zh-CN/translations.json +++ b/public/locales/zh-CN/translations.json @@ -326,6 +326,19 @@ "label_reason": "原因", "btn_add": "添加", "tab_backfill": "回填与重扫", + "signal_desc": { + "avg_line_length": "平均行长度过长(代码可能被压缩为少量长行)", + "max_line_length": "最大行长度过长(存在超长代码行)", + "whitespace_ratio": "空白字符比例过低(代码缺少正常的空格和缩进)", + "comment_ratio": "注释比例过低(代码几乎没有注释)", + "single_char_ident_ratio": "单字符变量名比例过高(变量名被缩短为单个字符)", + "hex_ident_ratio": "十六进制变量名比例过高(使用了 _0x 开头的混淆变量名)", + "large_string_array": "检测到大型字符串数组(常见于混淆工具的字符串表)", + "dean_edwards_packer": "检测到 Dean Edwards 打包器", + "aa_encode": "检测到 AAEncode 编码", + "jj_encode": "检测到 JJEncode 编码", + "eval_density": "eval/动态执行调用密度过高" + }, "backfill": { "help_title": "历史脚本回填", "help_body": "系统上线后,只有新发布或更新的脚本会自动进入相似度扫描。要让历史脚本也参与比对,需要手动触发一次回填。回填会为每个脚本投递一条扫描消息,由后台 consumer 异步处理,过程中可安全离开此页面。", diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index b6c46f95..58350c94 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -270,7 +270,20 @@ "label_detected_at": "檢測時間", "label_script_a": "腳本 A", "label_script_b": "腳本 B", - "label_code_diff": "程式碼差異" + "label_code_diff": "程式碼差異", + "signal_desc": { + "avg_line_length": "平均行長度過長(程式碼可能被壓縮為少量長行)", + "max_line_length": "最大行長度過長(存在超長程式碼行)", + "whitespace_ratio": "空白字元比例過低(程式碼缺少正常的空格和縮排)", + "comment_ratio": "註解比例過低(程式碼幾乎沒有註解)", + "single_char_ident_ratio": "單字元變數名稱比例過高(變數名稱被縮短為單個字元)", + "hex_ident_ratio": "十六進位變數名稱比例過高(使用了 _0x 開頭的混淆變數名稱)", + "large_string_array": "偵測到大型字串陣列(常見於混淆工具的字串表)", + "dean_edwards_packer": "偵測到 Dean Edwards 打包器", + "aa_encode": "偵測到 AAEncode 編碼", + "jj_encode": "偵測到 JJEncode 編碼", + "eval_density": "eval/動態執行呼叫密度過高" + } } }, "auth": { diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx index 6f4d7026..50127876 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -15,20 +15,6 @@ import ResolveReviewModal from './ResolveReviewModal'; const PAGE_SIZE = 20; -const SIGNAL_DESCRIPTIONS: Record = { - avg_line_length: '平均行长度过长(代码可能被压缩为少量长行)', - max_line_length: '最大行长度过长(存在超长代码行)', - whitespace_ratio: '空白字符比例过低(代码缺少正常的空格和缩进)', - comment_ratio: '注释比例过低(代码几乎没有注释)', - single_char_ident_ratio: '单字符变量名比例过高(变量名被缩短为单个字符)', - hex_ident_ratio: '十六进制变量名比例过高(使用了 _0x 开头的混淆变量名)', - large_string_array: '检测到大型字符串数组(常见于混淆工具的字符串表)', - dean_edwards_packer: '检测到 Dean Edwards 打包器', - aa_encode: '检测到 AAEncode 编码', - jj_encode: '检测到 JJEncode 编码', - eval_density: 'eval/动态执行调用密度过高', -}; - export default function IntegrityReviewTable() { const t = useTranslations('admin.similarity'); const [data, setData] = useState([]); @@ -154,7 +140,7 @@ export default function IntegrityReviewTable() {
    {detail.hit_signals.map((h) => (
  • - {SIGNAL_DESCRIPTIONS[h.name] ?? h.name} + {t.has(`signal_desc.${h.name}`) ? t(`signal_desc.${h.name}`) : h.name}
    {h.name}: {h.value.toFixed(3)} / {h.threshold.toFixed(3)} From ad3092252b6e83acfdeef5a70e6d92673fc468a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 16 Apr 2026 16:12:47 +0800 Subject: [PATCH 23/25] feat(i18n): add missing Phase 4 similarity keys to all non-zh-CN locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backfills script_deleted, filter_exclude_deleted, tab_backfill, and the full backfill sub-object (28 keys) into en-US, de-DE, ja-JP, ru-RU, and zh-TW — inserted before signal_desc to maintain consistent key ordering. --- public/locales/de-DE/translations.json | 33 ++++++++++++++++++++++++++ public/locales/en-US/translations.json | 33 ++++++++++++++++++++++++++ public/locales/ja-JP/translations.json | 33 ++++++++++++++++++++++++++ public/locales/ru-RU/translations.json | 33 ++++++++++++++++++++++++++ public/locales/zh-TW/translations.json | 33 ++++++++++++++++++++++++++ 5 files changed, 165 insertions(+) diff --git a/public/locales/de-DE/translations.json b/public/locales/de-DE/translations.json index 173350a2..6590701b 100644 --- a/public/locales/de-DE/translations.json +++ b/public/locales/de-DE/translations.json @@ -271,6 +271,39 @@ "label_script_a": "Script A", "label_script_b": "Script B", "label_code_diff": "Code Diff", + "script_deleted": "Gelöscht", + "filter_exclude_deleted": "Paare mit gelöschten Skripten ausblenden", + "tab_backfill": "Nachfüllung & Neuscan", + "backfill": { + "help_title": "Historische Skript-Nachfüllung", + "help_body": "Nach der Bereitstellung des Systems werden nur neu veröffentlichte oder aktualisierte Skripte automatisch auf Ähnlichkeit gescannt. Um historische Skripte einzubeziehen, lösen Sie eine manuelle Nachfüllung aus. Die Nachfüllung sendet für jedes Skript eine Scan-Nachricht, die asynchron verarbeitet wird. Sie können diese Seite während des Vorgangs sicher verlassen.", + "status_title": "Nachfüllungsstatus", + "label_running": "Status", + "label_total": "Gesamt", + "label_cursor": "Cursor", + "label_progress": "Fortschritt", + "label_started_at": "Gestartet am", + "label_finished_at": "Beendet am", + "state_running": "Läuft", + "state_idle": "Inaktiv", + "btn_start": "Nachfüllung starten", + "btn_restart": "Von Anfang neu starten", + "btn_refresh": "Status aktualisieren", + "confirm_start_title": "Nachfüllung starten?", + "confirm_start_body": "Scan-Nachrichten werden ab der letzten Cursor-Position fortgesetzt.", + "confirm_restart_title": "Nachfüllung von Anfang neu starten?", + "confirm_restart_body": "Der Cursor wird auf 0 zurückgesetzt und alle Skripte werden erneut gescannt. Dies ist normalerweise nur bei der Erstbereitstellung oder nach Aktualisierung der Stop-FP-Liste erforderlich.", + "msg_started": "Nachfüllungsaufgabe gestartet", + "manual_scan_title": "Einzelnes Skript manuell scannen", + "manual_scan_placeholder": "Skript-ID eingeben", + "btn_manual_scan": "Scan senden", + "msg_manual_scan_published": "Scan-Nachricht veröffentlicht", + "stop_fp_title": "Stop-Fingerprint Aktualisierung", + "stop_fp_warn_title": "Manuelles Auslösen normalerweise nicht nötig", + "stop_fp_warn_body": "Die Stop-FP-Liste wird automatisch stündlich aktualisiert. Nur einmal nach Abschluss der vollständigen Nachfüllung manuell auslösen, damit Jaccard-Berechnungen Template-Code herausfiltern.", + "btn_stop_fp_refresh": "Jetzt aktualisieren", + "msg_stop_fp_refreshed": "Stop-Fingerprint-Satz aktualisiert" + }, "signal_desc": { "avg_line_length": "Durchschnittliche Zeilenlänge zu hoch (Code möglicherweise in wenige lange Zeilen komprimiert)", "max_line_length": "Maximale Zeilenlänge zu hoch (enthält extrem lange Zeilen)", diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 4adfd08b..c11838d7 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -310,6 +310,39 @@ "label_script_a": "Script A", "label_script_b": "Script B", "label_code_diff": "Code Diff", + "script_deleted": "Deleted", + "filter_exclude_deleted": "Hide pairs with deleted scripts", + "tab_backfill": "Backfill & Rescan", + "backfill": { + "help_title": "Historical Script Backfill", + "help_body": "After the system is deployed, only newly published or updated scripts are automatically scanned for similarity. To include historical scripts in comparisons, trigger a manual backfill. The backfill sends a scan message for each script, processed asynchronously by background consumers. You can safely leave this page during the process.", + "status_title": "Backfill Status", + "label_running": "Status", + "label_total": "Total", + "label_cursor": "Cursor", + "label_progress": "Progress", + "label_started_at": "Started At", + "label_finished_at": "Finished At", + "state_running": "Running", + "state_idle": "Idle", + "btn_start": "Start Backfill", + "btn_restart": "Restart from Beginning", + "btn_refresh": "Refresh Status", + "confirm_start_title": "Start backfill?", + "confirm_start_body": "This will continue sending scan messages from the last cursor position.", + "confirm_restart_title": "Restart backfill from beginning?", + "confirm_restart_body": "The cursor will reset to 0, and all scripts in the database will be re-scanned. This is usually only needed on initial deployment or after refreshing the stop-fp list.", + "msg_started": "Backfill task started", + "manual_scan_title": "Manually Rescan Single Script", + "manual_scan_placeholder": "Enter script ID", + "btn_manual_scan": "Send Scan", + "msg_manual_scan_published": "Scan message published", + "stop_fp_title": "Stop-fingerprint Refresh", + "stop_fp_warn_title": "Manual trigger usually not needed", + "stop_fp_warn_body": "The stop-fp list is refreshed automatically every hour by a scheduled task. Only trigger manually once after completing the initial full backfill (step 8 in §8.5), so Jaccard calculations filter out common template code.", + "btn_stop_fp_refresh": "Refresh Now", + "msg_stop_fp_refreshed": "Stop-fingerprint set refreshed" + }, "signal_desc": { "avg_line_length": "Average line length too high (code may be compressed into few long lines)", "max_line_length": "Maximum line length too high (contains extremely long lines)", diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index e749b05f..edbe2ae9 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -271,6 +271,39 @@ "label_script_a": "Script A", "label_script_b": "Script B", "label_code_diff": "Code Diff", + "script_deleted": "削除済み", + "filter_exclude_deleted": "削除されたスクリプトを含むペアを非表示", + "tab_backfill": "バックフィル&再スキャン", + "backfill": { + "help_title": "過去のスクリプトのバックフィル", + "help_body": "システム導入後、新しく公開または更新されたスクリプトのみが自動的に類似性スキャンされます。過去のスクリプトも比較対象にするには、手動でバックフィルを実行してください。バックフィルは各スクリプトのスキャンメッセージを送信し、バックグラウンドで非同期に処理されます。処理中にこのページから離れても問題ありません。", + "status_title": "バックフィル状態", + "label_running": "状態", + "label_total": "合計", + "label_cursor": "カーソル", + "label_progress": "進捗", + "label_started_at": "開始日時", + "label_finished_at": "終了日時", + "state_running": "実行中", + "state_idle": "アイドル", + "btn_start": "バックフィル開始", + "btn_restart": "最初からやり直す", + "btn_refresh": "状態を更新", + "confirm_start_title": "バックフィルを開始しますか?", + "confirm_start_body": "最後のカーソル位置からスキャンメッセージの送信を続行します。", + "confirm_restart_title": "最初からバックフィルをやり直しますか?", + "confirm_restart_body": "カーソルが0にリセットされ、データベース内のすべてのスクリプトが再スキャンされます。通常、初回デプロイ時またはstop-fpリストの更新後にのみ必要です。", + "msg_started": "バックフィルタスクを開始しました", + "manual_scan_title": "単一スクリプトの手動再スキャン", + "manual_scan_placeholder": "スクリプトIDを入力", + "btn_manual_scan": "スキャン送信", + "msg_manual_scan_published": "スキャンメッセージを送信しました", + "stop_fp_title": "Stop-fingerprint更新", + "stop_fp_warn_title": "通常、手動実行は不要です", + "stop_fp_warn_body": "Stop-fpリストは定期タスクにより毎時自動更新されます。初回の全体バックフィル完了後に一度だけ手動で実行し、Jaccard計算で共通テンプレートコードを除外できるようにしてください。", + "btn_stop_fp_refresh": "今すぐ更新", + "msg_stop_fp_refreshed": "Stop-fingerprintセットを更新しました" + }, "signal_desc": { "avg_line_length": "平均行長が長すぎます(コードが少数の長い行に圧縮されている可能性)", "max_line_length": "最大行長が長すぎます(非常に長い行が存在)", diff --git a/public/locales/ru-RU/translations.json b/public/locales/ru-RU/translations.json index 03ebe61d..b2e3c30a 100644 --- a/public/locales/ru-RU/translations.json +++ b/public/locales/ru-RU/translations.json @@ -271,6 +271,39 @@ "label_script_a": "Script A", "label_script_b": "Script B", "label_code_diff": "Code Diff", + "script_deleted": "Удалён", + "filter_exclude_deleted": "Скрыть пары с удалёнными скриптами", + "tab_backfill": "Заполнение и повторное сканирование", + "backfill": { + "help_title": "Заполнение исторических скриптов", + "help_body": "После развёртывания системы автоматически сканируются только вновь опубликованные или обновлённые скрипты. Чтобы включить исторические скрипты в сравнение, запустите ручное заполнение. Для каждого скрипта отправляется сообщение на сканирование, которое обрабатывается асинхронно. Вы можете безопасно покинуть эту страницу во время процесса.", + "status_title": "Статус заполнения", + "label_running": "Статус", + "label_total": "Всего", + "label_cursor": "Курсор", + "label_progress": "Прогресс", + "label_started_at": "Начало", + "label_finished_at": "Завершение", + "state_running": "Выполняется", + "state_idle": "Простаивает", + "btn_start": "Начать заполнение", + "btn_restart": "Начать сначала", + "btn_refresh": "Обновить статус", + "confirm_start_title": "Начать заполнение?", + "confirm_start_body": "Отправка сообщений сканирования продолжится с последней позиции курсора.", + "confirm_restart_title": "Начать заполнение сначала?", + "confirm_restart_body": "Курсор будет сброшен на 0, все скрипты в базе данных будут пересканированы. Обычно это требуется только при первоначальном развёртывании или после обновления списка stop-fp.", + "msg_started": "Задача заполнения запущена", + "manual_scan_title": "Ручное сканирование скрипта", + "manual_scan_placeholder": "Введите ID скрипта", + "btn_manual_scan": "Отправить сканирование", + "msg_manual_scan_published": "Сообщение сканирования отправлено", + "stop_fp_title": "Обновление Stop-fingerprint", + "stop_fp_warn_title": "Ручной запуск обычно не требуется", + "stop_fp_warn_body": "Список stop-fp обновляется автоматически каждый час. Запустите вручную только один раз после завершения полного заполнения, чтобы расчёты Jaccard отфильтровывали общий шаблонный код.", + "btn_stop_fp_refresh": "Обновить сейчас", + "msg_stop_fp_refreshed": "Набор Stop-fingerprint обновлён" + }, "signal_desc": { "avg_line_length": "Средняя длина строки слишком велика (код может быть сжат в несколько длинных строк)", "max_line_length": "Максимальная длина строки слишком велика (содержит чрезмерно длинные строки)", diff --git a/public/locales/zh-TW/translations.json b/public/locales/zh-TW/translations.json index 58350c94..4edeacc0 100644 --- a/public/locales/zh-TW/translations.json +++ b/public/locales/zh-TW/translations.json @@ -271,6 +271,39 @@ "label_script_a": "腳本 A", "label_script_b": "腳本 B", "label_code_diff": "程式碼差異", + "script_deleted": "已刪除", + "filter_exclude_deleted": "隱藏含已刪除腳本的對", + "tab_backfill": "回填與重掃", + "backfill": { + "help_title": "歷史腳本回填", + "help_body": "系統上線後,只有新發佈或更新的腳本會自動進入相似度掃描。要讓歷史腳本也參與比對,需要手動觸發一次回填。回填會為每個腳本投遞一條掃描訊息,由後台 consumer 非同步處理,過程中可安全離開此頁面。", + "status_title": "回填狀態", + "label_running": "狀態", + "label_total": "總數", + "label_cursor": "進度", + "label_progress": "完成度", + "label_started_at": "開始時間", + "label_finished_at": "結束時間", + "state_running": "進行中", + "state_idle": "閒置", + "btn_start": "啟動回填", + "btn_restart": "從頭回填", + "btn_refresh": "重新整理狀態", + "confirm_start_title": "確認啟動回填?", + "confirm_start_body": "將從上次暫停的游標繼續投遞掃描訊息。", + "confirm_restart_title": "確認從頭回填?", + "confirm_restart_body": "游標會重置為 0,全庫腳本都會被重新掃描一次。通常只在首次上線或 stop-fp 清單重新整理後需要這麼做。", + "msg_started": "回填任務已啟動", + "manual_scan_title": "手動重掃單個腳本", + "manual_scan_placeholder": "輸入腳本 ID", + "btn_manual_scan": "傳送掃描", + "msg_manual_scan_published": "掃描訊息已投遞", + "stop_fp_title": "Stop-fingerprint 重新整理", + "stop_fp_warn_title": "通常不需要手動觸發", + "stop_fp_warn_body": "Stop-fp 清單每小時由定時任務自動重新整理。僅在首次上線完成全庫回填後(§8.5 第 8 步)手動觸發一次,使 Jaccard 計算過濾掉公共範本程式碼。", + "btn_stop_fp_refresh": "立即重新整理", + "msg_stop_fp_refreshed": "Stop-fingerprint 集合已重新整理" + }, "signal_desc": { "avg_line_length": "平均行長度過長(程式碼可能被壓縮為少量長行)", "max_line_length": "最大行長度過長(存在超長程式碼行)", From 600c29e5d1782ead2cedec4b83968cc8d27fc4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 16 Apr 2026 16:14:26 +0800 Subject: [PATCH 24/25] style: fix prettier formatting in IntegrityReviewTable --- .../admin/similarity/components/IntegrityReviewTable.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx index 50127876..97d79448 100644 --- a/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx +++ b/src/app/[locale]/(main)/admin/similarity/components/IntegrityReviewTable.tsx @@ -140,7 +140,11 @@ export default function IntegrityReviewTable() {
      {detail.hit_signals.map((h) => (
    • - {t.has(`signal_desc.${h.name}`) ? t(`signal_desc.${h.name}`) : h.name} + + {t.has(`signal_desc.${h.name}`) + ? t(`signal_desc.${h.name}`) + : h.name} +
      {h.name}: {h.value.toFixed(3)} / {h.threshold.toFixed(3)} From a9a840c90051e32f538893cd08ce759ca76650f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Thu, 16 Apr 2026 16:21:31 +0800 Subject: [PATCH 25/25] refactor: extract PairDetailClient and CodeDiffViewer to shared components Move these components from admin route directory to src/components/similarity/ so both the admin detail page and public evidence page import from the same shared location, eliminating the cross-layer dependency. --- .../[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx | 2 +- .../similarity/pair/[id]/components/EvidencePageClient.tsx | 2 +- .../components => components/similarity}/CodeDiffViewer.tsx | 0 .../components => components/similarity}/PairDetailClient.tsx | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename src/{app/[locale]/(main)/admin/similarity/pairs/[pairId]/components => components/similarity}/CodeDiffViewer.tsx (100%) rename src/{app/[locale]/(main)/admin/similarity/pairs/[pairId]/components => components/similarity}/PairDetailClient.tsx (100%) diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx index 8dcb2554..6b8a7713 100644 --- a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx +++ b/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/page.tsx @@ -1,4 +1,4 @@ -import PairDetailClient from './components/PairDetailClient'; +import PairDetailClient from '@/components/similarity/PairDetailClient'; export default async function PairDetailPage({ params, diff --git a/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx index d94861fb..3ca5d675 100644 --- a/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx +++ b/src/app/[locale]/(main)/similarity/pair/[id]/components/EvidencePageClient.tsx @@ -2,7 +2,7 @@ import { Alert } from 'antd'; import { useTranslations } from 'next-intl'; -import PairDetailClient from '@/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient'; +import PairDetailClient from '@/components/similarity/PairDetailClient'; export default function EvidencePageClient({ pairID }: { pairID: number }) { const t = useTranslations('similarity.evidence'); diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx b/src/components/similarity/CodeDiffViewer.tsx similarity index 100% rename from src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/CodeDiffViewer.tsx rename to src/components/similarity/CodeDiffViewer.tsx diff --git a/src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx b/src/components/similarity/PairDetailClient.tsx similarity index 100% rename from src/app/[locale]/(main)/admin/similarity/pairs/[pairId]/components/PairDetailClient.tsx rename to src/components/similarity/PairDetailClient.tsx