feat: 웹사이트 표준화 검사 도구 구현
- 4개 검사 엔진: HTML/CSS, 접근성(WCAG), SEO, 성능/보안 (총 50개 항목) - FastAPI 백엔드 (9개 API, SSE 실시간 진행, PDF/JSON 리포트) - Next.js 15 프론트엔드 (6개 페이지, 29개 컴포넌트, 반원 게이지 차트) - Docker Compose 배포 (Backend:8011, Frontend:3011, MongoDB:27022, Redis:6392) - 전체 테스트 32/32 PASS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
148
frontend/src/lib/api.ts
Normal file
148
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import type {
|
||||
StartInspectionResponse,
|
||||
InspectionResult,
|
||||
IssueListResponse,
|
||||
IssueFilters,
|
||||
PaginatedResponse,
|
||||
HistoryParams,
|
||||
TrendResponse,
|
||||
} from "@/types/inspection";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8011";
|
||||
|
||||
/** API 에러 클래스 */
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(status: number, detail: string) {
|
||||
super(detail);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
/** API 클라이언트 */
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "요청 처리 중 오류가 발생했습니다";
|
||||
try {
|
||||
const error = await response.json();
|
||||
detail = error.detail || detail;
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 기본 메시지 사용
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/** 검사 시작 */
|
||||
async startInspection(url: string): Promise<StartInspectionResponse> {
|
||||
return this.request("/api/inspections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 검사 결과 조회 */
|
||||
async getInspection(id: string): Promise<InspectionResult> {
|
||||
return this.request(`/api/inspections/${id}`);
|
||||
}
|
||||
|
||||
/** 이슈 목록 조회 */
|
||||
async getIssues(
|
||||
id: string,
|
||||
filters?: IssueFilters
|
||||
): Promise<IssueListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.category && filters.category !== "all") {
|
||||
params.set("category", filters.category);
|
||||
}
|
||||
if (filters?.severity && filters.severity !== "all") {
|
||||
params.set("severity", filters.severity);
|
||||
}
|
||||
const qs = params.toString();
|
||||
return this.request(`/api/inspections/${id}/issues${qs ? `?${qs}` : ""}`);
|
||||
}
|
||||
|
||||
/** 이력 목록 조회 (페이지네이션) */
|
||||
async getInspections(params: HistoryParams): Promise<PaginatedResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("page", String(params.page || 1));
|
||||
qs.set("limit", String(params.limit || 20));
|
||||
if (params.url) qs.set("url", params.url);
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
return this.request(`/api/inspections?${qs}`);
|
||||
}
|
||||
|
||||
/** 트렌드 데이터 조회 */
|
||||
async getTrend(url: string, limit = 10): Promise<TrendResponse> {
|
||||
const qs = new URLSearchParams({ url, limit: String(limit) });
|
||||
return this.request(`/api/inspections/trend?${qs}`);
|
||||
}
|
||||
|
||||
/** PDF 다운로드 */
|
||||
async downloadPdf(id: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/inspections/${id}/report/pdf`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, "PDF 다운로드에 실패했습니다");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download =
|
||||
response.headers.get("content-disposition")?.split("filename=")[1] ||
|
||||
`web-inspector-report.pdf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** JSON 다운로드 */
|
||||
async downloadJson(id: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/inspections/${id}/report/json`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, "JSON 다운로드에 실패했습니다");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download =
|
||||
response.headers.get("content-disposition")?.split("filename=")[1] ||
|
||||
`web-inspector-report.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** SSE 스트림 URL 반환 */
|
||||
getStreamUrl(inspectionId: string): string {
|
||||
return `${this.baseUrl}/api/inspections/${inspectionId}/stream`;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
141
frontend/src/lib/constants.ts
Normal file
141
frontend/src/lib/constants.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { CategoryKey, Grade, Severity } from "@/types/inspection";
|
||||
|
||||
/** 카테고리 한국어 라벨 */
|
||||
export const CATEGORY_LABELS: Record<CategoryKey, string> = {
|
||||
html_css: "HTML/CSS 표준",
|
||||
accessibility: "접근성 (WCAG)",
|
||||
seo: "SEO 최적화",
|
||||
performance_security: "성능/보안",
|
||||
};
|
||||
|
||||
/** 카테고리 짧은 라벨 */
|
||||
export const CATEGORY_SHORT_LABELS: Record<CategoryKey, string> = {
|
||||
html_css: "HTML/CSS",
|
||||
accessibility: "접근성",
|
||||
seo: "SEO",
|
||||
performance_security: "성능/보안",
|
||||
};
|
||||
|
||||
/** 카테고리 키 배열 */
|
||||
export const CATEGORY_KEYS: CategoryKey[] = [
|
||||
"html_css",
|
||||
"accessibility",
|
||||
"seo",
|
||||
"performance_security",
|
||||
];
|
||||
|
||||
/** 심각도 한국어 라벨 */
|
||||
export const SEVERITY_LABELS: Record<Severity, string> = {
|
||||
critical: "Critical",
|
||||
major: "Major",
|
||||
minor: "Minor",
|
||||
info: "Info",
|
||||
};
|
||||
|
||||
/** 심각도 색상 */
|
||||
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||
critical: "bg-red-500 text-white",
|
||||
major: "bg-orange-500 text-white",
|
||||
minor: "bg-yellow-500 text-white",
|
||||
info: "bg-blue-500 text-white",
|
||||
};
|
||||
|
||||
/** 심각도 배경 색상 (연한 버전) */
|
||||
export const SEVERITY_BG_COLORS: Record<Severity, string> = {
|
||||
critical: "bg-red-50 border-red-200",
|
||||
major: "bg-orange-50 border-orange-200",
|
||||
minor: "bg-yellow-50 border-yellow-200",
|
||||
info: "bg-blue-50 border-blue-200",
|
||||
};
|
||||
|
||||
/** 등급 색상 */
|
||||
export const GRADE_COLORS: Record<Grade, string> = {
|
||||
"A+": "text-green-600",
|
||||
A: "text-green-600",
|
||||
B: "text-blue-600",
|
||||
C: "text-yellow-600",
|
||||
D: "text-orange-600",
|
||||
F: "text-red-600",
|
||||
};
|
||||
|
||||
/** 등급 배경 색상 */
|
||||
export const GRADE_BG_COLORS: Record<Grade, string> = {
|
||||
"A+": "bg-green-100 text-green-700",
|
||||
A: "bg-green-100 text-green-700",
|
||||
B: "bg-blue-100 text-blue-700",
|
||||
C: "bg-yellow-100 text-yellow-700",
|
||||
D: "bg-orange-100 text-orange-700",
|
||||
F: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** 점수에 따른 색상 반환 */
|
||||
export function getScoreColor(score: number): string {
|
||||
if (score >= 80) return "#22C55E"; // 초록
|
||||
if (score >= 50) return "#F59E0B"; // 주황
|
||||
return "#EF4444"; // 빨강
|
||||
}
|
||||
|
||||
/** 점수에 따른 Tailwind 색상 클래스 */
|
||||
export function getScoreTailwindColor(score: number): string {
|
||||
if (score >= 80) return "text-green-500";
|
||||
if (score >= 50) return "text-yellow-500";
|
||||
return "text-red-500";
|
||||
}
|
||||
|
||||
/** 차트 카테고리 색상 */
|
||||
export const CHART_COLORS: Record<string, string> = {
|
||||
overall: "#6366F1",
|
||||
html_css: "#3B82F6",
|
||||
accessibility: "#22C55E",
|
||||
seo: "#F59E0B",
|
||||
performance_security: "#EF4444",
|
||||
};
|
||||
|
||||
/** 차트 범례 라벨 */
|
||||
export const CHART_LEGEND_LABELS: Record<string, string> = {
|
||||
overall_score: "종합",
|
||||
html_css: "HTML/CSS",
|
||||
accessibility: "접근성",
|
||||
seo: "SEO",
|
||||
performance_security: "성능/보안",
|
||||
};
|
||||
|
||||
/** 날짜 포맷 (로컬) */
|
||||
export function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** 날짜+시간 포맷 (로컬) */
|
||||
export function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** 짧은 날짜 포맷 (MM-DD) */
|
||||
export function formatShortDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${month}-${day}`;
|
||||
}
|
||||
|
||||
/** URL 유효성 검사 */
|
||||
export function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
23
frontend/src/lib/providers.tsx
Normal file
23
frontend/src/lib/providers.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
57
frontend/src/lib/queries.ts
Normal file
57
frontend/src/lib/queries.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
useQuery,
|
||||
keepPreviousData,
|
||||
} from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
/** 검사 결과 조회 */
|
||||
export function useInspectionResult(inspectionId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["inspection", inspectionId],
|
||||
queryFn: () => api.getInspection(inspectionId!),
|
||||
enabled: !!inspectionId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 이슈 목록 조회 */
|
||||
export function useInspectionIssues(
|
||||
inspectionId: string | undefined,
|
||||
category?: string,
|
||||
severity?: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["inspection-issues", inspectionId, category, severity],
|
||||
queryFn: () =>
|
||||
api.getIssues(inspectionId!, { category, severity }),
|
||||
enabled: !!inspectionId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 이력 목록 조회 (페이지네이션) */
|
||||
export function useInspectionHistory(page: number, url?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["inspection-history", page, url],
|
||||
queryFn: () => api.getInspections({ page, limit: 20, url }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
/** 트렌드 데이터 조회 */
|
||||
export function useInspectionTrend(url: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["inspection-trend", url],
|
||||
queryFn: () => api.getTrend(url!),
|
||||
enabled: !!url,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 최근 검사 이력 (메인 페이지용, 5건) */
|
||||
export function useRecentInspections() {
|
||||
return useQuery({
|
||||
queryKey: ["recent-inspections"],
|
||||
queryFn: () => api.getInspections({ page: 1, limit: 5 }),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user