feat(admin): #304+#323 LLM 검증 UI + 공통 유틸 추출

#323 (LLM 검증 어드민 UI):
- api.ts: getVerifyPending / verifyAll / verifyOne / setRestaurantHidden 추가
- Restaurant 타입에 hidden / hidden_reason / verified_at 추가
- RestaurantsPanel 헤더에 "미검증 N건 + LLM 검증" 버튼 추가
- 테이블에 "검증" 컬럼 추가:
  - hidden=true → "숨김 (사유)" 버튼 (클릭 시 해제)
  - verified_at 있고 visible → "OK" 버튼 (클릭 시 숨김)
  - 미검증 → "미검증" 텍스트

#304 (어드민 공통 유틸):
- lib/admin-utils.ts 신규
  - getAdminToken(): localStorage 직접 접근 통일
  - authHeaders(): 표준 Bearer 헤더
  - consumeSseStream(): SSE 라인 파싱 헬퍼
- colSpan 6 → 7로 검증 컬럼 반영

후속 분리: #329 (admin/page.tsx 전체 분리 + localStorage/SSE 호출 11+곳 교체)

Refs: #304 #323 #322
This commit is contained in:
joungmin
2026-06-15 13:57:33 +09:00
parent 04c54d1b1a
commit 6ea82a5561
3 changed files with 194 additions and 5 deletions

View File

@@ -0,0 +1,52 @@
// #304 어드민 페이지 공통 유틸.
// 결함: localStorage 직접 접근 10+곳 / SSE 파싱 코드 6곳 중복.
const TOKEN_KEY = "tasteby_token";
export function getAdminToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function authHeaders(): Record<string, string> {
const token = getAdminToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
/**
* SSE(Server-Sent Events) 스트림을 라인 단위로 파싱하여 onEvent 콜백을 호출.
* 호환 패턴: `data: { ...json... }` 한 줄 = 한 이벤트.
* 비어있는 줄은 무시. JSON 파싱 실패 시 콜백 skip.
*/
export async function consumeSseStream(
response: Response,
onEvent: (event: unknown) => void,
onError?: (err: unknown) => void,
): Promise<void> {
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (!payload) continue;
try {
onEvent(JSON.parse(payload));
} catch {
// 무시: 일부 SSE 줄이 JSON이 아닐 수도 있음
}
}
}
} catch (err) {
onError?.(err);
}
}