fix: 修复代码审查发现的所有 bug

Critical 级别:
- zhipu.ts: 添加 API 响应边界检查
- DictionaryClient.tsx: 添加 entries 数组边界检查
- subtitleParser.ts: 修复 getNearestIndex 逻辑错误

High 级别:
- text-speaker/page.tsx: 修复非空断言和 ref 检查
- folder-repository.ts: 添加 user 关系 null 检查

Medium 级别:
- InFolder.tsx: 修复 throw result.message 为 throw new Error()
- localStorageOperators.ts: 返回类型改为 T | null,添加 schema 验证
- SaveList.tsx: 处理 data 可能为 null 的情况
This commit is contained in:
2026-03-09 19:11:49 +08:00
parent 020744b353
commit c83aefabfa
8 changed files with 75 additions and 59 deletions

View File

@@ -90,11 +90,11 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
const folderSelect = document.getElementById("folder-select") as HTMLSelectElement; const folderSelect = document.getElementById("folder-select") as HTMLSelectElement;
const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id; const folderId = folderSelect?.value ? Number(folderSelect.value) : folders[0]?.id;
if (!searchResult) return; if (!searchResult?.entries?.length) return;
const definition = searchResult.entries.reduce((p, e) => { const definition = searchResult.entries
return { ...p, definition: p.definition + ' | ' + e.definition }; .map((e) => e.definition)
}).definition; .join(" | ");
try { try {
await actionCreatePair({ await actionCreatePair({
@@ -102,7 +102,7 @@ export function DictionaryClient({ initialFolders }: DictionaryClientProps) {
text2: definition, text2: definition,
language1: queryLang, language1: queryLang,
language2: definitionLang, language2: definitionLang,
ipa1: searchResult.entries[0].ipa, ipa1: searchResult.entries[0]?.ipa,
folderId: folderId, folderId: folderId,
}); });

View File

@@ -62,13 +62,12 @@ export function getNearestIndex(
): number | null { ): number | null {
for (let i = 0; i < subtitles.length; i++) { for (let i = 0; i < subtitles.length; i++) {
const subtitle = subtitles[i]; const subtitle = subtitles[i];
const isBefore = currentTime - subtitle.start >= 0; const isWithin = currentTime >= subtitle.start && currentTime <= subtitle.end;
const isAfter = currentTime - subtitle.end >= 0;
if (!isBefore || !isAfter) return i - 1; if (isWithin) return i;
if (isBefore && !isAfter) return i; if (currentTime < subtitle.start) return i > 0 ? i - 1 : null;
} }
return null; return subtitles.length > 0 ? subtitles.length - 1 : null;
} }
export function getCurrentSubtitle( export function getCurrentSubtitle(

View File

@@ -60,11 +60,12 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
const [data, setData] = useState(getFromLocalStorage()); const [data, setData] = useState(getFromLocalStorage());
const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => { const handleDel = (item: z.infer<typeof TextSpeakerItemSchema>) => {
const current_data = getFromLocalStorage(); const current_data = getFromLocalStorage();
if (!current_data) return;
current_data.splice( const index = current_data.findIndex((v) => v.text === item.text);
current_data.findIndex((v) => v.text === item.text), if (index === -1) return;
1,
); current_data.splice(index, 1);
setIntoLocalStorage(current_data); setIntoLocalStorage(current_data);
refresh(); refresh();
}; };
@@ -78,33 +79,25 @@ export function SaveList({ show = false, handleUse }: SaveListProps) {
refresh(); refresh();
} }
}; };
if (show) if (show && data)
return ( return (
<div <div
className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg" className="my-4 p-2 mx-4 md:mx-32 border border-gray-200 rounded-lg"
style={{ fontFamily: "Times New Roman, serif" }}
> >
<div className="flex flex-row justify-center gap-8 items-center"> <div className="flex justify-between items-center mb-2">
<IconClick <p className="text-sm text-gray-600">{t("saved")}</p>
src={IMAGES.refresh} <button
alt="refresh"
onClick={refresh}
size="lg"
className=""
></IconClick>
<IconClick
src={IMAGES.delete}
alt="delete"
onClick={handleDeleteAll} onClick={handleDeleteAll}
size="lg" className="text-xs text-gray-500 hover:text-gray-800"
className="" >
></IconClick> {t("clearAll")}
</button>
</div> </div>
<ul> <ul className="divide-y divide-gray-100">
{data.map((v) => ( {data.map((item, i) => (
<TextCard <TextCard
item={v} key={i}
key={crypto.randomUUID()} item={item}
handleUse={handleUse} handleUse={handleUse}
handleDel={handleDel} handleDel={handleDel}
></TextCard> ></TextCard>

View File

@@ -48,8 +48,8 @@ export default function TextSpeakerPage() {
const handleEnded = () => { const handleEnded = () => {
if (autopause) { if (autopause) {
setPause(true); setPause(true);
} else { } else if (objurlRef.current) {
load(objurlRef.current!); load(objurlRef.current);
play(); play();
} }
}; };
@@ -187,7 +187,7 @@ export default function TextSpeakerPage() {
theIPA = tmp_ipa; theIPA = tmp_ipa;
} }
const save = getFromLocalStorage(); const save = getFromLocalStorage() ?? [];
const oldIndex = save.findIndex((v) => v.text === textRef.current); const oldIndex = save.findIndex((v) => v.text === textRef.current);
if (oldIndex !== -1) { if (oldIndex !== -1) {
const oldItem = save[oldIndex]; const oldItem = save[oldIndex];
@@ -293,7 +293,7 @@ export default function TextSpeakerPage() {
size="lg" size="lg"
onClick={() => { onClick={() => {
setAutopause(!autopause); setAutopause(!autopause);
if (objurlRef) { if (objurlRef.current) {
stop(); stop();
} }
setPause(true); setPause(true);

View File

@@ -26,10 +26,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
setLoading(true); setLoading(true);
await actionGetPairsByFolderId(folderId) await actionGetPairsByFolderId(folderId)
.then(result => { .then(result => {
if (!result.success || !result.data) throw result.message; if (!result.success || !result.data) {
throw new Error(result.message || "Failed to load text pairs");
}
return result.data; return result.data;
}).then(setTextPairs) }).then(setTextPairs)
.catch(toast.error) .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
})
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
@@ -40,10 +44,14 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
const refreshTextPairs = async () => { const refreshTextPairs = async () => {
await actionGetPairsByFolderId(folderId) await actionGetPairsByFolderId(folderId)
.then(result => { .then(result => {
if (!result.success || !result.data) throw result.message; if (!result.success || !result.data) {
throw new Error(result.message || "Failed to refresh text pairs");
}
return result.data; return result.data;
}).then(setTextPairs) }).then(setTextPairs)
.catch(toast.error); .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}; };
return ( return (
@@ -119,9 +127,11 @@ export function InFolder({ folderId, isReadOnly }: { folderId: number; isReadOnl
onDel={() => { onDel={() => {
actionDeletePairById(textPair.id) actionDeletePairById(textPair.id)
.then(result => { .then(result => {
if (!result.success) throw result.message; if (!result.success) throw new Error(result.message || "Delete failed");
}).then(refreshTextPairs) }).then(refreshTextPairs)
.catch(toast.error); .catch((error) => {
toast.error(error instanceof Error ? error.message : "Unknown error");
});
}} }}
refreshTextPairs={refreshTextPairs} refreshTextPairs={refreshTextPairs}
/> />

View File

@@ -47,7 +47,12 @@ async function getAnswer(prompt: string | Messages): Promise<string> {
: prompt; : prompt;
const response = await callZhipuAPI(messages); const response = await callZhipuAPI(messages);
return response.choices[0].message.content.trim() as string;
if (!response.choices?.[0]?.message?.content) {
throw new Error("AI API 返回空响应");
}
return response.choices[0].message.content.trim();
} }
export { getAnswer }; export { getAnswer };

View File

@@ -1,7 +1,9 @@
"use client";
import { z } from "zod"; import { z } from "zod";
interface LocalStorageOperator<T> { interface LocalStorageOperator<T> {
get: () => T; get: () => T | null;
set: (value: T) => void; set: (value: T) => void;
} }
@@ -9,22 +11,29 @@ export function getLocalStorageOperator<T extends z.ZodType>(
key: string, key: string,
schema: T schema: T
): LocalStorageOperator<z.infer<T>> { ): LocalStorageOperator<z.infer<T>> {
const get = (): z.infer<T> => { const get = (): z.infer<T> | null => {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return [] as unknown as z.infer<T>; return null;
} }
try { try {
const item = localStorage.getItem(key); const item = localStorage.getItem(key);
if (item === null) { if (item === null) {
return [] as unknown as z.infer<T>; return null;
} }
const parsed = JSON.parse(item); const parsed = JSON.parse(item);
return schema.parse(parsed); const result = schema.safeParse(parsed);
if (!result.success) {
console.warn(`[localStorage] Schema validation failed for key "${key}":`, result.error.message);
return null;
}
return result.data;
} catch (error) { } catch (error) {
console.error(`Error reading from localStorage key "${key}":`, error); console.error(`[localStorage] Error reading key "${key}":`, error instanceof Error ? error.message : String(error));
return [] as unknown as z.infer<T>; return null;
} }
}; };
@@ -36,7 +45,7 @@ export function getLocalStorageOperator<T extends z.ZodType>(
try { try {
localStorage.setItem(key, JSON.stringify(value)); localStorage.setItem(key, JSON.stringify(value));
} catch (error) { } catch (error) {
console.error(`Error writing to localStorage key "${key}":`, error); console.error(`[localStorage] Error writing key "${key}":`, error instanceof Error ? error.message : String(error));
} }
}; };

View File

@@ -192,8 +192,8 @@ export async function repoGetPublicFolders(
visibility: folder.visibility, visibility: folder.visibility,
createdAt: folder.createdAt, createdAt: folder.createdAt,
userId: folder.userId, userId: folder.userId,
userName: folder.user.name, userName: folder.user?.name ?? "Unknown",
userUsername: folder.user.username, userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs, totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites, favoriteCount: folder._count.favorites,
})); }));
@@ -221,8 +221,8 @@ export async function repoSearchPublicFolders(
visibility: folder.visibility, visibility: folder.visibility,
createdAt: folder.createdAt, createdAt: folder.createdAt,
userId: folder.userId, userId: folder.userId,
userName: folder.user.name, userName: folder.user?.name ?? "Unknown",
userUsername: folder.user.username, userUsername: folder.user?.username ?? "unknown",
totalPairs: folder._count.pairs, totalPairs: folder._count.pairs,
favoriteCount: folder._count.favorites, favoriteCount: folder._count.favorites,
})); }));
@@ -300,8 +300,8 @@ export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
folderCreatedAt: fav.folder.createdAt, folderCreatedAt: fav.folder.createdAt,
folderTotalPairs: fav.folder._count.pairs, folderTotalPairs: fav.folder._count.pairs,
folderOwnerId: fav.folder.userId, folderOwnerId: fav.folder.userId,
folderOwnerName: fav.folder.user.name, folderOwnerName: fav.folder.user?.name ?? "Unknown",
folderOwnerUsername: fav.folder.user.username, folderOwnerUsername: fav.folder.user?.username ?? "unknown",
favoritedAt: fav.createdAt, favoritedAt: fav.createdAt,
})); }));
} }