From aa63336f51211396a6adafaef666d5a69a266d76 Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Thu, 18 Jun 2026 16:21:41 +0800 Subject: [PATCH 1/7] feat: maidata hash lookup --- public/i18n/en.json | 7 + public/i18n/ja.json | 7 + public/i18n/ko.json | 7 + public/i18n/zh.json | 7 + src/components/user/ChartUploader.tsx | 227 +++++++++++++++++++++++++- src/config/api.ts | 1 + 6 files changed, 253 insertions(+), 3 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index f44f6a0..cdc1300 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -142,6 +142,13 @@ "AvatarSettings": "Avatar Settings", "PersonalIntro": "Personal Introduction", "NoFileSelected": "No file selected for ", + "MaidataHashChecking": "Checking whether this chart already exists...", + "MaidataHashNotFound": "No existing chart found", + "MaidataAlreadyExists": "This chart already exists on the site", + "MaidataAlreadyExistsWithTitle": "This chart already exists on the site", + "MaidataWillInheritScores": "This upload will inherit previous scores", + "MaidataHashLookupFailed": "Failed to query maidata.txt hash", + "View": "View", "ManageYourCharts": "Manage your uploaded charts", "ModifyPersonalInfo": "Modify personal information and settings", "ViewYourHomePage": "View your personal homepage", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index c7a4f31..548f1fd 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -142,6 +142,13 @@ "AvatarSettings": "アバター設定", "PersonalIntro": "自己紹介", "NoFileSelected": "ファイルが選択されていません: ", + "MaidataHashChecking": "この譜面が既に存在するか確認しています...", + "MaidataHashNotFound": "既存の譜面は見つかりませんでした", + "MaidataAlreadyExists": "この譜面はすでにサイト上に存在します", + "MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します", + "MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます", + "MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました", + "View": "表示", "ManageYourCharts": "アップロードした譜面を管理", "ModifyPersonalInfo": "個人情報と設定を変更", "ViewYourHomePage": "個人ホームページを表示", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 436f7f6..846de6e 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -142,6 +142,13 @@ "AvatarSettings": "아바타 설정", "PersonalIntro": "자기소개", "NoFileSelected": "파일이 선택되지 않았습니다: ", + "MaidataHashChecking": "이 채보가 이미 존재하는지 확인하는 중...", + "MaidataHashNotFound": "기존 채보를 찾지 못했습니다", + "MaidataAlreadyExists": "이 채보는 이미 사이트에 존재합니다", + "MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다", + "MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다", + "MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다", + "View": "보기", "ManageYourCharts": "내가 올린 채보를 관리하세요", "ModifyPersonalInfo": "개인 정보와 설정을 수정하세요", "ViewYourHomePage": "내 개인 홈을 확인하세요", diff --git a/public/i18n/zh.json b/public/i18n/zh.json index f90c908..1f3f60b 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -144,6 +144,13 @@ "AvatarSettings": "头像设置", "PersonalIntro": "个人简介", "NoFileSelected": "没有选中", + "MaidataHashChecking": "正在查询这个谱面是否已存在...", + "MaidataHashNotFound": "未找到已存在的谱面", + "MaidataAlreadyExists": "这个谱面已经存在于网站上", + "MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上", + "MaidataWillInheritScores": "这次上传会继承之前的分数", + "MaidataHashLookupFailed": "查询 maidata.txt 哈希失败", + "View": "查看", "ManageYourCharts": "管理您上传的谱面", "ModifyPersonalInfo": "修改个人信息和设置", "ViewYourHomePage": "查看您的个人主页", diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index 5238001..1b0ee03 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -1,16 +1,197 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; +import { md5 } from 'js-md5'; import { endpoints } from '@/config/api'; import { useLoc } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; import { motion } from 'framer-motion'; import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md'; import { LoadingSpinner } from '@/components'; +import type { Song } from '@/types'; + +type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'error'; + +interface HashLookupState { + status: HashLookupStatus; + fileKey?: string; + hash?: string; + chart?: Song; + message?: string; +} + +interface HashStatusResponse { + hash?: string; + Hash?: string; + exists?: boolean; + Exists?: boolean; + hasHistoricalScores?: boolean; + HasHistoricalScores?: boolean; + willInheritScores?: boolean; + WillInheritScores?: boolean; + chart?: Song | null; + Chart?: Song | null; +} + +function getFileKey(file: File) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function addUnique(items: T[], item: T) { + if (!items.includes(item)) { + items.push(item); + } +} + +function normalizeLines(text: string) { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function encodeUtf8(text: string) { + return new TextEncoder().encode(text); +} + +function hashCandidatesFromBytes(bytes: Uint8Array) { + const candidates: string[] = []; + const add = (value: Uint8Array | ArrayBuffer | number[]) => { + addUnique(candidates, md5.base64(value)); + }; + + add(bytes); + + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + add(bytes.slice(3)); + } + + const decoder = new TextDecoder('utf-8', { fatal: false }); + const rawText = decoder.decode(bytes); + const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText; + const normalized = normalizeLines(text); + + add(encodeUtf8(normalized)); + add(encodeUtf8(normalized.split('\n').filter((line) => line.trim() !== '').join('\n'))); + + const nonBlank = normalized.split('\n').filter((line) => line.trim() !== ''); + const firstLevelIndex = nonBlank.findIndex((line) => line.trimStart().toLowerCase().startsWith('&lv_')); + if (firstLevelIndex !== -1) { + const normalizedLevelGap = [ + ...nonBlank.slice(0, firstLevelIndex), + '', + ...nonBlank.slice(firstLevelIndex), + ].join('\n'); + add(encodeUtf8(normalizedLevelGap)); + } + + add(encodeUtf8(rawText.replace(/\n/g, '\r\n'))); + + return candidates; +} export default function ChartUploader() { const loc = useLoc(); const [isUploading, setIsUploading] = useState(false); + const [hashLookup, setHashLookup] = useState({ status: 'idle' }); + const hashLookupSeq = useRef(0); + const hashLookupAbort = useRef(null); + + async function lookupMaidataHash(file: File) { + const fileKey = getFileKey(file); + const seq = hashLookupSeq.current + 1; + hashLookupSeq.current = seq; + hashLookupAbort.current?.abort(); + const abortController = new AbortController(); + hashLookupAbort.current = abortController; + + setHashLookup({ status: 'checking', fileKey }); + + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const hashes = hashCandidatesFromBytes(bytes); + let inheritedState: HashLookupState | null = null; + + for (const hash of hashes) { + const response = await axios.get(endpoints.maichart.hashStatus(hash), { + withCredentials: true, + signal: abortController.signal, + }); + const exists = response.data.exists ?? response.data.Exists; + const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores; + const willInheritScores = response.data.willInheritScores ?? response.data.WillInheritScores; + const chart = response.data.chart ?? response.data.Chart ?? undefined; + const result: Pick | null = exists + ? { status: 'exists', chart } + : willInheritScores || hasHistoricalScores + ? { status: 'inheritsScores' } + : null; + + if (result) { + const nextState: HashLookupState = { + status: result.status, + fileKey, + hash, + chart: result.chart, + }; + if (nextState.status === 'inheritsScores') { + inheritedState ??= nextState; + continue; + } + + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } + } + + if (inheritedState) { + if (hashLookupSeq.current === seq) { + setHashLookup(inheritedState); + } + return inheritedState; + } + + const nextState: HashLookupState = { status: 'notFound', fileKey }; + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } catch (e: unknown) { + if (axios.isCancel(e) || (e instanceof AxiosError && e.code === 'ERR_CANCELED')) { + return { status: 'checking', fileKey } satisfies HashLookupState; + } + + const error = e as { response?: { data?: unknown }; message?: string }; + const nextState: HashLookupState = { + status: 'error', + fileKey, + message: getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')), + }; + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } + } + + function resetHashLookup() { + hashLookupSeq.current += 1; + hashLookupAbort.current?.abort(); + setHashLookup({ status: 'idle' }); + } + + function onFileChange(index: number, event: React.ChangeEvent) { + if (index !== 0) { + return; + } + + const file = event.currentTarget.files?.[0]; + if (!file) { + resetHashLookup(); + return; + } + + void lookupMaidataHash(file); + } async function onSubmit(event: React.FormEvent) { event.preventDefault(); @@ -40,6 +221,11 @@ export default function ChartUploader() { return; } + if (hashLookup.status === 'exists') { + toast.error(loc('MaidataAlreadyExists', 'This chart already exists on the site'), { autoClose: false }); + return; + } + const uploading = toast.loading(loc('Uploading'), { hideProgressBar: false, }); @@ -82,6 +268,25 @@ export default function ChartUploader() { { label: 'track.mp3', icon: }, { label: loc('BGVideoHint'), icon: }, ]; + const hashLookupStatusText: Record = { + idle: '', + checking: loc('MaidataHashChecking', 'Checking whether this chart already exists...'), + notFound: loc('MaidataHashNotFound', 'No existing chart found'), + exists: hashLookup.chart + ? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}` + : loc('MaidataAlreadyExists', 'This chart already exists on the site'), + inheritsScores: loc('MaidataWillInheritScores', 'This upload will inherit previous scores'), + error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'), + }; + const hashLookupStatusClass: Record = { + idle: '', + checking: 'text-blue-300', + notFound: 'text-emerald-300', + exists: 'text-red-300', + inheritsScores: 'text-amber-300', + error: 'text-red-300', + }; + const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists'; return ( onFileChange(index, event)} /> ))} + {hashLookup.status !== 'idle' && ( +
+ {hashLookup.status === 'checking' && } + {hashLookupStatusText[hashLookup.status]} + {hashLookup.status === 'exists' && hashLookup.chart && ( + + {loc('View', 'View')} + + )} +
+ )} + {isUploading ? ( <> diff --git a/src/config/api.ts b/src/config/api.ts index c0f9a59..72b05b8 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -42,6 +42,7 @@ export const endpoints = { listSearch: (searchKeyword: string) => `${apiroot3}/maichart/list?sort=&search=${encodeURIComponent(searchKeyword)}`, listRanking: (sortType: string) => `${apiroot3}/maichart/list?&isRanking=true&sort=${encodeURIComponent(sortType)}`, upload: `${apiroot3}/maichart/upload`, + hashStatus: (hash: string) => `${apiroot3}/maichart/hash-status?hash=${encodeURIComponent(hash)}`, delete: (chartId: string) => `${apiroot3}/maichart/delete?chartId=${chartId}`, summary: (id: string | number) => `${apiroot3}/maichart/${id}/summary`, image: (id: string | number) => `${apiroot3}/maichart/${id}/image`, From bea5fc04e7426937166652207b885c58ad4c0fa9 Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Wed, 24 Jun 2026 15:43:22 +0800 Subject: [PATCH 2/7] feat: login / interact --- public/i18n/en.json | 2 ++ public/i18n/ja.json | 2 ++ public/i18n/ko.json | 2 ++ public/i18n/zh.json | 2 ++ src/components/user/ChartUploader.tsx | 38 ++++++++++++++++++++++----- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index cdc1300..28887fd 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -147,6 +147,8 @@ "MaidataAlreadyExists": "This chart already exists on the site", "MaidataAlreadyExistsWithTitle": "This chart already exists on the site", "MaidataWillInheritScores": "This upload will inherit previous scores", + "MaidataWillInheritHistory": "This upload will inherit previous scores or interactions", + "MaidataHashLoginRequired": "Please log in before checking maidata.txt hash", "MaidataHashLookupFailed": "Failed to query maidata.txt hash", "View": "View", "ManageYourCharts": "Manage your uploaded charts", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index 548f1fd..a06a36d 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -147,6 +147,8 @@ "MaidataAlreadyExists": "この譜面はすでにサイト上に存在します", "MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します", "MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます", + "MaidataWillInheritHistory": "今回のアップロードは以前のスコアまたはインタラクションを引き継ぎます", + "MaidataHashLoginRequired": "maidata.txt のハッシュを確認する前にログインしてください", "MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました", "View": "表示", "ManageYourCharts": "アップロードした譜面を管理", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 846de6e..0867a13 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -147,6 +147,8 @@ "MaidataAlreadyExists": "이 채보는 이미 사이트에 존재합니다", "MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다", "MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다", + "MaidataWillInheritHistory": "이번 업로드는 이전 점수 또는 상호작용을 이어받습니다", + "MaidataHashLoginRequired": "maidata.txt 해시를 확인하기 전에 로그인하세요", "MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다", "View": "보기", "ManageYourCharts": "내가 올린 채보를 관리하세요", diff --git a/public/i18n/zh.json b/public/i18n/zh.json index 1f3f60b..5cac775 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -149,6 +149,8 @@ "MaidataAlreadyExists": "这个谱面已经存在于网站上", "MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上", "MaidataWillInheritScores": "这次上传会继承之前的分数", + "MaidataWillInheritHistory": "这次上传会继承之前的分数或互动", + "MaidataHashLoginRequired": "请先登录再查询 maidata.txt 哈希", "MaidataHashLookupFailed": "查询 maidata.txt 哈希失败", "View": "查看", "ManageYourCharts": "管理您上传的谱面", diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index 1b0ee03..11366d2 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -3,14 +3,14 @@ import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; import { md5 } from 'js-md5'; import { endpoints } from '@/config/api'; -import { useLoc } from '@/hooks'; +import { useLoc, useUserContext } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; import { motion } from 'framer-motion'; import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md'; import { LoadingSpinner } from '@/components'; import type { Song } from '@/types'; -type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'error'; +type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'loginRequired' | 'error'; interface HashLookupState { status: HashLookupStatus; @@ -27,8 +27,12 @@ interface HashStatusResponse { Exists?: boolean; hasHistoricalScores?: boolean; HasHistoricalScores?: boolean; + hasHistoricalInteract?: boolean; + HasHistoricalInteract?: boolean; willInheritScores?: boolean; WillInheritScores?: boolean; + willInheritInteract?: boolean; + WillInheritInteract?: boolean; chart?: Song | null; Chart?: Song | null; } @@ -89,6 +93,7 @@ function hashCandidatesFromBytes(bytes: Uint8Array) { export default function ChartUploader() { const loc = useLoc(); + const { user, isLoading: isUserLoading } = useUserContext(); const [isUploading, setIsUploading] = useState(false); const [hashLookup, setHashLookup] = useState({ status: 'idle' }); const hashLookupSeq = useRef(0); @@ -105,6 +110,18 @@ export default function ChartUploader() { setHashLookup({ status: 'checking', fileKey }); try { + if (!user) { + const nextState: HashLookupState = { + status: 'loginRequired', + fileKey, + message: isUserLoading + ? loc('MaidataHashChecking', 'Checking whether this chart already exists...') + : loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash'), + }; + setHashLookup(nextState); + return nextState; + } + const bytes = new Uint8Array(await file.arrayBuffer()); const hashes = hashCandidatesFromBytes(bytes); let inheritedState: HashLookupState | null = null; @@ -116,11 +133,13 @@ export default function ChartUploader() { }); const exists = response.data.exists ?? response.data.Exists; const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores; + const hasHistoricalInteract = response.data.hasHistoricalInteract ?? response.data.HasHistoricalInteract; const willInheritScores = response.data.willInheritScores ?? response.data.WillInheritScores; + const willInheritInteract = response.data.willInheritInteract ?? response.data.WillInheritInteract; const chart = response.data.chart ?? response.data.Chart ?? undefined; const result: Pick | null = exists ? { status: 'exists', chart } - : willInheritScores || hasHistoricalScores + : willInheritScores || hasHistoricalScores || willInheritInteract || hasHistoricalInteract ? { status: 'inheritsScores' } : null; @@ -161,10 +180,13 @@ export default function ChartUploader() { } const error = e as { response?: { data?: unknown }; message?: string }; + const isUnauthorized = e instanceof AxiosError && e.response?.status === 401; const nextState: HashLookupState = { - status: 'error', + status: isUnauthorized ? 'loginRequired' : 'error', fileKey, - message: getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')), + message: isUnauthorized + ? loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash') + : getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')), }; if (hashLookupSeq.current === seq) { setHashLookup(nextState); @@ -275,7 +297,8 @@ export default function ChartUploader() { exists: hashLookup.chart ? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}` : loc('MaidataAlreadyExists', 'This chart already exists on the site'), - inheritsScores: loc('MaidataWillInheritScores', 'This upload will inherit previous scores'), + inheritsScores: loc('MaidataWillInheritHistory', 'This upload will inherit previous scores or interactions'), + loginRequired: hashLookup.message || loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash'), error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'), }; const hashLookupStatusClass: Record = { @@ -284,9 +307,10 @@ export default function ChartUploader() { notFound: 'text-emerald-300', exists: 'text-red-300', inheritsScores: 'text-amber-300', + loginRequired: 'text-red-300', error: 'text-red-300', }; - const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists'; + const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists' || hashLookup.status === 'loginRequired'; return ( Date: Thu, 25 Jun 2026 15:11:15 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E7=AE=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/user/ChartUploader.tsx | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index 11366d2..4f7dde1 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -10,7 +10,7 @@ import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVide import { LoadingSpinner } from '@/components'; import type { Song } from '@/types'; -type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'loginRequired' | 'error'; +type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsHistory' | 'loginRequired' | 'error'; interface HashLookupState { status: HashLookupStatus; @@ -29,10 +29,6 @@ interface HashStatusResponse { HasHistoricalScores?: boolean; hasHistoricalInteract?: boolean; HasHistoricalInteract?: boolean; - willInheritScores?: boolean; - WillInheritScores?: boolean; - willInheritInteract?: boolean; - WillInheritInteract?: boolean; chart?: Song | null; Chart?: Song | null; } @@ -134,13 +130,11 @@ export default function ChartUploader() { const exists = response.data.exists ?? response.data.Exists; const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores; const hasHistoricalInteract = response.data.hasHistoricalInteract ?? response.data.HasHistoricalInteract; - const willInheritScores = response.data.willInheritScores ?? response.data.WillInheritScores; - const willInheritInteract = response.data.willInheritInteract ?? response.data.WillInheritInteract; const chart = response.data.chart ?? response.data.Chart ?? undefined; const result: Pick | null = exists ? { status: 'exists', chart } - : willInheritScores || hasHistoricalScores || willInheritInteract || hasHistoricalInteract - ? { status: 'inheritsScores' } + : hasHistoricalScores || hasHistoricalInteract + ? { status: 'inheritsHistory' } : null; if (result) { @@ -150,7 +144,7 @@ export default function ChartUploader() { hash, chart: result.chart, }; - if (nextState.status === 'inheritsScores') { + if (nextState.status === 'inheritsHistory') { inheritedState ??= nextState; continue; } @@ -297,7 +291,7 @@ export default function ChartUploader() { exists: hashLookup.chart ? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}` : loc('MaidataAlreadyExists', 'This chart already exists on the site'), - inheritsScores: loc('MaidataWillInheritHistory', 'This upload will inherit previous scores or interactions'), + inheritsHistory: loc('MaidataWillInheritHistory', 'This upload will inherit previous scores or interactions'), loginRequired: hashLookup.message || loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash'), error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'), }; @@ -306,7 +300,7 @@ export default function ChartUploader() { checking: 'text-blue-300', notFound: 'text-emerald-300', exists: 'text-red-300', - inheritsScores: 'text-amber-300', + inheritsHistory: 'text-amber-300', loginRequired: 'text-red-300', error: 'text-red-300', }; From c0b035eb81895c202081c517c69961299d4e2e78 Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Thu, 25 Jun 2026 15:13:23 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E8=B0=83=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/i18n/en.json | 2 +- public/i18n/ja.json | 2 +- public/i18n/ko.json | 2 +- public/i18n/zh.json | 2 +- src/components/user/ChartUploader.tsx | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index 28887fd..68fd1c4 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -148,7 +148,6 @@ "MaidataAlreadyExistsWithTitle": "This chart already exists on the site", "MaidataWillInheritScores": "This upload will inherit previous scores", "MaidataWillInheritHistory": "This upload will inherit previous scores or interactions", - "MaidataHashLoginRequired": "Please log in before checking maidata.txt hash", "MaidataHashLookupFailed": "Failed to query maidata.txt hash", "View": "View", "ManageYourCharts": "Manage your uploaded charts", @@ -165,6 +164,7 @@ "DislikeAction": "Dislike", "CancelSuccess": "Cancelled successfully", "Success": " successful", + "NotLoggedIn": "Not logged in", "FailedLoginPrompt": " failed: Are you logged in?", "PleaseWait": "Please wait", "Sending": "Sending...", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index a06a36d..0608436 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -148,7 +148,6 @@ "MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します", "MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます", "MaidataWillInheritHistory": "今回のアップロードは以前のスコアまたはインタラクションを引き継ぎます", - "MaidataHashLoginRequired": "maidata.txt のハッシュを確認する前にログインしてください", "MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました", "View": "表示", "ManageYourCharts": "アップロードした譜面を管理", @@ -165,6 +164,7 @@ "DislikeAction": "よくない", "CancelSuccess": "キャンセルしました", "Success": "成功", + "NotLoggedIn": "未ログイン", "FailedLoginPrompt": "失敗:ログインしていますか?", "PleaseWait": "お待ちください", "Sending": "送信中...", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 0867a13..2fa827b 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -148,7 +148,6 @@ "MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다", "MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다", "MaidataWillInheritHistory": "이번 업로드는 이전 점수 또는 상호작용을 이어받습니다", - "MaidataHashLoginRequired": "maidata.txt 해시를 확인하기 전에 로그인하세요", "MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다", "View": "보기", "ManageYourCharts": "내가 올린 채보를 관리하세요", @@ -165,6 +164,7 @@ "DislikeAction": "좋아요 취소", "CancelSuccess": "취소되었습니다", "Success": " 성공했습니다", + "NotLoggedIn": "로그인하지 않음", "FailedLoginPrompt": " 실패했습니다: 로그인했나요?", "PleaseWait": "잠시만 기다려 주세요", "Sending": "전송 중...", diff --git a/public/i18n/zh.json b/public/i18n/zh.json index 5cac775..48d5e00 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -150,7 +150,6 @@ "MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上", "MaidataWillInheritScores": "这次上传会继承之前的分数", "MaidataWillInheritHistory": "这次上传会继承之前的分数或互动", - "MaidataHashLoginRequired": "请先登录再查询 maidata.txt 哈希", "MaidataHashLookupFailed": "查询 maidata.txt 哈希失败", "View": "查看", "ManageYourCharts": "管理您上传的谱面", @@ -167,6 +166,7 @@ "DislikeAction": "点踩", "CancelSuccess": "取消成功", "Success": "成功", + "NotLoggedIn": "未登录", "FailedLoginPrompt": "失败:登录了吗?", "PleaseWait": "请稍候", "Sending": "正在发送...", diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index 4f7dde1..eb42979 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -112,7 +112,7 @@ export default function ChartUploader() { fileKey, message: isUserLoading ? loc('MaidataHashChecking', 'Checking whether this chart already exists...') - : loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash'), + : loc('NotLoggedIn', 'Not logged in'), }; setHashLookup(nextState); return nextState; @@ -179,7 +179,7 @@ export default function ChartUploader() { status: isUnauthorized ? 'loginRequired' : 'error', fileKey, message: isUnauthorized - ? loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash') + ? loc('NotLoggedIn', 'Not logged in') : getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')), }; if (hashLookupSeq.current === seq) { @@ -292,7 +292,7 @@ export default function ChartUploader() { ? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}` : loc('MaidataAlreadyExists', 'This chart already exists on the site'), inheritsHistory: loc('MaidataWillInheritHistory', 'This upload will inherit previous scores or interactions'), - loginRequired: hashLookup.message || loc('MaidataHashLoginRequired', 'Please log in before checking maidata hash'), + loginRequired: hashLookup.message || loc('NotLoggedIn', 'Not logged in'), error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'), }; const hashLookupStatusClass: Record = { From 85cc4f99b93de2b1e7e1cb4bfe4ae605fd3e0910 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9C=A7=E9=9B=A8=E3=83=90=E3=83=8B=E3=83=A9=E3=83=BC?= Date: Sat, 27 Jun 2026 14:34:56 +0800 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/components/user/ChartUploader.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index eb42979..aceac9c 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -350,13 +350,12 @@ export default function ChartUploader() { {hashLookup.status === 'checking' && } {hashLookupStatusText[hashLookup.status]} {hashLookup.status === 'exists' && hashLookup.chart && ( - {loc('View', 'View')} - - )} + )} From a985f29d9b4c85551c70d91f72ae12dc2483e1de Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Sat, 27 Jun 2026 22:22:19 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix=E5=86=97=E4=BD=99=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/user/ChartUploader.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index aceac9c..ecf39bc 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -2,6 +2,7 @@ import { useRef, useState } from 'react'; import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; import { md5 } from 'js-md5'; +import { Link } from 'react-router-dom'; import { endpoints } from '@/config/api'; import { useLoc, useUserContext } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; @@ -21,16 +22,10 @@ interface HashLookupState { } interface HashStatusResponse { - hash?: string; - Hash?: string; exists?: boolean; - Exists?: boolean; hasHistoricalScores?: boolean; - HasHistoricalScores?: boolean; hasHistoricalInteract?: boolean; - HasHistoricalInteract?: boolean; chart?: Song | null; - Chart?: Song | null; } function getFileKey(file: File) { @@ -127,10 +122,10 @@ export default function ChartUploader() { withCredentials: true, signal: abortController.signal, }); - const exists = response.data.exists ?? response.data.Exists; - const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores; - const hasHistoricalInteract = response.data.hasHistoricalInteract ?? response.data.HasHistoricalInteract; - const chart = response.data.chart ?? response.data.Chart ?? undefined; + const exists = response.data.exists; + const hasHistoricalScores = response.data.hasHistoricalScores; + const hasHistoricalInteract = response.data.hasHistoricalInteract; + const chart = response.data.chart ?? undefined; const result: Pick | null = exists ? { status: 'exists', chart } : hasHistoricalScores || hasHistoricalInteract @@ -356,6 +351,7 @@ export default function ChartUploader() { > {loc('View', 'View')} + )} )} From c23089ac96df8b92f80d5cd9e589ea647cda144a Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Sat, 27 Jun 2026 23:01:18 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E6=8B=86=E5=88=86utils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/user/ChartUploader.tsx | 56 +---------- src/utils/index.ts | 128 +++++++++++++------------- src/utils/maidataHash.ts | 55 +++++++++++ 3 files changed, 120 insertions(+), 119 deletions(-) create mode 100644 src/utils/maidataHash.ts diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index ecf39bc..f742f88 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -1,11 +1,11 @@ import { useRef, useState } from 'react'; import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; -import { md5 } from 'js-md5'; import { Link } from 'react-router-dom'; import { endpoints } from '@/config/api'; import { useLoc, useUserContext } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; +import { getFileKey, hashCandidatesFromBytes } from '@/utils/maidataHash'; import { motion } from 'framer-motion'; import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md'; import { LoadingSpinner } from '@/components'; @@ -28,60 +28,6 @@ interface HashStatusResponse { chart?: Song | null; } -function getFileKey(file: File) { - return `${file.name}:${file.size}:${file.lastModified}`; -} - -function addUnique(items: T[], item: T) { - if (!items.includes(item)) { - items.push(item); - } -} - -function normalizeLines(text: string) { - return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); -} - -function encodeUtf8(text: string) { - return new TextEncoder().encode(text); -} - -function hashCandidatesFromBytes(bytes: Uint8Array) { - const candidates: string[] = []; - const add = (value: Uint8Array | ArrayBuffer | number[]) => { - addUnique(candidates, md5.base64(value)); - }; - - add(bytes); - - if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { - add(bytes.slice(3)); - } - - const decoder = new TextDecoder('utf-8', { fatal: false }); - const rawText = decoder.decode(bytes); - const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText; - const normalized = normalizeLines(text); - - add(encodeUtf8(normalized)); - add(encodeUtf8(normalized.split('\n').filter((line) => line.trim() !== '').join('\n'))); - - const nonBlank = normalized.split('\n').filter((line) => line.trim() !== ''); - const firstLevelIndex = nonBlank.findIndex((line) => line.trimStart().toLowerCase().startsWith('&lv_')); - if (firstLevelIndex !== -1) { - const normalizedLevelGap = [ - ...nonBlank.slice(0, firstLevelIndex), - '', - ...nonBlank.slice(firstLevelIndex), - ].join('\n'); - add(encodeUtf8(normalizedLevelGap)); - } - - add(encodeUtf8(rawText.replace(/\n/g, '\r\n'))); - - return candidates; -} - export default function ChartUploader() { const loc = useLoc(); const { user, isLoading: isUserLoading } = useUserContext(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 76e6d2d..8f91e4f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,64 +1,64 @@ -/** - * 工具函数统一导出 - */ - -// 基础工具 -export { default as sleep } from './sleep'; - -// 难度等级工具 -export { getLevelName, formatLevel } from './levelUtils'; -export { renderLevel } from './renderLevel'; - -// Combo工具 -export { getComboState } from './comboUtils'; - -// 认证工具 -export { handleLogout } from './authUtils'; - -// 滚动工具 -export { scrollToTop, makeLevelClickCallback } from './scrollUtils'; - -// 下载工具 -export { downloadSong } from './download'; - -// HTTP 消息工具 -export { getDisplayMessage } from './httpMessage'; - -// 国际化工具 -export { - setLanguage, - getTranslatedString, - loc, - getCurrentLanguage, - getBrowserLanguage, - initializeLanguage, - preloadLanguage, -} from './i18n'; - -// TMP 富文本工具 -export { hasTmpTags, stripTmpTags, parseTmpRichText, TmpRichText } from './richTextUtils'; - -// 活动数据工具 -export { - getAllEvents, - getEventsCount, - getNonFeaturedEventsCount, - getEventById, - getTimeAgo, - getEventsWithTimeAgo, - isEventUpcoming, - isEventOngoing, - getOngoingEvents, - getActiveEvents, - getUpcomingEvents, - getEndedEvents, - getCarouselEvents, - getNextCarouselEvents, - resetCarousel, - shouldEnableCarousel, - getRandomOngoingEvents, - getEventStatusText, - getEventStatusClass, - getCategoryTranslation, - getEventBySearchKeyword, -} from './eventsData'; +/** + * 工具函数统一导出 + */ + +// 基础工具 +export { default as sleep } from './sleep'; + +// 难度等级工具 +export { getLevelName, formatLevel } from './levelUtils'; +export { renderLevel } from './renderLevel'; + +// Combo工具 +export { getComboState } from './comboUtils'; + +// 认证工具 +export { handleLogout } from './authUtils'; + +// 滚动工具 +export { scrollToTop, makeLevelClickCallback } from './scrollUtils'; + +// 下载工具 +export { downloadSong } from './download'; + +// HTTP 消息工具 +export { getDisplayMessage } from './httpMessage'; + +// 国际化工具 +export { + setLanguage, + getTranslatedString, + loc, + getCurrentLanguage, + getBrowserLanguage, + initializeLanguage, + preloadLanguage, +} from './i18n'; + +// TMP 富文本工具 +export { hasTmpTags, stripTmpTags, parseTmpRichText, TmpRichText } from './richTextUtils'; + +// 活动数据工具 +export { + getAllEvents, + getEventsCount, + getNonFeaturedEventsCount, + getEventById, + getTimeAgo, + getEventsWithTimeAgo, + isEventUpcoming, + isEventOngoing, + getOngoingEvents, + getActiveEvents, + getUpcomingEvents, + getEndedEvents, + getCarouselEvents, + getNextCarouselEvents, + resetCarousel, + shouldEnableCarousel, + getRandomOngoingEvents, + getEventStatusText, + getEventStatusClass, + getCategoryTranslation, + getEventBySearchKeyword, +} from './eventsData'; diff --git a/src/utils/maidataHash.ts b/src/utils/maidataHash.ts new file mode 100644 index 0000000..02ca03f --- /dev/null +++ b/src/utils/maidataHash.ts @@ -0,0 +1,55 @@ +import { md5 } from 'js-md5'; + +export function getFileKey(file: File) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function addUnique(items: T[], item: T) { + if (!items.includes(item)) { + items.push(item); + } +} + +function normalizeLines(text: string) { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function encodeUtf8(text: string) { + return new TextEncoder().encode(text); +} + +export function hashCandidatesFromBytes(bytes: Uint8Array) { + const candidates: string[] = []; + const add = (value: Uint8Array | ArrayBuffer | number[]) => { + addUnique(candidates, md5.base64(value)); + }; + + add(bytes); + + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + add(bytes.slice(3)); + } + + const decoder = new TextDecoder('utf-8', { fatal: false }); + const rawText = decoder.decode(bytes); + const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText; + const normalized = normalizeLines(text); + + add(encodeUtf8(normalized)); + add(encodeUtf8(normalized.split('\n').filter((line) => line.trim() !== '').join('\n'))); + + const nonBlank = normalized.split('\n').filter((line) => line.trim() !== ''); + const firstLevelIndex = nonBlank.findIndex((line) => line.trimStart().toLowerCase().startsWith('&lv_')); + if (firstLevelIndex !== -1) { + const normalizedLevelGap = [ + ...nonBlank.slice(0, firstLevelIndex), + '', + ...nonBlank.slice(firstLevelIndex), + ].join('\n'); + add(encodeUtf8(normalizedLevelGap)); + } + + add(encodeUtf8(rawText.replace(/\n/g, '\r\n'))); + + return candidates; +}