generated from gc/template-java-maven
- 디자인 시스템 가이드 문서 11개 생성 (docs/design/) - CSS 변수 토큰 시스템 (@theme + :root/.dark 전환) - cn() 유틸리티 (clsx + tailwind-merge) - Button/Badge 공통 컴포넌트 (variant/size, 다크모드 대응) - 하드코딩 Tailwind 색상 → CSS 변수 토큰 리팩토링 (30개 파일) - 차트 팔레트 다크모드 색상 업데이트 (CHART_COLORS_HEX) - 버튼 다크모드 채도/대비 강화 (primary-600 기반) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
496 lines
22 KiB
TypeScript
496 lines
22 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import type { ServiceCatalog } from '../../types/apihub';
|
|
import type { ApiDetailInfo } from '../../types/service';
|
|
import { getServiceCatalog, getApiHubApiDetail } from '../../services/apiHubService';
|
|
import { getSystemConfig } from '../../services/configService';
|
|
import { useApiRequestBasket } from '../../hooks/useApiRequestBasket';
|
|
import ApiKeyRequestModal from '../../components/ApiKeyRequestModal';
|
|
import Button from '../../components/ui/Button';
|
|
|
|
const COMMON_SAMPLE_CODE_KEY = 'COMMON_SAMPLE_CODE';
|
|
|
|
const METHOD_COLORS_LARGE: Record<string, string> = {
|
|
GET: 'bg-green-500',
|
|
POST: 'bg-blue-500',
|
|
PUT: 'bg-amber-500',
|
|
PATCH: 'bg-amber-500',
|
|
DELETE: 'bg-red-500',
|
|
};
|
|
|
|
const ApiHubApiDetailPage = () => {
|
|
const { serviceId, apiId } = useParams<{ serviceId: string; apiId: string }>();
|
|
const navigate = useNavigate();
|
|
const { addItem, removeItem, hasItem, setBasketOpen } = useApiRequestBasket();
|
|
const [detail, setDetail] = useState<ApiDetailInfo | null>(null);
|
|
const [service, setService] = useState<ServiceCatalog | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [paramInputs, setParamInputs] = useState<Record<string, string>>({});
|
|
const [generatedUrl, setGeneratedUrl] = useState<string | null>(null);
|
|
const [urlCopied, setUrlCopied] = useState(false);
|
|
const [validationErrors, setValidationErrors] = useState<Record<string, boolean>>({});
|
|
const [commonSampleCode, setCommonSampleCode] = useState<string | null>(null);
|
|
const [urlGenOpen, setUrlGenOpen] = useState(false);
|
|
|
|
// 신청 모달 상태
|
|
const [showRequestModal, setShowRequestModal] = useState(false);
|
|
const [requestModalApiIds, setRequestModalApiIds] = useState<number[]>([]);
|
|
const [requestModalApiNames, setRequestModalApiNames] = useState<string[]>([]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (!serviceId || !apiId) return;
|
|
try {
|
|
const [serviceRes, detailRes, sampleCodeRes] = await Promise.all([
|
|
getServiceCatalog(Number(serviceId)),
|
|
getApiHubApiDetail(Number(serviceId), Number(apiId)),
|
|
getSystemConfig(COMMON_SAMPLE_CODE_KEY),
|
|
]);
|
|
if (serviceRes.success && serviceRes.data) {
|
|
setService(serviceRes.data);
|
|
}
|
|
if (detailRes.success && detailRes.data) {
|
|
setDetail(detailRes.data);
|
|
} else {
|
|
setError('API 정보를 불러오지 못했습니다');
|
|
}
|
|
if (sampleCodeRes.success && sampleCodeRes.data?.configValue) {
|
|
setCommonSampleCode(sampleCodeRes.data.configValue);
|
|
}
|
|
} catch {
|
|
setError('API 정보를 불러오는 중 오류가 발생했습니다');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [serviceId, apiId]);
|
|
|
|
useEffect(() => {
|
|
setParamInputs({});
|
|
setGeneratedUrl(null);
|
|
setUrlCopied(false);
|
|
setValidationErrors({});
|
|
setIsLoading(true);
|
|
setError(null);
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const urlInputParams = useMemo(() =>
|
|
detail?.requestParams.filter((p) => p.paramName.toUpperCase() !== 'URL') ?? [],
|
|
[detail?.requestParams]
|
|
);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-[var(--color-text-secondary)]">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !detail) {
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={() => navigate(`/api-hub/services/${serviceId}`)}
|
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] mb-4 inline-block"
|
|
>
|
|
← 서비스로
|
|
</button>
|
|
<div className="text-center py-20 text-[var(--color-text-secondary)]">
|
|
{error ?? 'API를 찾을 수 없습니다'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const api = detail.api;
|
|
const spec = detail.spec;
|
|
const domainLabel = api.apiDomain ? api.apiDomain.toUpperCase() : '기타';
|
|
|
|
const handleGenerateUrl = () => {
|
|
const errors: Record<string, boolean> = {};
|
|
for (const p of urlInputParams) {
|
|
if (p.required && !paramInputs[p.paramName]?.trim()) {
|
|
errors[p.paramName] = true;
|
|
}
|
|
}
|
|
setValidationErrors(errors);
|
|
if (Object.keys(errors).length > 0) return;
|
|
|
|
const base = spec?.sampleUrl || (service?.serviceUrl ? `${service.serviceUrl}${api.apiPath}` : api.apiPath);
|
|
const queryParts: string[] = [];
|
|
for (const p of urlInputParams) {
|
|
const val = paramInputs[p.paramName];
|
|
if (val) {
|
|
const type = (p.inputType || 'TEXT').toUpperCase();
|
|
let formatted = val;
|
|
if (type === 'DATE' || type === 'DATETIME') {
|
|
formatted = new Date(val).toISOString();
|
|
}
|
|
queryParts.push(`${p.paramName}=${formatted}`);
|
|
}
|
|
}
|
|
const url = queryParts.length > 0
|
|
? `${base}${base.includes('?') ? '&' : '?'}${queryParts.join('&')}`
|
|
: base;
|
|
setGeneratedUrl(url);
|
|
setUrlCopied(false);
|
|
};
|
|
|
|
const handleCopyGeneratedUrl = () => {
|
|
if (generatedUrl) {
|
|
navigator.clipboard.writeText(generatedUrl);
|
|
setUrlCopied(true);
|
|
setTimeout(() => setUrlCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const handleOpenRequest = () => {
|
|
if (detail) {
|
|
setRequestModalApiIds([detail.api.apiId]);
|
|
setRequestModalApiNames([detail.api.apiName]);
|
|
setShowRequestModal(true);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto">
|
|
{/* Breadcrumb */}
|
|
<nav className="flex items-center gap-2 text-sm text-[var(--color-text-secondary)] mb-4">
|
|
<button
|
|
onClick={() => navigate('/api-hub')}
|
|
className="hover:text-[var(--color-primary)] transition-colors"
|
|
>
|
|
API HUB
|
|
</button>
|
|
<span>/</span>
|
|
<button
|
|
onClick={() => navigate(`/api-hub/domains/${encodeURIComponent(domainLabel)}`)}
|
|
className="hover:text-[var(--color-primary)] transition-colors"
|
|
>
|
|
{domainLabel}
|
|
</button>
|
|
<span>/</span>
|
|
<span className="text-[var(--color-text-primary)] font-medium truncate max-w-xs">{api.apiName}</span>
|
|
</nav>
|
|
|
|
{/* Header Card */}
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 mb-6 border border-[var(--color-border)]">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<span
|
|
className={`px-3 py-1.5 rounded text-sm font-bold uppercase text-white shrink-0 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
|
>
|
|
{api.apiMethod}
|
|
</span>
|
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">{api.apiName}</h1>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{hasItem(Number(apiId)) ? (
|
|
<button
|
|
onClick={() => removeItem(Number(apiId))}
|
|
className="px-4 py-2 rounded-lg border border-indigo-300 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 text-sm font-medium transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
신청함에서 빼기
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
if (detail) {
|
|
addItem({ apiId: detail.api.apiId, serviceId: Number(serviceId), apiName: detail.api.apiName, domain: detail.api.apiDomain ?? undefined });
|
|
setBasketOpen(true);
|
|
}
|
|
}}
|
|
className="px-4 py-2 rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-bg-base)] text-sm font-medium transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
</svg>
|
|
신청함에 담기
|
|
</button>
|
|
)}
|
|
<Button onClick={handleOpenRequest}>
|
|
API 사용 신청
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
|
|
{/* 기본 정보 */}
|
|
{api.description && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-4">기본 정보</h2>
|
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">{api.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 샘플 URL */}
|
|
{spec?.sampleUrl && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">샘플 URL</h2>
|
|
<a
|
|
href={spec.sampleUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2 font-mono"
|
|
>
|
|
{spec.sampleUrl}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* 요청 URL 생성 (아코디언) */}
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
|
<button
|
|
type="button"
|
|
onClick={() => setUrlGenOpen((v) => !v)}
|
|
className="w-full flex items-center justify-between px-6 py-4 text-left"
|
|
>
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청 URL 생성</h2>
|
|
<svg
|
|
className={`w-5 h-5 text-[var(--color-text-secondary)] transition-transform duration-200 ${urlGenOpen ? 'rotate-180' : ''}`}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
|
|
{urlGenOpen && (
|
|
<div className="px-6 pb-6 border-t border-[var(--color-border)] pt-4">
|
|
{/* URL 생성 폼 */}
|
|
{urlInputParams.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="space-y-3 mb-4">
|
|
{urlInputParams.map((p) => {
|
|
const hasError = validationErrors[p.paramName];
|
|
const inputCls = `flex-1 border ${hasError ? 'border-red-400' : 'border-[var(--color-border-strong)]'} bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-[var(--color-primary)] focus:outline-none`;
|
|
const type = (p.inputType || 'TEXT').toUpperCase();
|
|
|
|
return (
|
|
<div key={p.paramName}>
|
|
<div className="flex items-center gap-3">
|
|
<label className="w-36 shrink-0 text-sm font-medium text-[var(--color-text-secondary)]">
|
|
{p.paramName}
|
|
{p.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
</label>
|
|
{type === 'DATE' || type === 'DATETIME' ? (
|
|
<input
|
|
type={type === 'DATETIME' ? 'datetime-local' : 'date'}
|
|
value={paramInputs[p.paramName] || ''}
|
|
onChange={(e) => {
|
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
|
}}
|
|
className={inputCls}
|
|
/>
|
|
) : type === 'NUMBER' ? (
|
|
<input
|
|
type="number"
|
|
value={paramInputs[p.paramName] || ''}
|
|
onChange={(e) => {
|
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
|
}}
|
|
placeholder={p.paramMeaning || p.paramName}
|
|
className={inputCls}
|
|
/>
|
|
) : (
|
|
<input
|
|
type="text"
|
|
value={paramInputs[p.paramName] || ''}
|
|
onChange={(e) => {
|
|
setParamInputs((prev) => ({ ...prev, [p.paramName]: e.target.value }));
|
|
if (hasError) setValidationErrors((prev) => ({ ...prev, [p.paramName]: false }));
|
|
}}
|
|
placeholder={p.paramMeaning || p.paramName}
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
</div>
|
|
{hasError && (
|
|
<p className="ml-[9.75rem] mt-1 text-xs text-red-500">{p.paramName}은(는) 필수입니다</p>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<Button onClick={handleGenerateUrl}>
|
|
URL 생성
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 생성된 URL */}
|
|
{generatedUrl && (
|
|
<div className="mb-4">
|
|
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5">생성된 요청 URL</p>
|
|
<div className="flex items-center p-3 bg-[var(--color-bg-base)] rounded-lg">
|
|
<span
|
|
className={`px-2 py-1 rounded text-xs font-bold uppercase text-white shrink-0 mr-3 ${METHOD_COLORS_LARGE[api.apiMethod] ?? 'bg-gray-500'}`}
|
|
>
|
|
{api.apiMethod}
|
|
</span>
|
|
<span className="font-mono text-sm text-[var(--color-text-primary)] break-all flex-1">
|
|
{generatedUrl}
|
|
</span>
|
|
<button
|
|
onClick={handleCopyGeneratedUrl}
|
|
className="ml-2 px-2 py-0.5 text-xs bg-[var(--color-bg-base)] text-[var(--color-text-tertiary)] rounded hover:bg-[var(--color-border)] transition-colors"
|
|
>
|
|
{urlCopied ? '복사됨' : '복사'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 샘플 코드 */}
|
|
{commonSampleCode && (
|
|
<div>
|
|
<p className="text-xs font-medium text-[var(--color-text-secondary)] mb-1.5">샘플 코드</p>
|
|
<pre className="bg-gray-900 text-green-400 text-sm font-mono p-4 rounded-lg overflow-x-auto">
|
|
{commonSampleCode}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{urlInputParams.length === 0 && !commonSampleCode && (
|
|
<p className="text-sm text-[var(--color-text-tertiary)]">요청인자가 등록되지 않았습니다</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 요청인자 */}
|
|
{detail.requestParams.length > 0 && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">요청인자</h2>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--color-bg-base)]">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">인자명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">의미</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase">설명</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{detail.requestParams.map((param) => (
|
|
<tr key={param.paramId} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
|
{param.paramName}
|
|
{param.required && <span className="text-red-500 ml-0.5">*</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
{param.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--color-text-tertiary)] max-w-xs">
|
|
{param.paramDescription ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 출력결과 */}
|
|
{detail.responseParams.length > 0 && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow border border-[var(--color-border)]">
|
|
<div className="px-6 py-4 border-b border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">출력결과</h2>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--color-bg-base)]">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">변수명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">의미(단위)</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">변수명</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--color-text-secondary)] uppercase w-1/4">의미(단위)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{Array.from({ length: Math.ceil(detail.responseParams.length / 2) }, (_, rowIdx) => {
|
|
const left = detail.responseParams[rowIdx * 2];
|
|
const right = detail.responseParams[rowIdx * 2 + 1];
|
|
return (
|
|
<tr key={rowIdx} className="hover:bg-[var(--color-bg-base)] transition-colors">
|
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
|
{left.paramName}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
{left.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
|
</td>
|
|
{right ? (
|
|
<>
|
|
<td className="px-4 py-3 font-mono text-xs text-[var(--color-text-primary)] font-medium">
|
|
{right.paramName}
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--color-text-primary)]">
|
|
{right.paramMeaning ?? <span className="text-[var(--color-text-tertiary)]">-</span>}
|
|
</td>
|
|
</>
|
|
) : (
|
|
<>
|
|
<td className="px-4 py-3" />
|
|
<td className="px-4 py-3" />
|
|
</>
|
|
)}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 참고자료 */}
|
|
{spec?.referenceUrl && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">참고자료</h2>
|
|
<a
|
|
href={spec.referenceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-sm text-[var(--color-primary)] hover:text-[var(--color-primary-hover)] break-all underline underline-offset-2"
|
|
>
|
|
{spec.referenceUrl}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{/* 비고 */}
|
|
{spec?.note && (
|
|
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow p-6 border border-[var(--color-border)]">
|
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-3">비고</h2>
|
|
<p className="text-sm text-[var(--color-text-primary)] leading-relaxed whitespace-pre-wrap">
|
|
{spec.note}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ApiKeyRequestModal
|
|
isOpen={showRequestModal}
|
|
onClose={() => setShowRequestModal(false)}
|
|
initialApiIds={requestModalApiIds}
|
|
initialApiNames={requestModalApiNames}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApiHubApiDetailPage;
|