검증게시판 기능 추가
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* GoldenChart 화면·기능별 재현 경로 카탈로그 (검증 이슈 재현경로 자동완성)
|
||||
*
|
||||
* path: 사용자에게 표시·저장되는 경로 (메뉴-화면-… 형식)
|
||||
* keywords: 입력 검색용 별칭 (메뉴명, 기능명, 영문 등)
|
||||
*/
|
||||
|
||||
export interface AppNavigationPathEntry {
|
||||
path: string;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
const P = (path: string, ...keywords: string[]): AppNavigationPathEntry => ({
|
||||
path,
|
||||
keywords: [path, ...keywords],
|
||||
});
|
||||
|
||||
/** 프로젝트에서 제공하는 주요 화면·기능 경로 */
|
||||
export const APP_NAVIGATION_PATHS: AppNavigationPathEntry[] = [
|
||||
// ── 상단 메뉴 ──
|
||||
P('메뉴-대시보드', 'dashboard', '홈'),
|
||||
P('메뉴-실시간 차트', 'chart', '실시간차트', '캔들', '차트'),
|
||||
P('메뉴-가상매매', 'virtual', '가상', '모의'),
|
||||
P('메뉴-추세검색', 'trend', '추세', '검색'),
|
||||
P('메뉴-검증게시판', 'verification', 'QA', '이슈', '검증'),
|
||||
P('메뉴-전략편집기', 'strategy-editor', '전략', '편집'),
|
||||
P('메뉴-백테스팅', 'backtest', '백테스트', '히스토리'),
|
||||
P('메뉴-설정', 'settings', '환경설정'),
|
||||
|
||||
// ── 실시간 차트 · 툴바 ──
|
||||
P('메뉴-실시간 차트-종목 변경', '심볼', '검색', '마켓'),
|
||||
P('메뉴-실시간 차트-워치리스트', 'watchlist', '관심'),
|
||||
P('메뉴-실시간 차트-시간봉 변경', 'timeframe', '분봉', '일봉'),
|
||||
P('메뉴-실시간 차트-차트 유형', '캔들', '봉', 'line', 'heikin'),
|
||||
P('메뉴-실시간 차트-지표 추가 버튼', '지표', 'indicator', '추가', 'Ctrl+I', '보조지표'),
|
||||
P('메뉴-실시간 차트-지표 추가 팝업-지표 검색', '지표검색', 'RSI', 'MACD', '이동평균'),
|
||||
P('메뉴-실시간 차트-지표 추가 팝업-보조지표 설정', '지표설정', '활성지표'),
|
||||
P('메뉴-실시간 차트-보조지표 설정 버튼', '일괄설정', '보조지표설정'),
|
||||
P('메뉴-실시간 차트-실행 취소', 'undo', 'Ctrl+Z'),
|
||||
P('메뉴-실시간 차트-다시 실행', 'redo', 'Ctrl+Y'),
|
||||
P('메뉴-실시간 차트-자석모드', 'magnet', '스냅'),
|
||||
P('메뉴-실시간 차트-전체 드로잉 삭제', '드로잉', 'drawing'),
|
||||
P('메뉴-실시간 차트-통계 패널', 'stats', '통계'),
|
||||
P('메뉴-실시간 차트-자동 피보나치', 'fib', '피보'),
|
||||
P('메뉴-실시간 차트-그리드 표시', 'grid', '격자'),
|
||||
P('메뉴-실시간 차트-트레이딩/차트 모드 전환', 'mode', '트레이딩'),
|
||||
P('메뉴-실시간 차트-백테스팅 전략 선택', '백테', '전략선택'),
|
||||
P('메뉴-실시간 차트-백테스팅 설정', 'bt설정'),
|
||||
P('메뉴-실시간 차트-백테스팅 결과 보기', 'bt결과'),
|
||||
P('메뉴-실시간 차트-실시간 전략 체크', 'live', '라이브전략'),
|
||||
P('메뉴-실시간 차트-마켓 패널', '마켓', '좌측패널'),
|
||||
P('메뉴-실시간 차트-확대경', 'magnifier', '돋보기'),
|
||||
P('메뉴-실시간 차트-레이아웃 설정', 'layout', '분할'),
|
||||
P('메뉴-실시간 차트-화면 맞춤', 'fit', 'F키'),
|
||||
P('메뉴-실시간 차트-현재 시각으로 이동', 'realtime', 'now'),
|
||||
P('메뉴-실시간 차트-매매 시그널 알림', 'trade', '시그널', '알림'),
|
||||
P('메뉴-실시간 차트-가격 알림', 'alert', '가격'),
|
||||
P('메뉴-실시간 차트-스크린샷', 'screenshot', '캡처'),
|
||||
P('메뉴-실시간 차트-전체화면', 'fullscreen'),
|
||||
|
||||
// ── 설정 카테고리 ──
|
||||
P('메뉴-설정-일반 설정', 'general', '언어', '테마'),
|
||||
P('메뉴-설정-차트 설정', 'chart설정', '캔들색', '범례'),
|
||||
P('메뉴-설정-보조지표 설정', 'indicators', '지표기본값', 'Main탭'),
|
||||
P('메뉴-설정-백테스팅', 'bt옵션', '자본'),
|
||||
P('메뉴-설정-전략 설정', 'strategy', '전략기본'),
|
||||
P('메뉴-설정-가상매매', 'paper', '가상자본'),
|
||||
P('메뉴-설정-추세검색', 'trend설정', '배점'),
|
||||
P('메뉴-설정-알림 설정', 'alert설정', '소리', '팝업'),
|
||||
P('메뉴-설정-네트워크', 'network', 'API', 'WebSocket'),
|
||||
P('메뉴-설정-관리자 설정', 'admin', '권한', '역할'),
|
||||
|
||||
// ── 가상매매 ──
|
||||
P('메뉴-가상매매-투자대상 그리드', '타겟', '종목'),
|
||||
P('메뉴-가상매매-우측 매매 탭', '주문', 'trade'),
|
||||
P('메뉴-가상매매-우측 호가 탭', '호가', 'orderbook'),
|
||||
P('메뉴-가상매매-우측 체결 탭', '체결', 'history'),
|
||||
|
||||
// ── 추세검색 ──
|
||||
P('메뉴-추세검색-검색 실행', 'run', '스캔'),
|
||||
P('메뉴-추세검색-결과 카드', '결과', '차트미리보기'),
|
||||
|
||||
// ── 검증게시판 ──
|
||||
P('메뉴-검증게시판-이슈 등록', '등록', '추가'),
|
||||
P('메뉴-검증게시판-이슈 수정', '수정', '편집'),
|
||||
P('메뉴-검증게시판-이슈 삭제', '삭제'),
|
||||
P('메뉴-검증게시판-첨부 이미지', '이미지', '스크린샷', '첨부'),
|
||||
P('메뉴-검증게시판-댓글', 'comment', '코멘트'),
|
||||
|
||||
// ── 전략편집기 ──
|
||||
P('메뉴-전략편집기-전략 목록', '목록', '리스트'),
|
||||
P('메뉴-전략편집기-노드 캔버스', 'flow', '노드', '조건'),
|
||||
P('메뉴-전략편집기-지표 팔레트', 'palette', '팔레트'),
|
||||
P('메뉴-전략편집기-전략 저장', 'save'),
|
||||
P('메뉴-전략편집기-전략 JSON보내기', 'export', '보내기'),
|
||||
P('메뉴-전략편집기-전략 JSON 가져오기', 'import', '가져오기'),
|
||||
|
||||
// ── 백테스팅 ──
|
||||
P('메뉴-백테스팅-결과 목록', '히스토리', 'history'),
|
||||
P('메뉴-백테스팅-분석 차트', 'analysis'),
|
||||
|
||||
// ── 상단 메뉴바 기타 ──
|
||||
P('메뉴-알림 목록', 'notifications', '시그널목록'),
|
||||
P('메뉴-테마 전환', 'theme', '다크', '라이트'),
|
||||
P('메뉴-로그인', 'login', 'auth'),
|
||||
];
|
||||
|
||||
function normalizeForSearch(s: string): string {
|
||||
return s.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 문자열과 관련된 경로 후보 (최대 limit건, 관련도 순)
|
||||
*/
|
||||
export function searchAppNavigationPaths(query: string, limit = 12): AppNavigationPathEntry[] {
|
||||
const q = query.trim();
|
||||
if (!q) return APP_NAVIGATION_PATHS.slice(0, limit);
|
||||
|
||||
const nq = normalizeForSearch(q);
|
||||
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
type Scored = { entry: AppNavigationPathEntry; score: number };
|
||||
const scored: Scored[] = [];
|
||||
|
||||
for (const entry of APP_NAVIGATION_PATHS) {
|
||||
let score = 0;
|
||||
const pathNorm = normalizeForSearch(entry.path);
|
||||
|
||||
if (entry.path.includes(q)) score += 80;
|
||||
if (pathNorm.includes(nq)) score += 60;
|
||||
if (pathNorm.startsWith(nq)) score += 40;
|
||||
|
||||
for (const kw of entry.keywords) {
|
||||
const kn = normalizeForSearch(kw);
|
||||
if (kw.includes(q)) score += 50;
|
||||
if (kn.includes(nq)) score += 35;
|
||||
if (kn.startsWith(nq)) score += 25;
|
||||
for (const t of tokens) {
|
||||
if (kn.includes(t) || kw.toLowerCase().includes(t)) score += 15;
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0) scored.push({ entry, score });
|
||||
}
|
||||
|
||||
scored.sort((a, b) => b.score - a.score || a.entry.path.localeCompare(b.entry.path, 'ko'));
|
||||
return scored.slice(0, limit).map(s => s.entry);
|
||||
}
|
||||
@@ -404,6 +404,8 @@ export interface AppSettingsDto {
|
||||
tradeAlertPopupLayout?: string;
|
||||
/** 그리드 배치 열 개수 (2~4) */
|
||||
tradeAlertPopupGridCols?: number;
|
||||
/** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */
|
||||
verificationIssueNotify?: boolean;
|
||||
/** 실시간 전략 체크 마스터 ON — ON이면 DB 관심종목 전체가 체크 대상 */
|
||||
liveStrategyCheck?: boolean;
|
||||
/** 관심종목 공통 전략 ID */
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* 클립보드·붙여넣기 이미지 → 업로드용 File 정규화
|
||||
*/
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/bmp',
|
||||
]);
|
||||
|
||||
/** 클립보드 항목에서 우선 선택할 MIME (macOS TIFF 등 제외) */
|
||||
const CLIPBOARD_PREFERRED = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
];
|
||||
|
||||
function normalizeMime(raw: string | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const base = raw.split(';')[0]?.trim().toLowerCase() ?? '';
|
||||
if (base === 'image/jpg' || base === 'image/pjpeg') {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (base === 'image/x-png') {
|
||||
return 'image/png';
|
||||
}
|
||||
return ALLOWED_MIME.has(base) ? base : null;
|
||||
}
|
||||
|
||||
function extForMime(mime: string): string {
|
||||
switch (mime) {
|
||||
case 'image/jpeg': return 'jpg';
|
||||
case 'image/png': return 'png';
|
||||
case 'image/gif': return 'gif';
|
||||
case 'image/webp': return 'webp';
|
||||
case 'image/bmp': return 'bmp';
|
||||
default: return 'png';
|
||||
}
|
||||
}
|
||||
|
||||
function guessMimeFromName(name: string): string | undefined {
|
||||
const lower = name.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
||||
if (lower.endsWith('.png')) return 'image/png';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.bmp')) return 'image/bmp';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** File MIME·이름 정규화 (클립보드·붙여넣기 대응) */
|
||||
export function normalizeImageFile(file: File): File | null {
|
||||
const mime = normalizeMime(file.type)
|
||||
?? normalizeMime(guessMimeFromName(file.name))
|
||||
?? 'image/png';
|
||||
|
||||
if (!ALLOWED_MIME.has(mime)) return null;
|
||||
if (file.size === 0) return null;
|
||||
|
||||
const ext = extForMime(mime);
|
||||
const name = file.name && !file.name.startsWith('blob')
|
||||
? file.name
|
||||
: `clipboard-${Date.now()}.${ext}`;
|
||||
|
||||
if (file.type === mime && file.name === name) return file;
|
||||
return new File([file], name, { type: mime, lastModified: file.lastModified });
|
||||
}
|
||||
|
||||
/** FileList / File[] → 허용 이미지만 */
|
||||
export function pickImageFiles(list: FileList | File[]): File[] {
|
||||
return Array.from(list)
|
||||
.map(normalizeImageFile)
|
||||
.filter((f): f is File => f != null);
|
||||
}
|
||||
|
||||
/** 백엔드 제한(5MB) 초과 시 JPEG로 축소 */
|
||||
export async function compressImageIfNeeded(
|
||||
file: File,
|
||||
maxBytes = 5 * 1024 * 1024,
|
||||
): Promise<File> {
|
||||
if (file.size <= maxBytes) return file;
|
||||
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
throw new Error('IMAGE_READ_FAILED');
|
||||
}
|
||||
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
const maxDim = 2560;
|
||||
if (w > maxDim || h > maxDim) {
|
||||
const scale = Math.min(maxDim / w, maxDim / h);
|
||||
w = Math.max(1, Math.round(w * scale));
|
||||
h = Math.max(1, Math.round(h * scale));
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
throw new Error('IMAGE_READ_FAILED');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||
bitmap.close();
|
||||
|
||||
let quality = 0.9;
|
||||
let blob: Blob | null = null;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
blob = await new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality);
|
||||
});
|
||||
if (blob && blob.size <= maxBytes) break;
|
||||
quality -= 0.1;
|
||||
}
|
||||
|
||||
if (!blob || blob.size > maxBytes) {
|
||||
throw new Error('IMAGE_TOO_LARGE');
|
||||
}
|
||||
|
||||
const base = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||
return new File([blob], `${base}.jpg`, { type: 'image/jpeg', lastModified: Date.now() });
|
||||
}
|
||||
|
||||
/** 업로드 전 이미지 정규화·용량 조정 */
|
||||
export async function prepareImagesForUpload(files: File[]): Promise<File[]> {
|
||||
const normalized = pickImageFiles(files);
|
||||
const out: File[] = [];
|
||||
for (const f of normalized) {
|
||||
out.push(await compressImageIfNeeded(f));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** ClipboardEvent / DataTransfer 에서 이미지 File 추출 */
|
||||
export function filesFromDataTransfer(data: DataTransfer): File[] {
|
||||
const out: File[] = [];
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const normalized = normalizeImageFile(file);
|
||||
if (normalized) out.push(normalized);
|
||||
}
|
||||
if (out.length === 0) {
|
||||
return pickImageFiles(data.files);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** navigator.clipboard.read() 결과 → File[] */
|
||||
export async function filesFromClipboardRead(): Promise<File[]> {
|
||||
if (!navigator.clipboard?.read) {
|
||||
throw new Error('CLIPBOARD_UNSUPPORTED');
|
||||
}
|
||||
|
||||
const items = await navigator.clipboard.read();
|
||||
const files: File[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const type = CLIPBOARD_PREFERRED.find(t => item.types.includes(t));
|
||||
if (!type) continue;
|
||||
|
||||
const blob = await item.getType(type);
|
||||
if (!blob || blob.size === 0) continue;
|
||||
|
||||
const mime = normalizeMime(blob.type) ?? type;
|
||||
const ext = extForMime(mime);
|
||||
files.push(new File(
|
||||
[blob],
|
||||
`clipboard-${Date.now()}.${ext}`,
|
||||
{ type: mime },
|
||||
));
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type SettingsCategoryId =
|
||||
| 'paper' | 'trend-search' | 'alert' | 'network' | 'admin';
|
||||
|
||||
export const TOP_MENU_IDS: TopMenuId[] = [
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings',
|
||||
'dashboard', 'chart', 'virtual', 'trend-search', 'strategy-editor', 'backtest', 'settings', 'verification-board',
|
||||
];
|
||||
|
||||
export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
@@ -20,7 +20,7 @@ export const SETTINGS_MENU_IDS: SettingsCategoryId[] = [
|
||||
];
|
||||
|
||||
export const ALL_MENU_IDS = [
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
'dashboard', 'chart', 'paper', 'virtual', 'trend-search', 'verification-board', 'strategy', 'strategy-editor', 'backtest', 'notifications', 'settings',
|
||||
...SETTINGS_MENU_IDS.map(id => `settings_${id}` as const),
|
||||
] as const;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const MENU_LABELS: Record<string, string> = {
|
||||
paper: '모의투자',
|
||||
virtual: '가상매매',
|
||||
'trend-search': '추세검색',
|
||||
'verification-board': '검증게시판',
|
||||
strategy: '투자전략',
|
||||
'strategy-editor': '전략편집기',
|
||||
backtest: '백테스팅',
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
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));
|
||||
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 · 파일당 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})`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** 검증 이슈 처리 단계 */
|
||||
export type VerificationIssueStage =
|
||||
| 'REGISTERED'
|
||||
| 'IN_FIX'
|
||||
| 'FIX_COMPLETE'
|
||||
| 'RE_REGISTERED'
|
||||
| 'COMPLETE';
|
||||
|
||||
export interface VerificationIssueStageMeta {
|
||||
value: VerificationIssueStage;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const VERIFICATION_ISSUE_STAGES: VerificationIssueStageMeta[] = [
|
||||
{ value: 'REGISTERED', label: '등록', color: '#42a5f5' },
|
||||
{ value: 'IN_FIX', label: '수정중', color: '#ffa726' },
|
||||
{ value: 'FIX_COMPLETE', label: '수정완료', color: '#66bb6a' },
|
||||
{ value: 'RE_REGISTERED', label: '재등록', color: '#ab47bc' },
|
||||
{ value: 'COMPLETE', label: '완료', color: '#78909c' },
|
||||
];
|
||||
|
||||
export const STAGE_BY_VALUE = Object.fromEntries(
|
||||
VERIFICATION_ISSUE_STAGES.map(s => [s.value, s]),
|
||||
) as Record<VerificationIssueStage, VerificationIssueStageMeta>;
|
||||
|
||||
export function stageLabel(stage: VerificationIssueStage): string {
|
||||
return STAGE_BY_VALUE[stage]?.label ?? stage;
|
||||
}
|
||||
|
||||
export function stageColor(stage: VerificationIssueStage): string {
|
||||
return STAGE_BY_VALUE[stage]?.color ?? '#78909c';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { VerificationIssueStage } from './verificationBoardStages';
|
||||
|
||||
export const VERIFICATION_ISSUE_EVENT_TOPIC = '/sub/verification-issues/events';
|
||||
|
||||
export type VerificationIssueEventType = 'CREATED' | 'STAGE_CHANGED';
|
||||
|
||||
export interface VerificationIssueEventDto {
|
||||
eventType: VerificationIssueEventType;
|
||||
issueId: number;
|
||||
title: string;
|
||||
stage: VerificationIssueStage;
|
||||
previousStage?: VerificationIssueStage | null;
|
||||
updatedAt?: string;
|
||||
sourceDeviceId?: string | null;
|
||||
}
|
||||
|
||||
export interface VerificationIssueToastItem {
|
||||
id: string;
|
||||
issueId: number;
|
||||
title: string;
|
||||
stage: VerificationIssueStage;
|
||||
previousStage?: VerificationIssueStage | null;
|
||||
eventType: VerificationIssueEventType;
|
||||
}
|
||||
|
||||
export function getVerificationDeviceId(): string {
|
||||
let id = localStorage.getItem('gc_device_id');
|
||||
if (!id) {
|
||||
id = crypto.randomUUID();
|
||||
localStorage.setItem('gc_device_id', id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function parseVerificationIssueEvent(raw: string): VerificationIssueEventDto | null {
|
||||
try {
|
||||
const data = JSON.parse(raw) as VerificationIssueEventDto;
|
||||
if (data.issueId == null || !data.eventType || !data.title) return null;
|
||||
if (data.eventType !== 'CREATED' && data.eventType !== 'STAGE_CHANGED') return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldShowVerificationIssueEvent(event: VerificationIssueEventDto): boolean {
|
||||
const localDevice = getVerificationDeviceId();
|
||||
if (event.sourceDeviceId && event.sourceDeviceId === localDevice) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function eventToToast(event: VerificationIssueEventDto): VerificationIssueToastItem {
|
||||
return {
|
||||
id: `${event.eventType}-${event.issueId}-${event.updatedAt ?? Date.now()}`,
|
||||
issueId: event.issueId,
|
||||
title: event.title,
|
||||
stage: event.stage,
|
||||
previousStage: event.previousStage,
|
||||
eventType: event.eventType,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user