214 lines
6.7 KiB
TypeScript
214 lines
6.7 KiB
TypeScript
import type { VerificationIssueStage } from './verificationBoardStages';
|
|
|
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
|
|
|
|
export interface VerificationIssueDto {
|
|
id?: number;
|
|
title: string;
|
|
content: string;
|
|
reproductionPath?: string;
|
|
stage: VerificationIssueStage;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
function getDeviceId(): string {
|
|
let id = localStorage.getItem('gc_device_id');
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
localStorage.setItem('gc_device_id', id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'x-device-id': getDeviceId(),
|
|
};
|
|
const userId = localStorage.getItem('gc_user_id');
|
|
if (userId) headers['x-user-id'] = userId;
|
|
return headers;
|
|
}
|
|
|
|
function authHeadersMultipart(): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'x-device-id': getDeviceId(),
|
|
};
|
|
const userId = localStorage.getItem('gc_user_id');
|
|
if (userId) headers['x-user-id'] = userId;
|
|
return headers;
|
|
}
|
|
|
|
export function verificationIssueImageUrl(issueId: number, imageId: number): string {
|
|
return `${API_BASE}/verification-issues/${issueId}/images/${imageId}/file`;
|
|
}
|
|
|
|
async function apiGet<T>(path: string): Promise<T | null> {
|
|
try {
|
|
const res = await fetch(`${API_BASE}${path}`, { headers: authHeaders() });
|
|
if (!res.ok) return null;
|
|
return (await res.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function mutateOrThrow<T>(path: string, init: RequestInit): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
...init,
|
|
headers: { ...authHeaders(), ...(init.headers ?? {}) },
|
|
});
|
|
const text = await res.text().catch(() => '');
|
|
if (!res.ok) {
|
|
throw new Error(text ? text.slice(0, 200) : `요청 실패 (${res.status})`);
|
|
}
|
|
if (res.status === 204 || !text) return undefined as T;
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export async function loadVerificationIssues(
|
|
stage?: VerificationIssueStage | '',
|
|
): Promise<VerificationIssueDto[]> {
|
|
const qs = stage ? `?stage=${encodeURIComponent(stage)}` : '';
|
|
return (await apiGet<VerificationIssueDto[]>(`/verification-issues${qs}`)) ?? [];
|
|
}
|
|
|
|
export async function loadVerificationIssue(id: number): Promise<VerificationIssueDto | null> {
|
|
return apiGet<VerificationIssueDto>(`/verification-issues/${id}`);
|
|
}
|
|
|
|
export async function saveVerificationIssue(dto: VerificationIssueDto): Promise<VerificationIssueDto> {
|
|
return mutateOrThrow<VerificationIssueDto>('/verification-issues', {
|
|
method: 'POST',
|
|
body: JSON.stringify(dto),
|
|
});
|
|
}
|
|
|
|
export async function deleteVerificationIssue(id: number): Promise<void> {
|
|
await mutateOrThrow<void>(`/verification-issues/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
// ── 댓글 ─────────────────────────────────────────────────────────────────────
|
|
|
|
export interface VerificationIssueCommentDto {
|
|
id?: number;
|
|
issueId?: number;
|
|
content: string;
|
|
authorName?: string;
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export async function loadVerificationIssueComments(
|
|
issueId: number,
|
|
): Promise<VerificationIssueCommentDto[]> {
|
|
return (await apiGet<VerificationIssueCommentDto[]>(
|
|
`/verification-issues/${issueId}/comments`,
|
|
)) ?? [];
|
|
}
|
|
|
|
export async function createVerificationIssueComment(
|
|
issueId: number,
|
|
dto: Pick<VerificationIssueCommentDto, 'content' | 'authorName'>,
|
|
): Promise<VerificationIssueCommentDto> {
|
|
return mutateOrThrow<VerificationIssueCommentDto>(
|
|
`/verification-issues/${issueId}/comments`,
|
|
{ method: 'POST', body: JSON.stringify(dto) },
|
|
);
|
|
}
|
|
|
|
export async function updateVerificationIssueComment(
|
|
issueId: number,
|
|
commentId: number,
|
|
dto: Pick<VerificationIssueCommentDto, 'content'>,
|
|
): Promise<VerificationIssueCommentDto> {
|
|
return mutateOrThrow<VerificationIssueCommentDto>(
|
|
`/verification-issues/${issueId}/comments/${commentId}`,
|
|
{ method: 'PUT', body: JSON.stringify(dto) },
|
|
);
|
|
}
|
|
|
|
export async function deleteVerificationIssueComment(
|
|
issueId: number,
|
|
commentId: number,
|
|
): Promise<void> {
|
|
await mutateOrThrow<void>(
|
|
`/verification-issues/${issueId}/comments/${commentId}`,
|
|
{ method: 'DELETE' },
|
|
);
|
|
}
|
|
|
|
// ── 첨부 이미지 ─────────────────────────────────────────────────────────────
|
|
|
|
export interface VerificationIssueImageDto {
|
|
id?: number;
|
|
issueId?: number;
|
|
fileName?: string;
|
|
contentType?: string;
|
|
fileSize?: number;
|
|
sortOrder?: number;
|
|
url?: string;
|
|
createdAt?: string;
|
|
}
|
|
|
|
export async function loadVerificationIssueImages(
|
|
issueId: number,
|
|
): Promise<VerificationIssueImageDto[]> {
|
|
return (await apiGet<VerificationIssueImageDto[]>(
|
|
`/verification-issues/${issueId}/images`,
|
|
)) ?? [];
|
|
}
|
|
|
|
export async function uploadVerificationIssueImages(
|
|
issueId: number,
|
|
files: File[],
|
|
): Promise<VerificationIssueImageDto[]> {
|
|
const form = new FormData();
|
|
files.forEach(f => form.append('files', f, f.name));
|
|
const res = await fetch(`${API_BASE}/verification-issues/${issueId}/images`, {
|
|
method: 'POST',
|
|
headers: authHeadersMultipart(),
|
|
body: form,
|
|
});
|
|
const text = await res.text().catch(() => '');
|
|
if (!res.ok) {
|
|
throw new Error(parseVerificationUploadError(res.status, text));
|
|
}
|
|
return JSON.parse(text) as VerificationIssueImageDto[];
|
|
}
|
|
|
|
function parseVerificationUploadError(status: number, text: string): string {
|
|
if (text) {
|
|
try {
|
|
const json = JSON.parse(text) as { message?: string };
|
|
if (json.message) return json.message;
|
|
} catch {
|
|
if (text.includes('413') || text.toLowerCase().includes('too large')) {
|
|
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
|
|
}
|
|
if (!text.trimStart().startsWith('<')) {
|
|
return text.slice(0, 200);
|
|
}
|
|
}
|
|
}
|
|
if (status === 413) {
|
|
return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.';
|
|
}
|
|
if (status === 400) return '파일 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp, zip, hwp, hwpx · 파일당 5MB)';
|
|
return `이미지 업로드에 실패했습니다. (${status})`;
|
|
}
|
|
|
|
export async function deleteVerificationIssueImage(
|
|
issueId: number,
|
|
imageId: number,
|
|
): Promise<void> {
|
|
const res = await fetch(
|
|
`${API_BASE}/verification-issues/${issueId}/images/${imageId}`,
|
|
{ method: 'DELETE', headers: authHeadersMultipart() },
|
|
);
|
|
if (!res.ok && res.status !== 204) {
|
|
throw new Error(`삭제 실패 (${res.status})`);
|
|
}
|
|
}
|