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:
@@ -1470,12 +1470,12 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
setEditRest({
|
||||
name: r.name,
|
||||
cuisine_type: r.cuisine_type || "",
|
||||
foods_mentioned: r.foods_mentioned.join(", "),
|
||||
foods_mentioned: (r.foods_mentioned || []).join(", "),
|
||||
evaluation: evalText,
|
||||
address: r.address || "",
|
||||
region: r.region || "",
|
||||
price_range: r.price_range || "",
|
||||
guests: r.guests.join(", "),
|
||||
guests: (r.guests || []).join(", "),
|
||||
});
|
||||
} : undefined}
|
||||
title={isAdmin ? "클릭하여 수정" : undefined}
|
||||
@@ -1513,7 +1513,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.cuisine_type && <p>종류: {r.cuisine_type}</p>}
|
||||
{r.price_range && <p>가격대: {r.price_range}</p>}
|
||||
</div>
|
||||
{r.foods_mentioned.length > 0 && (
|
||||
{r.foods_mentioned?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{r.foods_mentioned.map((f, j) => (
|
||||
<span key={j} className="px-1.5 py-0.5 bg-brand-50 text-brand-700 rounded text-xs">{f}</span>
|
||||
@@ -1523,7 +1523,7 @@ function VideosPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.evaluation?.text && (
|
||||
<p className="mt-1 text-xs text-gray-600">{r.evaluation.text}</p>
|
||||
)}
|
||||
{r.guests.length > 0 && (
|
||||
{r.guests?.length > 0 && (
|
||||
<p className="mt-1 text-xs text-gray-500">게스트: {r.guests.join(", ")}</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1627,6 +1627,45 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// #322/#323 LLM 검증 UI
|
||||
const [verifyPending, setVerifyPending] = useState<number | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<string | null>(null);
|
||||
|
||||
const loadVerifyPending = useCallback(() => {
|
||||
api.getVerifyPending().then((r) => setVerifyPending(r.pending)).catch(() => setVerifyPending(null));
|
||||
}, []);
|
||||
useEffect(() => { loadVerifyPending(); }, [loadVerifyPending]);
|
||||
|
||||
const handleVerifyAll = async () => {
|
||||
if (!isAdmin) return;
|
||||
if (!confirm(`미검증 식당 ${verifyPending ?? "?"}건을 LLM으로 일괄 검증합니다.\n잘못 등록된 데이터/프랜차이즈를 자동으로 숨김 처리합니다.\n진행할까요?`)) return;
|
||||
setVerifying(true);
|
||||
setVerifyResult(null);
|
||||
try {
|
||||
const r = await api.verifyAll(10);
|
||||
setVerifyResult(`${r.processed}건 검증 완료`);
|
||||
loadVerifyPending();
|
||||
load();
|
||||
} catch (e) {
|
||||
setVerifyResult(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleHidden = async (r: Restaurant) => {
|
||||
if (!isAdmin) return;
|
||||
const becomingHidden = !r.hidden;
|
||||
const reason = becomingHidden ? (prompt("숨김 사유(선택)", "manual") ?? "manual") : "";
|
||||
try {
|
||||
await api.setRestaurantHidden(r.id, becomingHidden, reason || "manual");
|
||||
load();
|
||||
} catch (e) {
|
||||
alert(`실패: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = restaurants.filter((r) => {
|
||||
if (nameSearch && !r.name.toLowerCase().includes(nameSearch.toLowerCase())) return false;
|
||||
return true;
|
||||
@@ -1728,6 +1767,18 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (<>
|
||||
{/* #322/#323 — LLM 검증 */}
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>미검증 {verifyPending ?? "?"}건</span>
|
||||
<button
|
||||
onClick={handleVerifyAll}
|
||||
disabled={verifying || verifyPending === 0}
|
||||
className="px-3 py-1 text-xs rounded bg-amber-500 hover:bg-amber-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{verifying ? "검증 중..." : "LLM 검증"}
|
||||
</button>
|
||||
{verifyResult && <span className="text-amber-600">{verifyResult}</span>}
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const pending = await fetch(`/api/restaurants/tabling-pending`, {
|
||||
@@ -1890,6 +1941,7 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<th className="text-left px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("price_range")}>가격대{sortIcon("price_range")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("rating")}>평점{sortIcon("rating")}</th>
|
||||
<th className="text-center px-4 py-3 cursor-pointer select-none hover:bg-gray-100" onClick={() => handleSort("business_status")}>상태{sortIcon("business_status")}</th>
|
||||
<th className="text-center px-4 py-3">검증</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1917,11 +1969,34 @@ function RestaurantsPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{r.hidden ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title={r.hidden_reason || "manual"}
|
||||
className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px] font-semibold hover:bg-red-200 disabled:opacity-50"
|
||||
>
|
||||
숨김 {r.hidden_reason ? `(${r.hidden_reason.slice(0, 12)})` : ""}
|
||||
</button>
|
||||
) : r.verified_at ? (
|
||||
<button
|
||||
onClick={() => handleToggleHidden(r)}
|
||||
disabled={!isAdmin}
|
||||
title="검증 통과 — 클릭하면 숨김"
|
||||
className="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px] font-semibold hover:bg-green-200 disabled:opacity-50"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">미검증</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
|
||||
식당 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
@@ -2144,6 +2219,7 @@ interface AdminUser {
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
@@ -2246,6 +2322,7 @@ function UsersPanel() {
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2">사용자</th>
|
||||
<th className="text-left px-4 py-2">이메일</th>
|
||||
<th className="text-center px-4 py-2">관리자</th>
|
||||
<th className="text-center px-4 py-2">찜</th>
|
||||
<th className="text-center px-4 py-2">리뷰</th>
|
||||
<th className="text-center px-4 py-2">메모</th>
|
||||
@@ -2282,6 +2359,27 @@ function UsersPanel() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500">{u.email || "-"}</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.updateAdminUserAdmin(u.id, !u.is_admin);
|
||||
setUsers((prev) => prev.map((x) => x.id === u.id ? { ...x, is_admin: !u.is_admin } : x));
|
||||
} catch (err) {
|
||||
console.error("Failed to update admin:", err);
|
||||
alert("관리자 권한 변경에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium transition-colors ${
|
||||
u.is_admin
|
||||
? "bg-green-100 text-green-700 hover:bg-green-200"
|
||||
: "bg-gray-100 text-gray-400 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{u.is_admin ? "ON" : "OFF"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
{u.favorite_count > 0 ? (
|
||||
<span className="inline-block px-2 py-0.5 bg-red-50 text-red-600 rounded-full text-xs font-medium">
|
||||
|
||||
52
frontend/src/lib/admin-utils.ts
Normal file
52
frontend/src/lib/admin-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ export interface Restaurant {
|
||||
website: string | null;
|
||||
channels?: string[];
|
||||
foods_mentioned?: string[];
|
||||
// #322 LLM 검증
|
||||
hidden?: boolean;
|
||||
hidden_reason?: string | null;
|
||||
verified_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoLink {
|
||||
@@ -310,6 +314,7 @@ export const api = {
|
||||
email: string | null;
|
||||
nickname: string | null;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
provider: string | null;
|
||||
created_at: string | null;
|
||||
favorite_count: number;
|
||||
@@ -320,6 +325,14 @@ export const api = {
|
||||
}>(`/api/admin/users${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
updateAdminUserAdmin(userId: string, admin: boolean) {
|
||||
return fetchApi<{ success: boolean }>(`/api/admin/users/${userId}/admin`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ admin }),
|
||||
});
|
||||
},
|
||||
|
||||
getAdminUserFavorites(userId: string) {
|
||||
return fetchApi<
|
||||
{
|
||||
@@ -567,4 +580,30 @@ export const api = {
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
|
||||
// #322 — LLM 검증 어드민 API
|
||||
getVerifyPending() {
|
||||
return fetchApi<{ pending: number }>("/api/admin/restaurants/verify/pending");
|
||||
},
|
||||
verifyAll(batchSize: number = 10) {
|
||||
return fetchApi<{ processed: number }>(
|
||||
`/api/admin/restaurants/verify/all?batchSize=${batchSize}`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
verifyOne(id: string) {
|
||||
return fetchApi<{ success: boolean; id: string }>(
|
||||
`/api/admin/restaurants/${id}/verify`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
},
|
||||
setRestaurantHidden(id: string, hidden: boolean, reason: string = "manual") {
|
||||
return fetchApi<{ success: boolean; id: string; hidden: boolean }>(
|
||||
`/api/admin/restaurants/${id}/hidden`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ hidden, reason }),
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user