검증게시판 기능 추가

This commit is contained in:
Macbook
2026-05-27 15:41:19 +09:00
parent 7a0af36b9b
commit 63693d47c0
57 changed files with 4480 additions and 14 deletions
+5 -7
View File
@@ -28,15 +28,13 @@ RUN mvn -f backend/pom.xml package -DskipTests -q
FROM eclipse-temurin:21-jre-alpine AS production
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
USER spring
RUN addgroup -S spring && adduser -S spring -G spring \
&& apk add --no-cache su-exec
COPY --from=builder /build/backend/target/goldenchart-backend-*.jar app.jar
COPY backend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"]
ENTRYPOINT ["/docker-entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
UPLOAD_DIR="${GC_VERIFICATION_UPLOAD_DIR:-/app/data/verification-images}"
mkdir -p "$UPLOAD_DIR"
chown -R spring:spring "$UPLOAD_DIR"
exec su-exec spring java \
-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-Djava.security.egd=file:/dev/./urandom \
-jar app.jar
@@ -7,7 +7,7 @@ public final class MenuIds {
private MenuIds() {}
public static final List<String> ALL = List.of(
"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_general", "settings_chart", "settings_indicators", "settings_backtest",
"settings_strategy", "settings_paper", "settings_trend-search",
"settings_alert", "settings_network", "settings_admin"
@@ -14,6 +14,7 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
*
* 구독 채널 (명세서 5.2):
* /sub/charts/{market}/{type} — 실시간 캔들 배포
* /sub/verification-issues/events — 검증 이슈 등록·단계 변경
*
* 발행 채널:
* /pub/... — 클라이언트 → 서버 (향후 확장용)
@@ -0,0 +1,60 @@
package com.goldenchart.controller;
import com.goldenchart.dto.VerificationIssueCommentDto;
import com.goldenchart.service.VerificationIssueCommentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/verification-issues/{issueId}/comments")
@RequiredArgsConstructor
public class VerificationIssueCommentController {
private final VerificationIssueCommentService service;
@GetMapping
public ResponseEntity<List<VerificationIssueCommentDto>> list(@PathVariable Long issueId) {
return ResponseEntity.ok(service.listByIssue(issueId));
}
@PostMapping
public ResponseEntity<VerificationIssueCommentDto> create(
@PathVariable Long issueId,
@RequestBody VerificationIssueCommentDto dto,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
try {
return ResponseEntity.ok(service.create(issueId, dto, parseUserId(userIdHeader), deviceId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PutMapping("/{commentId}")
public ResponseEntity<VerificationIssueCommentDto> update(
@PathVariable Long issueId,
@PathVariable Long commentId,
@RequestBody VerificationIssueCommentDto dto) {
return service.update(issueId, commentId, dto)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{commentId}")
public ResponseEntity<Void> delete(
@PathVariable Long issueId,
@PathVariable Long commentId) {
if (!service.delete(issueId, commentId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
}
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
}
}
@@ -0,0 +1,60 @@
package com.goldenchart.controller;
import com.goldenchart.dto.VerificationIssueDto;
import com.goldenchart.entity.VerificationIssueStage;
import com.goldenchart.service.VerificationIssueService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/verification-issues")
@RequiredArgsConstructor
public class VerificationIssueController {
private final VerificationIssueService service;
@GetMapping
public ResponseEntity<List<VerificationIssueDto>> list(
@RequestParam(value = "stage", required = false) String stage) {
VerificationIssueStage parsed = parseStage(stage);
return ResponseEntity.ok(service.list(parsed));
}
@GetMapping("/{id}")
public ResponseEntity<VerificationIssueDto> get(@PathVariable Long id) {
return service.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<VerificationIssueDto> save(
@RequestBody VerificationIssueDto dto,
@RequestHeader(value = "X-User-Id", required = false) String userIdHeader,
@RequestHeader(value = "X-Device-Id", required = false) String deviceId) {
return ResponseEntity.ok(service.save(dto, parseUserId(userIdHeader), deviceId));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
private Long parseUserId(String header) {
if (header == null || header.isBlank()) return null;
try { return Long.parseLong(header); } catch (NumberFormatException e) { return null; }
}
private VerificationIssueStage parseStage(String stage) {
if (stage == null || stage.isBlank()) return null;
try {
return VerificationIssueStage.valueOf(stage);
} catch (IllegalArgumentException e) {
return null;
}
}
}
@@ -0,0 +1,87 @@
package com.goldenchart.controller;
import com.goldenchart.dto.VerificationIssueImageDto;
import com.goldenchart.service.VerificationIssueImageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/verification-issues/{issueId}/images")
@RequiredArgsConstructor
@Slf4j
public class VerificationIssueImageController {
private final VerificationIssueImageService service;
@GetMapping
public ResponseEntity<List<VerificationIssueImageDto>> list(@PathVariable Long issueId) {
return ResponseEntity.ok(service.listByIssue(issueId));
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<?> upload(
@PathVariable Long issueId,
@RequestParam("files") List<MultipartFile> files) {
try {
return ResponseEntity.ok(service.upload(issueId, files));
} catch (IllegalArgumentException e) {
log.warn("[VerificationImage] upload rejected issueId={}: {}", issueId, e.getMessage());
return ResponseEntity.badRequest().body(Map.of("message", toUserMessage(e.getMessage())));
} catch (IOException e) {
log.error("[VerificationImage] upload io error issueId={}", issueId, e);
return ResponseEntity.internalServerError()
.body(Map.of("message", "이미지 저장에 실패했습니다. 잠시 후 다시 시도하세요."));
}
}
private static String toUserMessage(String code) {
if (code == null) return "업로드할 수 없습니다.";
return switch (code) {
case "unsupported image type" -> "지원하지 않는 이미지 형식입니다. (jpeg, png, gif, webp, bmp)";
case "file too large" -> "이미지 크기는 파일당 5MB 이하여야 합니다.";
case "no files", "no valid image files" -> "업로드할 이미지가 없습니다.";
default -> code.startsWith("max images exceeded")
? "이슈당 첨부 이미지는 최대 30장까지입니다."
: code.startsWith("issue not found") ? "검증 이슈를 찾을 수 없습니다." : code;
};
}
@GetMapping("/{imageId}/file")
public ResponseEntity<Resource> file(
@PathVariable Long issueId,
@PathVariable Long imageId) {
return service.loadFile(issueId, imageId)
.map(resource -> {
String type = service.contentType(issueId, imageId).orElse(MediaType.APPLICATION_OCTET_STREAM_VALUE);
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(type))
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
.body(resource);
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{imageId}")
public ResponseEntity<Void> delete(
@PathVariable Long issueId,
@PathVariable Long imageId) {
try {
if (!service.delete(issueId, imageId)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.noContent().build();
} catch (IOException e) {
return ResponseEntity.internalServerError().build();
}
}
}
@@ -0,0 +1,30 @@
package com.goldenchart.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import java.util.Map;
@RestControllerAdvice
@Slf4j
public class VerificationUploadExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Map<String, String>> handleMaxUpload(MaxUploadSizeExceededException e) {
log.warn("[VerificationUpload] size exceeded: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("message", "업로드 용량이 제한(30MB)을 초과했습니다."));
}
@ExceptionHandler(MultipartException.class)
public ResponseEntity<Map<String, String>> handleMultipart(MultipartException e) {
log.warn("[VerificationUpload] multipart error: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(Map.of("message", "이미지 파일 형식을 확인할 수 없습니다."));
}
}
@@ -0,0 +1,16 @@
package com.goldenchart.dto;
import lombok.*;
import java.time.LocalDateTime;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class VerificationIssueCommentDto {
private Long id;
private Long issueId;
private String content;
private String authorName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,18 @@
package com.goldenchart.dto;
import com.goldenchart.entity.VerificationIssueStage;
import lombok.*;
import java.time.LocalDateTime;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class VerificationIssueDto {
private Long id;
private String title;
private String content;
private String reproductionPath;
private VerificationIssueStage stage;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.goldenchart.dto;
import com.goldenchart.entity.VerificationIssueStage;
import lombok.*;
import java.time.LocalDateTime;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class VerificationIssueEventDto {
public static final String TYPE_CREATED = "CREATED";
public static final String TYPE_STAGE_CHANGED = "STAGE_CHANGED";
private String eventType;
private Long issueId;
private String title;
private VerificationIssueStage stage;
private VerificationIssueStage previousStage;
private LocalDateTime updatedAt;
/** 이벤트를 발생시킨 클라이언트 (동일 기기 알림 억제용) */
private String sourceDeviceId;
}
@@ -0,0 +1,18 @@
package com.goldenchart.dto;
import lombok.*;
import java.time.LocalDateTime;
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class VerificationIssueImageDto {
private Long id;
private Long issueId;
private String fileName;
private String contentType;
private Long fileSize;
private Integer sortOrder;
private String url;
private LocalDateTime createdAt;
}
@@ -137,6 +137,11 @@ public class GcAppSettings {
@Builder.Default
private Integer tradeAlertPopupGridCols = 2;
/** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */
@Column(name = "verification_issue_notify", nullable = false)
@Builder.Default
private Boolean verificationIssueNotify = true;
/** 실시간 전략 체크 마스터 ON/OFF — ON 이면 DB 관심종목 전체가 체크 대상 */
@Column(name = "live_strategy_check", nullable = false)
@Builder.Default
@@ -0,0 +1,53 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_verification_issue")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcVerificationIssue {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title", nullable = false, length = 300)
private String title;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "reproduction_path", length = 500)
private String reproductionPath;
@Enumerated(EnumType.STRING)
@Column(name = "stage", nullable = false, length = 32)
@Builder.Default
private VerificationIssueStage stage = VerificationIssueStage.REGISTERED;
@Column(name = "user_id")
private Long userId;
@Column(name = "device_id", length = 100)
private String deviceId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,48 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_verification_issue_comment")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcVerificationIssueComment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "issue_id", nullable = false)
private Long issueId;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "author_name", length = 100)
private String authorName;
@Column(name = "user_id")
private Long userId;
@Column(name = "device_id", length = 100)
private String deviceId;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,43 @@
package com.goldenchart.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "gc_verification_issue_image")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GcVerificationIssueImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "issue_id", nullable = false)
private Long issueId;
@Column(name = "file_name", nullable = false, length = 255)
private String fileName;
@Column(name = "content_type", nullable = false, length = 100)
private String contentType;
@Column(name = "storage_path", nullable = false, length = 500)
private String storagePath;
@Column(name = "file_size", nullable = false)
private Long fileSize;
@Column(name = "sort_order", nullable = false)
@Builder.Default
private Integer sortOrder = 0;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,10 @@
package com.goldenchart.entity;
/** 검증 이슈 처리 단계 */
public enum VerificationIssueStage {
REGISTERED,
IN_FIX,
FIX_COMPLETE,
RE_REGISTERED,
COMPLETE
}
@@ -0,0 +1,13 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcVerificationIssueComment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GcVerificationIssueCommentRepository extends JpaRepository<GcVerificationIssueComment, Long> {
List<GcVerificationIssueComment> findByIssueIdOrderByCreatedAtAsc(Long issueId);
void deleteByIssueId(Long issueId);
}
@@ -0,0 +1,13 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcVerificationIssueImage;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GcVerificationIssueImageRepository extends JpaRepository<GcVerificationIssueImage, Long> {
List<GcVerificationIssueImage> findByIssueIdOrderBySortOrderAscCreatedAtAsc(Long issueId);
void deleteByIssueId(Long issueId);
}
@@ -0,0 +1,14 @@
package com.goldenchart.repository;
import com.goldenchart.entity.GcVerificationIssue;
import com.goldenchart.entity.VerificationIssueStage;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GcVerificationIssueRepository extends JpaRepository<GcVerificationIssue, Long> {
List<GcVerificationIssue> findAllByOrderByUpdatedAtDesc();
List<GcVerificationIssue> findByStageOrderByUpdatedAtDesc(VerificationIssueStage stage);
}
@@ -114,6 +114,8 @@ public class AppSettingsService {
int cols = Integer.parseInt(d.get("tradeAlertPopupGridCols").toString());
s.setTradeAlertPopupGridCols(Math.min(4, Math.max(2, cols)));
}
if (d.containsKey("verificationIssueNotify")) s.setVerificationIssueNotify(
Boolean.parseBoolean(d.get("verificationIssueNotify").toString()));
if (d.containsKey("liveStrategyCheck")) s.setLiveStrategyCheck(
Boolean.parseBoolean(d.get("liveStrategyCheck").toString()));
if (d.containsKey("liveStrategyId")) {
@@ -196,6 +198,7 @@ public class AppSettingsService {
m.put("tradeAlertPopupPosition", s.getTradeAlertPopupPosition() != null ? s.getTradeAlertPopupPosition() : "right");
m.put("tradeAlertPopupLayout", s.getTradeAlertPopupLayout() != null ? s.getTradeAlertPopupLayout() : "stack");
m.put("tradeAlertPopupGridCols", s.getTradeAlertPopupGridCols() != null ? s.getTradeAlertPopupGridCols() : 2);
m.put("verificationIssueNotify", s.getVerificationIssueNotify() != null ? s.getVerificationIssueNotify() : true);
m.put("liveStrategyCheck", s.getLiveStrategyCheck() != null ? s.getLiveStrategyCheck() : false);
m.put("liveStrategyId", s.getLiveStrategyId());
m.put("liveExecutionType", s.getLiveExecutionType() != null ? s.getLiveExecutionType() : "CANDLE_CLOSE");
@@ -0,0 +1,98 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueCommentDto;
import com.goldenchart.entity.GcVerificationIssueComment;
import com.goldenchart.repository.GcVerificationIssueCommentRepository;
import com.goldenchart.repository.GcVerificationIssueRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class VerificationIssueCommentService {
private final GcVerificationIssueCommentRepository commentRepository;
private final GcVerificationIssueRepository issueRepository;
public List<VerificationIssueCommentDto> listByIssue(Long issueId) {
return commentRepository.findByIssueIdOrderByCreatedAtAsc(issueId).stream()
.map(this::toDto)
.toList();
}
@Transactional
public VerificationIssueCommentDto create(
Long issueId,
VerificationIssueCommentDto dto,
Long userId,
String deviceId) {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("issue not found: " + issueId);
}
GcVerificationIssueComment entity = GcVerificationIssueComment.builder()
.issueId(issueId)
.content(requireContent(dto.getContent()))
.authorName(trimAuthor(dto.getAuthorName()))
.userId(userId)
.deviceId(deviceId)
.build();
return toDto(commentRepository.save(entity));
}
@Transactional
public Optional<VerificationIssueCommentDto> update(Long issueId, Long commentId, VerificationIssueCommentDto dto) {
return commentRepository.findById(commentId)
.filter(c -> issueId.equals(c.getIssueId()))
.map(c -> {
c.setContent(requireContent(dto.getContent()));
if (dto.getAuthorName() != null) {
c.setAuthorName(trimAuthor(dto.getAuthorName()));
}
return toDto(commentRepository.save(c));
});
}
@Transactional
public boolean delete(Long issueId, Long commentId) {
return commentRepository.findById(commentId)
.filter(c -> issueId.equals(c.getIssueId()))
.map(c -> {
commentRepository.delete(c);
return true;
})
.orElse(false);
}
private VerificationIssueCommentDto toDto(GcVerificationIssueComment e) {
return VerificationIssueCommentDto.builder()
.id(e.getId())
.issueId(e.getIssueId())
.content(e.getContent())
.authorName(e.getAuthorName())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
private String trimContent(String content) {
if (content == null) return "";
return content.trim();
}
private String requireContent(String content) {
String trimmed = trimContent(content);
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("comment content required");
}
return trimmed;
}
private String trimAuthor(String name) {
if (name == null || name.isBlank()) return null;
return name.trim();
}
}
@@ -0,0 +1,250 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueImageDto;
import com.goldenchart.entity.GcVerificationIssueImage;
import com.goldenchart.repository.GcVerificationIssueImageRepository;
import com.goldenchart.repository.GcVerificationIssueRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Service
@Slf4j
public class VerificationIssueImageService {
private static final Set<String> ALLOWED_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp"
);
private final GcVerificationIssueImageRepository imageRepository;
private final GcVerificationIssueRepository issueRepository;
private final Path uploadRoot;
private final long maxFileSizeBytes;
private final int maxImagesPerIssue;
public VerificationIssueImageService(
GcVerificationIssueImageRepository imageRepository,
GcVerificationIssueRepository issueRepository,
@Value("${goldenchart.verification.upload-dir:data/verification-images}") String uploadDir,
@Value("${goldenchart.verification.max-image-size-mb:5}") int maxImageSizeMb,
@Value("${goldenchart.verification.max-images-per-issue:30}") int maxImagesPerIssue) throws IOException {
this.imageRepository = imageRepository;
this.issueRepository = issueRepository;
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
this.maxFileSizeBytes = maxImageSizeMb * 1024L * 1024L;
this.maxImagesPerIssue = maxImagesPerIssue;
Files.createDirectories(this.uploadRoot);
}
public List<VerificationIssueImageDto> listByIssue(Long issueId) {
return imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).stream()
.map(this::toDto)
.toList();
}
@Transactional
public List<VerificationIssueImageDto> upload(Long issueId, List<MultipartFile> files) throws IOException {
if (!issueRepository.existsById(issueId)) {
throw new IllegalArgumentException("issue not found: " + issueId);
}
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("no files");
}
long current = imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId).size();
if (current + files.size() > maxImagesPerIssue) {
throw new IllegalArgumentException("max images exceeded: " + maxImagesPerIssue);
}
Path issueDir = uploadRoot.resolve("issue_" + issueId);
Files.createDirectories(issueDir);
int sort = (int) current;
List<VerificationIssueImageDto> saved = new java.util.ArrayList<>();
for (MultipartFile file : files) {
if (file == null || file.isEmpty()) continue;
byte[] data = file.getBytes();
String contentType = resolveContentType(file, data);
validateFile(data.length, contentType);
String original = sanitizeFileName(file.getOriginalFilename());
String storedName = UUID.randomUUID() + "_" + original;
Path target = issueDir.resolve(storedName);
Files.write(target, data);
String relative = uploadRoot.relativize(target).toString().replace('\\', '/');
GcVerificationIssueImage entity = GcVerificationIssueImage.builder()
.issueId(issueId)
.fileName(original)
.contentType(contentType)
.storagePath(relative)
.fileSize((long) data.length)
.sortOrder(sort++)
.build();
saved.add(toDto(imageRepository.save(entity)));
}
if (saved.isEmpty()) {
throw new IllegalArgumentException("no valid image files");
}
return saved;
}
public Optional<Resource> loadFile(Long issueId, Long imageId) {
return imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()))
.map(img -> {
try {
Path path = uploadRoot.resolve(img.getStoragePath()).normalize();
if (!path.startsWith(uploadRoot) || !Files.exists(path)) {
return null;
}
Resource resource = new UrlResource(path.toUri());
return resource.exists() && resource.isReadable() ? resource : null;
} catch (Exception e) {
log.warn("image load failed id={}: {}", imageId, e.getMessage());
return null;
}
});
}
public Optional<String> contentType(Long issueId, Long imageId) {
return imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()))
.map(GcVerificationIssueImage::getContentType);
}
@Transactional
public boolean delete(Long issueId, Long imageId) throws IOException {
Optional<GcVerificationIssueImage> opt = imageRepository.findById(imageId)
.filter(img -> issueId.equals(img.getIssueId()));
if (opt.isEmpty()) return false;
GcVerificationIssueImage img = opt.get();
deletePhysicalFile(img.getStoragePath());
imageRepository.delete(img);
return true;
}
@Transactional
public void deleteAllForIssue(Long issueId) throws IOException {
List<GcVerificationIssueImage> images =
imageRepository.findByIssueIdOrderBySortOrderAscCreatedAtAsc(issueId);
for (GcVerificationIssueImage img : images) {
deletePhysicalFile(img.getStoragePath());
}
imageRepository.deleteByIssueId(issueId);
Path issueDir = uploadRoot.resolve("issue_" + issueId);
if (Files.isDirectory(issueDir)) {
try { Files.deleteIfExists(issueDir); } catch (IOException ignored) { /* ok */ }
}
}
private void validateFile(long sizeBytes, String contentType) {
if (contentType == null) {
throw new IllegalArgumentException("unsupported image type");
}
if (sizeBytes > maxFileSizeBytes) {
throw new IllegalArgumentException("file too large");
}
}
private String resolveContentType(MultipartFile file, byte[] data) {
String type = file.getContentType();
if (type != null && !type.isBlank()) {
type = type.split(";")[0].trim().toLowerCase();
if ("image/jpg".equals(type) || "image/pjpeg".equals(type)) {
return "image/jpeg";
}
if ("image/x-png".equals(type)) {
return "image/png";
}
if (ALLOWED_TYPES.contains(type)) {
return type;
}
// application/octet-stream 등 — 매직 바이트로 판별
if ("application/octet-stream".equals(type) || type.startsWith("image/")) {
String sniffed = sniffImageType(data);
if (sniffed != null) return sniffed;
}
}
String name = file.getOriginalFilename();
if (name != null) {
String 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 sniffImageType(data);
}
private String sniffImageType(byte[] header) {
if (header == null) return null;
if (header.length >= 8
&& header[0] == (byte) 0x89 && header[1] == 0x50
&& header[2] == 0x4E && header[3] == 0x47) {
return "image/png";
}
if (header.length >= 3
&& header[0] == (byte) 0xFF && header[1] == (byte) 0xD8 && header[2] == (byte) 0xFF) {
return "image/jpeg";
}
if (header.length >= 6
&& header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46) {
return "image/gif";
}
if (header.length >= 12
&& header[0] == 0x52 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x46
&& header[8] == 0x57 && header[9] == 0x45 && header[10] == 0x42 && header[11] == 0x50) {
return "image/webp";
}
if (header.length >= 2 && header[0] == 0x42 && header[1] == 0x4D) {
return "image/bmp";
}
return null;
}
private void deletePhysicalFile(String storagePath) throws IOException {
Path path = uploadRoot.resolve(storagePath).normalize();
if (path.startsWith(uploadRoot)) {
Files.deleteIfExists(path);
}
}
private String sanitizeFileName(String name) {
if (name == null || name.isBlank()) return "image.png";
String base = Paths.get(name).getFileName().toString();
return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_");
}
private VerificationIssueImageDto toDto(GcVerificationIssueImage e) {
return VerificationIssueImageDto.builder()
.id(e.getId())
.issueId(e.getIssueId())
.fileName(e.getFileName())
.contentType(e.getContentType())
.fileSize(e.getFileSize())
.sortOrder(e.getSortOrder())
.url("/verification-issues/" + e.getIssueId() + "/images/" + e.getId() + "/file")
.createdAt(e.getCreatedAt())
.build();
}
}
@@ -0,0 +1,93 @@
package com.goldenchart.service;
import com.goldenchart.dto.VerificationIssueDto;
import com.goldenchart.entity.GcVerificationIssue;
import com.goldenchart.entity.VerificationIssueStage;
import com.goldenchart.repository.GcVerificationIssueRepository;
import com.goldenchart.websocket.VerificationIssueEventBroker;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class VerificationIssueService {
private final GcVerificationIssueRepository repository;
private final VerificationIssueImageService imageService;
private final VerificationIssueEventBroker eventBroker;
public List<VerificationIssueDto> list(VerificationIssueStage stage) {
List<GcVerificationIssue> entities = stage != null
? repository.findByStageOrderByUpdatedAtDesc(stage)
: repository.findAllByOrderByUpdatedAtDesc();
return entities.stream().map(this::toDto).toList();
}
public Optional<VerificationIssueDto> findById(Long id) {
return repository.findById(id).map(this::toDto);
}
@Transactional
public VerificationIssueDto save(VerificationIssueDto dto, Long userId, String deviceId) {
GcVerificationIssue entity;
boolean isNew = dto.getId() == null;
VerificationIssueStage previousStage = null;
if (dto.getId() != null) {
entity = repository.findById(dto.getId()).orElseGet(GcVerificationIssue::new);
if (entity.getId() != null) {
previousStage = entity.getStage();
}
} else {
entity = new GcVerificationIssue();
entity.setUserId(userId);
entity.setDeviceId(deviceId);
}
entity.setTitle(dto.getTitle() != null ? dto.getTitle().trim() : "제목 없음");
entity.setContent(dto.getContent() != null ? dto.getContent() : "");
String path = dto.getReproductionPath();
entity.setReproductionPath(path != null && !path.isBlank() ? path.trim() : null);
entity.setStage(dto.getStage() != null ? dto.getStage() : VerificationIssueStage.REGISTERED);
GcVerificationIssue saved = repository.save(entity);
VerificationIssueDto result = toDto(saved);
if (isNew) {
eventBroker.publishCreated(result, deviceId);
} else if (previousStage != null && previousStage != saved.getStage()) {
eventBroker.publishStageChanged(result, previousStage, deviceId);
}
return result;
}
@Transactional
public void delete(Long id) {
try {
imageService.deleteAllForIssue(id);
} catch (IOException e) {
log.warn("issue image cleanup failed id={}: {}", id, e.getMessage());
}
repository.deleteById(id);
}
private VerificationIssueDto toDto(GcVerificationIssue e) {
return VerificationIssueDto.builder()
.id(e.getId())
.title(e.getTitle())
.content(e.getContent())
.reproductionPath(e.getReproductionPath())
.stage(e.getStage())
.createdAt(e.getCreatedAt())
.updatedAt(e.getUpdatedAt())
.build();
}
}
@@ -0,0 +1,56 @@
package com.goldenchart.websocket;
import com.goldenchart.dto.VerificationIssueEventDto;
import com.goldenchart.dto.VerificationIssueDto;
import com.goldenchart.entity.VerificationIssueStage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class VerificationIssueEventBroker {
public static final String TOPIC = "/sub/verification-issues/events";
private final SimpMessagingTemplate messagingTemplate;
public void publishCreated(VerificationIssueDto issue, String sourceDeviceId) {
publish(VerificationIssueEventDto.builder()
.eventType(VerificationIssueEventDto.TYPE_CREATED)
.issueId(issue.getId())
.title(issue.getTitle())
.stage(issue.getStage())
.updatedAt(issue.getUpdatedAt())
.sourceDeviceId(sourceDeviceId)
.build());
}
public void publishStageChanged(
VerificationIssueDto issue,
VerificationIssueStage previousStage,
String sourceDeviceId) {
publish(VerificationIssueEventDto.builder()
.eventType(VerificationIssueEventDto.TYPE_STAGE_CHANGED)
.issueId(issue.getId())
.title(issue.getTitle())
.stage(issue.getStage())
.previousStage(previousStage)
.updatedAt(issue.getUpdatedAt())
.sourceDeviceId(sourceDeviceId)
.build());
}
private void publish(VerificationIssueEventDto event) {
try {
messagingTemplate.convertAndSend(TOPIC, event);
if (log.isDebugEnabled()) {
log.debug("[VerificationIssueEvent] {} issueId={}", event.getEventType(), event.getIssueId());
}
} catch (Exception e) {
log.warn("[VerificationIssueEvent] publish failed: {}", e.getMessage());
}
}
}
@@ -2,6 +2,11 @@ spring:
application:
name: goldenchart-backend
servlet:
multipart:
max-file-size: 6MB
max-request-size: 30MB
profiles:
active: ${SPRING_PROFILES_ACTIVE:local}
@@ -95,6 +100,10 @@ goldenchart:
enabled: true
tick-ms: 1000
queue-capacity: 16
verification:
upload-dir: ${GC_VERIFICATION_UPLOAD_DIR:data/verification-images}
max-image-size-mb: 5
max-images-per-issue: 30
cors:
allowed-origins:
- http://localhost:5173
@@ -0,0 +1,13 @@
-- 검증게시판 이슈
CREATE TABLE gc_verification_issue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
stage VARCHAR(32) NOT NULL DEFAULT 'REGISTERED',
user_id BIGINT NULL,
device_id VARCHAR(100) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_verification_issue_stage (stage),
INDEX idx_verification_issue_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,6 @@
-- 검증게시판 메뉴 권한
INSERT INTO gc_role_menu_permission (role, menu_id, allowed) VALUES
('ADMIN', 'verification-board', 1),
('USER', 'verification-board', 1),
('GUEST', 'verification-board', 0)
ON DUPLICATE KEY UPDATE allowed = VALUES(allowed);
@@ -0,0 +1,14 @@
-- 검증 이슈 댓글
CREATE TABLE gc_verification_issue_comment (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
issue_id BIGINT NOT NULL,
content TEXT NOT NULL,
author_name VARCHAR(100) NULL,
user_id BIGINT NULL,
device_id VARCHAR(100) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_vic_issue_id (issue_id),
CONSTRAINT fk_vic_issue FOREIGN KEY (issue_id)
REFERENCES gc_verification_issue (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,14 @@
-- 검증 이슈 첨부 이미지
CREATE TABLE gc_verification_issue_image (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
issue_id BIGINT NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
storage_path VARCHAR(500) NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_vii_issue_id (issue_id),
CONSTRAINT fk_vii_issue FOREIGN KEY (issue_id)
REFERENCES gc_verification_issue (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,2 @@
ALTER TABLE gc_verification_issue
ADD COLUMN reproduction_path VARCHAR(500) NULL AFTER content;
@@ -0,0 +1,4 @@
-- 검증 이슈 등록·단계 변경 알림 팝업 ON/OFF
ALTER TABLE gc_app_settings
ADD COLUMN verification_issue_notify TINYINT(1) NOT NULL DEFAULT 1
COMMENT '검증 이슈 등록·단계 변경 알림 팝업';
+6 -2
View File
@@ -12,6 +12,8 @@ networks:
volumes:
mysql-data:
driver: local
verification-images:
driver: local
services:
@@ -50,7 +52,7 @@ services:
# ── Spring Boot Backend ──────────────────────────────────────────────────
backend:
build:
context: . # 루트를 컨텍스트로 (ta4j-master 포함)
context: .
dockerfile: backend/Dockerfile
container_name: gc-backend
restart: unless-stopped
@@ -62,10 +64,12 @@ services:
DB_URL: "jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-stockAnalyzer}?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8&allowPublicKeyRetrieval=true&createDatabaseIfNotExist=true"
DB_USERNAME: ${MYSQL_USER:-stock}
DB_PASSWORD: ${MYSQL_PASSWORD:-analyzer}
# 업비트 API 키 DB 암호화 (운영 환경에서 반드시 변경)
GC_SECRETS_ENCRYPTION_KEY: ${GC_SECRETS_ENCRYPTION_KEY:-goldenchart-local-dev-key-change-me}
GC_VERIFICATION_UPLOAD_DIR: /app/data/verification-images
ports:
- "${BACKEND_PORT:-8080}:8080"
volumes:
- verification-images:/app/data/verification-images
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/api/indicators/list || exit 1"]
interval: 15s
+3
View File
@@ -10,6 +10,9 @@ server {
root /usr/share/nginx/html;
index index.html;
# multipart 이미지 업로드 (Spring max-request-size 30MB와 맞춤)
client_max_body_size 32m;
# 외부 호스트명 DNS 조회용 resolver (Upbit + Backend 프록시에 필요)
resolver 127.0.0.11 8.8.8.8 8.8.4.4 valid=30s;
resolver_timeout 5s;
+22
View File
@@ -67,6 +67,7 @@ import BacktestPanel from './components/BacktestPanel';
import { useBacktest } from './hooks/useBacktest';
import LiveStrategyPanel from './components/LiveStrategyPanel';
import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
import { LiveSignalNotifier, type LiveSignalNotifierHandle } from './components/LiveSignalNotifier';
import { NotifyTopMenuBar, ChartToolbarNotify } from './components/NotifyUiBindings';
import { AppNotificationLayer } from './components/AppNotificationLayer';
@@ -78,6 +79,7 @@ import SettingsPage from './components/SettingsPage';
import PaperTradingPage from './components/PaperTradingPage';
import VirtualTradingPage from './components/VirtualTradingPage';
import TrendSearchPage from './components/TrendSearchPage';
import VerificationBoardPage from './components/VerificationBoardPage';
import DashboardPage from './components/DashboardPage';
import { loadPaperSummary, resetPaperAccount, loadActiveLiveStrategySettings, expandLiveStrategySubscriptions } from './utils/backendApi';
import ChartLegendBar from './components/ChartLegendBar';
@@ -203,6 +205,7 @@ function App() {
const [mobileRightOpen, setMobileRightOpen] = useState(false);
const [mobileRightTab, setMobileRightTab] = useState<'trade' | 'orderbook'>('trade');
const [menuPage, setMenuPage] = useState<MenuPage>('chart');
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
const chartVisible = menuPage === 'chart';
const isMobile = useIsMobile();
@@ -1598,6 +1601,10 @@ function App() {
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
soundId={appDefaults.tradeAlertSound ?? 'bell'}
>
<VerificationIssueNotificationProvider
enabled={canMenu('verification-board')}
notifyEnabled={appDefaults.verificationIssueNotify}
>
<div className={`app ${theme} mode-${mode}${isMobile ? ' app--mobile' : ''}`}>
<LiveSignalNotifier
ref={liveNotifierRef}
@@ -1700,6 +1707,14 @@ function App() {
/>
)}
{menuPage === 'verification-board' && (
<VerificationBoardPage
theme={theme}
focusIssueId={verificationFocusIssueId}
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
/>
)}
{/* ── 설정 화면 ──────────────────────────────────────────────────── */}
{menuPage === 'notifications' && (
<TradeNotificationListPage
@@ -1818,6 +1833,8 @@ function App() {
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
displayTimezone={displayTimezone}
onDisplayTimezoneChange={handleTimezoneChange}
verificationIssueNotify={appDefaults.verificationIssueNotify}
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
onFcmTest={async () => {
const { sendFcmTest } = await import('./utils/backendApi');
const r = await sendFcmTest();
@@ -2563,6 +2580,10 @@ function App() {
menuPage={menuPage}
onPage={guardedSetMenuPage}
onGoToChart={goToMarketChart}
onGoToVerificationIssue={issueId => {
setVerificationFocusIssueId(issueId);
guardedSetMenuPage('verification-board');
}}
tradingMode={appDefaults.tradingMode}
hasUpbitKeys={appDefaults.hasUpbitKeys}
paperTradingEnabled={paperTradingEnabled}
@@ -2578,6 +2599,7 @@ function App() {
/>
</div>
</VerificationIssueNotificationProvider>
</TradeNotificationProvider>
);
}
@@ -5,6 +5,7 @@ import React from 'react';
import { createPortal } from 'react-dom';
import TradeSignalSnackbar from './TradeSignalSnackbar';
import TradeAlertModal from './TradeAlertModal';
import VerificationIssueToastStack from './verificationBoard/VerificationIssueToastStack';
import { useTradeNotification } from '../contexts/TradeNotificationContext';
import {
normalizeTradeAlertGridCols,
@@ -12,11 +13,13 @@ import {
normalizeTradeAlertPopupPosition,
} from '../utils/tradeAlertPopupLayout';
import type { MenuPage } from './TopMenuBar';
import '../styles/verificationBoard.css';
interface Props {
menuPage: MenuPage;
onPage: (page: MenuPage) => void;
onGoToChart: (market: string) => void;
onGoToVerificationIssue?: (issueId: number) => void;
tradingMode?: string;
hasUpbitKeys?: boolean;
paperTradingEnabled?: boolean;
@@ -32,6 +35,7 @@ export const AppNotificationLayer: React.FC<Props> = ({
menuPage,
onPage,
onGoToChart,
onGoToVerificationIssue,
tradingMode,
hasUpbitKeys,
paperTradingEnabled,
@@ -73,6 +77,9 @@ export const AppNotificationLayer: React.FC<Props> = ({
onOrderDone={onOrderDone}
/>
)}
{onGoToVerificationIssue && (
<VerificationIssueToastStack onGoToIssue={onGoToVerificationIssue} />
)}
</div>
);
+35 -1
View File
@@ -142,6 +142,8 @@ interface SettingsPageProps {
displayTimezone?: string;
onDisplayTimezoneChange?: (tz: string) => void;
menuPermissions?: Record<string, boolean>;
verificationIssueNotify?: boolean;
onVerificationIssueNotify?: (v: boolean) => void;
trendSearchSettings?: TrendSearchAppSettings;
onTrendSearchSettingsChange?: (s: TrendSearchAppSettings) => void;
}
@@ -534,7 +536,16 @@ const PaperPanel: React.FC<PaperPanelProps> = ({
const GeneralPanel: React.FC<{
displayTimezone: string;
onDisplayTimezoneChange: (tz: string) => void;
}> = ({ displayTimezone, onDisplayTimezoneChange }) => {
verificationIssueNotify?: boolean;
onVerificationIssueNotify?: (v: boolean) => void;
showVerificationNotify?: boolean;
}> = ({
displayTimezone,
onDisplayTimezoneChange,
verificationIssueNotify = true,
onVerificationIssueNotify,
showVerificationNotify = true,
}) => {
const [lang, setLang] = useState('ko');
const [dateFormat, setDateFmt] = useState('YYYY-MM-DD');
const [startPage, setStart] = useState('chart');
@@ -567,6 +578,24 @@ const GeneralPanel: React.FC<{
</SettingRow>
</SettingSection>
{showVerificationNotify && (
<SettingSection title="검증게시판">
<SettingRow
label="검증 이슈 등록 알림"
desc="검증 이슈가 등록되거나 단계가 변경될 때 팝업 알림을 표시합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={verificationIssueNotify}
onChange={e => onVerificationIssueNotify?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
)}
<SettingSection title="시작 화면">
<SettingRow label="시작 페이지" desc="앱 시작 시 표시될 기본 화면입니다.">
<select className="stg-select" value={startPage} onChange={e => setStart(e.target.value)}>
@@ -1458,6 +1487,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
displayTimezone = 'Asia/Seoul',
onDisplayTimezoneChange,
menuPermissions,
verificationIssueNotify = true,
onVerificationIssueNotify,
trendSearchSettings,
onTrendSearchSettingsChange,
}) => {
@@ -1540,6 +1571,9 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
<GeneralPanel
displayTimezone={displayTimezone}
onDisplayTimezoneChange={onDisplayTimezoneChange ?? (() => {})}
verificationIssueNotify={verificationIssueNotify}
onVerificationIssueNotify={onVerificationIssueNotify}
showVerificationNotify={menuPermissions == null || menuPermissions['verification-board'] === true}
/>
);
case 'admin': return (
+10 -1
View File
@@ -11,7 +11,7 @@ import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
import { canAccessMenu } from '../utils/permissions';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'virtual' | 'trend-search' | 'verification-board' | 'strategy' | 'strategy-editor' | 'backtest' | 'notifications' | 'settings';
interface TopMenuBarProps {
activePage: MenuPage;
@@ -137,6 +137,14 @@ const IcTrendSearch = () => (
</svg>
);
const IcVerificationBoard = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="1.5" width="12" height="13" rx="1.5"/>
<path d="M5 5h6M5 8h6M5 11h4"/>
<path d="M11.5 10.5l1 1 2-2" strokeWidth="1.3"/>
</svg>
);
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
@@ -145,6 +153,7 @@ const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
{ page: 'strategy-editor', label: '전략편집기', icon: <IcStrategyEditor /> },
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
{ page: 'settings', label: '설정', icon: <IcSettings /> },
{ page: 'verification-board', label: '검증게시판', icon: <IcVerificationBoard /> },
];
export const TopMenuBar = memo(function TopMenuBar({
@@ -0,0 +1,250 @@
/**
* 검증게시판 — 좌 목록 + 우 상세
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import BuilderPageShell from './layout/BuilderPageShell';
import VerificationIssueListPanel from './verificationBoard/VerificationIssueListPanel';
import VerificationIssueDetailPanel from './verificationBoard/VerificationIssueDetailPanel';
import VerificationIssueFormModal from './verificationBoard/VerificationIssueFormModal';
import type { Theme } from '../types';
import type { VerificationIssueStage } from '../utils/verificationBoardStages';
import {
deleteVerificationIssue,
deleteVerificationIssueImage,
loadVerificationIssues,
saveVerificationIssue,
uploadVerificationIssueImages,
type VerificationIssueDto,
} from '../utils/verificationBoardApi';
import { prepareImagesForUpload } from '../utils/clipboardImageFiles';
import '../styles/verificationBoard.css';
interface Props {
theme?: Theme;
focusIssueId?: number | null;
onFocusIssueConsumed?: () => void;
}
const IconAdd = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M9 3v12M3 9h12" />
</svg>
);
const IconEdit = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.5 2.5l3 3L6 15H3v-3L12.5 2.5z" />
</svg>
);
const IconDelete = () => (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 5h12M7 5V3h4v2M6 5l.5 10h5L12 5" />
</svg>
);
const VerificationBoardPage: React.FC<Props> = ({
theme = 'dark',
focusIssueId = null,
onFocusIssueConsumed,
}) => {
const [issues, setIssues] = useState<VerificationIssueDto[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [stageFilter, setStageFilter] = useState<VerificationIssueStage | ''>('');
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editTarget, setEditTarget] = useState<VerificationIssueDto | null>(null);
const [saving, setSaving] = useState(false);
const [detailSaving, setDetailSaving] = useState(false);
const [attachmentsRefreshKey, setAttachmentsRefreshKey] = useState(0);
const reload = useCallback(async (stage?: VerificationIssueStage | '') => {
setLoading(true);
try {
const list = await loadVerificationIssues(stage ?? stageFilter);
setIssues(list);
setSelectedId(prev => {
if (prev != null && list.some(i => i.id === prev)) return prev;
return list[0]?.id ?? null;
});
} catch (e) {
console.warn('[VerificationBoard] load', e);
window.alert('검증 이슈 목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [stageFilter]);
useEffect(() => {
void reload(stageFilter);
}, [stageFilter]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (focusIssueId == null) return;
setSelectedId(focusIssueId);
setStageFilter('');
onFocusIssueConsumed?.();
}, [focusIssueId, onFocusIssueConsumed]);
const selected = useMemo(
() => issues.find(i => i.id === selectedId) ?? null,
[issues, selectedId],
);
const handleSelect = useCallback((issue: VerificationIssueDto) => {
if (issue.id != null) setSelectedId(issue.id);
}, []);
const handleAdd = useCallback(() => {
setEditTarget(null);
setModalOpen(true);
}, []);
const handleEdit = useCallback(() => {
if (!selected) {
window.alert('수정할 이슈를 선택하세요.');
return;
}
setEditTarget(selected);
setModalOpen(true);
}, [selected]);
const handleDelete = useCallback(async () => {
if (!selected?.id) {
window.alert('삭제할 이슈를 선택하세요.');
return;
}
if (!window.confirm(`${selected.title}」 이슈를 삭제할까요?`)) return;
try {
await deleteVerificationIssue(selected.id);
await reload(stageFilter);
} catch (e) {
console.warn('[VerificationBoard] delete', e);
window.alert('삭제에 실패했습니다.');
}
}, [selected, reload, stageFilter]);
const handleSave = useCallback(async (dto: VerificationIssueDto, pendingImages: File[]) => {
setSaving(true);
try {
const saved = await saveVerificationIssue(dto);
if (pendingImages.length > 0 && saved.id != null) {
await uploadVerificationIssueImages(saved.id, pendingImages);
}
setModalOpen(false);
setEditTarget(null);
setAttachmentsRefreshKey(k => k + 1);
await reload(stageFilter);
if (saved.id != null) setSelectedId(saved.id);
} catch (e) {
console.warn('[VerificationBoard] save', e);
window.alert('저장에 실패했습니다.');
} finally {
setSaving(false);
}
}, [reload, stageFilter]);
const handleDetailSave = useCallback(async (
dto: VerificationIssueDto,
pendingImages: File[],
pendingDeleteIds: number[],
) => {
if (dto.id == null) return;
setDetailSaving(true);
try {
const saved = await saveVerificationIssue(dto);
const issueId = saved.id ?? dto.id;
for (const imageId of pendingDeleteIds) {
await deleteVerificationIssueImage(issueId, imageId);
}
if (pendingImages.length > 0) {
const prepared = await prepareImagesForUpload(pendingImages);
await uploadVerificationIssueImages(issueId, prepared);
}
setAttachmentsRefreshKey(k => k + 1);
await reload(stageFilter);
if (issueId != null) setSelectedId(issueId);
} catch (e) {
console.warn('[VerificationBoard] detail save', e);
window.alert('저장에 실패했습니다.');
} finally {
setDetailSaving(false);
}
}, [reload, stageFilter]);
return (
<>
<BuilderPageShell
theme={theme}
title="검증게시판"
subtitle="Golden QA · Verification Board"
pageClassName="bps-page--vbd"
loading={loading}
loadingText="검증 이슈 로딩 중…"
headerActions={(
<div className="vbd-header-actions">
<button type="button" className="vbd-icon-btn" onClick={handleAdd} title="이슈 추가">
<IconAdd />
<span className="vbd-icon-btn-label"></span>
</button>
<button
type="button"
className="vbd-icon-btn"
onClick={handleEdit}
disabled={!selected}
title="이슈 수정"
>
<IconEdit />
<span className="vbd-icon-btn-label"></span>
</button>
<button
type="button"
className="vbd-icon-btn vbd-icon-btn--danger"
onClick={() => void handleDelete()}
disabled={!selected}
title="이슈 삭제"
>
<IconDelete />
<span className="vbd-icon-btn-label"></span>
</button>
</div>
)}
leftStorageKey="vbd-left-width"
leftDefaultWidth={320}
leftCollapsedStorageKey="vbd-left-open"
collapsiblePanels
leftTitle="검증 이슈"
left={(
<VerificationIssueListPanel
issues={issues}
selectedId={selectedId}
stageFilter={stageFilter}
searchQuery={searchQuery}
onStageFilterChange={setStageFilter}
onSearchChange={setSearchQuery}
onSelect={handleSelect}
/>
)}
center={(
<VerificationIssueDetailPanel
issue={selected}
attachmentsRefreshKey={attachmentsRefreshKey}
saving={detailSaving}
onSave={handleDetailSave}
/>
)}
/>
<VerificationIssueFormModal
open={modalOpen}
initial={editTarget}
saving={saving}
onSave={(dto, pendingImages) => void handleSave(dto, pendingImages)}
onCancel={() => { setModalOpen(false); setEditTarget(null); }}
/>
</>
);
};
export default VerificationBoardPage;
@@ -0,0 +1,115 @@
/**
* 재현경로 입력 — 직접 입력 + 관련 메뉴 경로 자동완성
*/
import React, { useEffect, useId, useRef, useState } from 'react';
import { searchAppNavigationPaths } from '../../utils/appNavigationPaths';
interface Props {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
const ReproductionPathInput: React.FC<Props> = ({
value,
onChange,
disabled = false,
placeholder = '예: 메뉴-실시간 차트-지표 추가 버튼',
}) => {
const listId = useId();
const wrapRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [activeIdx, setActiveIdx] = useState(-1);
const suggestions = searchAppNavigationPaths(value);
useEffect(() => {
setActiveIdx(-1);
}, [value, suggestions.length]);
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, []);
const pick = (path: string) => {
onChange(path);
setOpen(false);
setActiveIdx(-1);
};
const showList = open && !disabled && suggestions.length > 0;
return (
<div className="vbd-path-wrap" ref={wrapRef}>
<input
type="text"
className="vbd-path-input"
value={value}
disabled={disabled}
placeholder={placeholder}
autoComplete="off"
aria-autocomplete="list"
aria-controls={showList ? listId : undefined}
aria-expanded={showList}
onChange={e => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={e => {
if (!showList) {
if (e.key === 'ArrowDown' && suggestions.length > 0) {
setOpen(true);
setActiveIdx(0);
e.preventDefault();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIdx(i => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
pick(suggestions[activeIdx]!.path);
} else if (e.key === 'Escape') {
setOpen(false);
setActiveIdx(-1);
}
}}
/>
{showList && (
<ul id={listId} className="vbd-path-suggest" role="listbox">
{suggestions.map((s, i) => (
<li key={s.path} role="presentation">
<button
type="button"
role="option"
aria-selected={i === activeIdx}
className={`vbd-path-suggest-item${i === activeIdx ? ' vbd-path-suggest-item--active' : ''}`}
onMouseDown={e => e.preventDefault()}
onClick={() => pick(s.path)}
>
{s.path}
</button>
</li>
))}
</ul>
)}
{open && value.trim() && suggestions.length === 0 && (
<p className="vbd-path-hint"> . .</p>
)}
</div>
);
};
export default ReproductionPathInput;
@@ -0,0 +1,394 @@
/**
* 검증 이슈 첨부 이미지 — 파일 선택·드래그·클립보드 붙여넣기
* issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
deleteVerificationIssueImage,
loadVerificationIssueImages,
uploadVerificationIssueImages,
verificationIssueImageUrl,
type VerificationIssueImageDto,
} from '../../utils/verificationBoardApi';
import {
filesFromClipboardRead,
filesFromDataTransfer,
pickImageFiles,
prepareImagesForUpload,
} from '../../utils/clipboardImageFiles';
function uploadErrorMessage(e: unknown): string {
if (e instanceof Error) {
if (e.message === 'IMAGE_TOO_LARGE') {
return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.';
}
if (e.message === 'IMAGE_READ_FAILED') {
return '이미지를 읽을 수 없습니다.';
}
if (e.message) return e.message;
}
return '이미지 업로드에 실패했습니다.';
}
interface Props {
/** 저장된 이슈 ID — 수정·상세 화면 */
issueId?: number | null;
/** 등록 draft — 저장 전 로컬 파일 */
draftFiles?: File[];
onDraftFilesChange?: (files: File[]) => void;
/** 상세 화면 — 저장 시 삭제할 서버 이미지 ID */
pendingDeleteIds?: number[];
onPendingDeleteIdsChange?: (ids: number[]) => void;
/** true: 추가·삭제를 저장 버튼까지 지연 (상세 편집) */
deferUpload?: boolean;
disabled?: boolean;
/** 모달 등 좁은 영역 */
compact?: boolean;
}
interface CtxMenuState {
x: number;
y: number;
}
const VerificationIssueAttachmentsSection: React.FC<Props> = ({
issueId = null,
draftFiles = [],
onDraftFilesChange,
pendingDeleteIds = [],
onPendingDeleteIdsChange,
deferUpload = false,
disabled = false,
compact = false,
}) => {
const isDraft = issueId == null;
const saveOnSubmit = deferUpload && issueId != null;
const [images, setImages] = useState<VerificationIssueImageDto[]>([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [ctxMenu, setCtxMenu] = useState<CtxMenuState | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const zoneRef = useRef<HTMLDivElement>(null);
const reload = useCallback(async () => {
if (issueId == null) return;
setLoading(true);
try {
setImages(await loadVerificationIssueImages(issueId));
} catch (e) {
console.warn('[VerificationBoard] images load', e);
} finally {
setLoading(false);
}
}, [issueId]);
useEffect(() => {
if (issueId != null) void reload();
else setImages([]);
}, [issueId, reload]);
const draftPreviews = useMemo(
() => draftFiles.map((file, index) => ({
key: `${file.name}-${file.size}-${file.lastModified}-${index}`,
index,
fileName: file.name,
src: URL.createObjectURL(file),
})),
[draftFiles],
);
useEffect(() => () => {
draftPreviews.forEach(p => URL.revokeObjectURL(p.src));
}, [draftPreviews]);
useEffect(() => {
const close = () => setCtxMenu(null);
window.addEventListener('click', close);
window.addEventListener('scroll', close, true);
return () => {
window.removeEventListener('click', close);
window.removeEventListener('scroll', close, true);
};
}, []);
const addDraftFiles = useCallback(async (files: File[]) => {
const imgs = pickImageFiles(files);
if (imgs.length === 0) {
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
return;
}
setUploading(true);
try {
const prepared = await prepareImagesForUpload(imgs);
onDraftFilesChange?.([...draftFiles, ...prepared]);
} catch (e) {
window.alert(uploadErrorMessage(e));
} finally {
setUploading(false);
}
}, [draftFiles, onDraftFilesChange]);
const uploadFiles = useCallback(async (files: File[]) => {
if (issueId == null || saveOnSubmit) {
await addDraftFiles(files);
return;
}
const imgs = pickImageFiles(files);
if (imgs.length === 0) {
window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.');
return;
}
setUploading(true);
try {
const prepared = await prepareImagesForUpload(imgs);
await uploadVerificationIssueImages(issueId, prepared);
await reload();
} catch (e) {
console.warn('[VerificationBoard] image upload', e);
window.alert(uploadErrorMessage(e));
} finally {
setUploading(false);
}
}, [issueId, saveOnSubmit, addDraftFiles, reload]);
const pasteFromClipboard = useCallback(async () => {
setCtxMenu(null);
try {
const files = await filesFromClipboardRead();
if (files.length === 0) {
window.alert('클립보드에 붙여넣을 수 있는 이미지(png, jpeg 등)가 없습니다.');
return;
}
await uploadFiles(files);
} catch (e) {
if (e instanceof Error && e.message === 'CLIPBOARD_UNSUPPORTED') {
window.alert('이 브라우저에서는 클립보드 이미지 읽기를 지원하지 않습니다.');
return;
}
window.alert('클립보드 접근이 거부되었습니다. 브라우저 권한을 확인하세요.');
}
}, [uploadFiles]);
const handleDeleteServer = async (imageId: number) => {
if (issueId == null) return;
if (!window.confirm('이 이미지를 삭제할까요?')) return;
if (saveOnSubmit) {
onPendingDeleteIdsChange?.(
pendingDeleteIds.includes(imageId)
? pendingDeleteIds
: [...pendingDeleteIds, imageId],
);
return;
}
try {
await deleteVerificationIssueImage(issueId, imageId);
await reload();
} catch (e) {
console.warn('[VerificationBoard] image delete', e);
window.alert('이미지 삭제에 실패했습니다.');
}
};
const handleDeleteDraft = (index: number) => {
onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index));
};
const openImage = (src: string) => {
window.open(src, '_blank', 'noopener,noreferrer');
};
const handleContextMenu = (e: React.MouseEvent) => {
if (disabled) return;
e.preventDefault();
e.stopPropagation();
setCtxMenu({ x: e.clientX, y: e.clientY });
};
const handlePaste = (e: React.ClipboardEvent) => {
if (disabled) return;
const files = filesFromDataTransfer(e.clipboardData);
if (files.length === 0) return;
e.preventDefault();
void uploadFiles(files);
};
const handleDrop = (e: React.DragEvent) => {
if (disabled) return;
e.preventDefault();
setDragOver(false);
const fromTransfer = filesFromDataTransfer(e.dataTransfer);
if (fromTransfer.length > 0) void uploadFiles(fromTransfer);
};
const visibleServerImages = useMemo(
() => images.filter(img => img.id != null && !pendingDeleteIds.includes(img.id)),
[images, pendingDeleteIds],
);
const totalCount = isDraft || saveOnSubmit
? visibleServerImages.length + draftFiles.length
: images.length;
const showEmptyHint = !loading && totalCount === 0;
return (
<div className={`vbd-attachments${compact ? ' vbd-attachments--compact' : ''}`}>
<div className="vbd-attachments-head">
<h3 className="vbd-attachments-title">
{totalCount > 0 && `(${totalCount})`}
</h3>
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || uploading}
>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/bmp"
multiple
hidden
disabled={disabled}
onChange={e => {
const files = e.target.files;
if (files?.length) void uploadFiles(pickImageFiles(files));
e.target.value = '';
}}
/>
</div>
<div
ref={zoneRef}
className={`vbd-attach-zone${dragOver ? ' vbd-attach-zone--over' : ''}${disabled ? ' vbd-attach-zone--disabled' : ''}`}
tabIndex={disabled ? -1 : 0}
role="region"
aria-label="이미지 첨부 영역"
onContextMenu={handleContextMenu}
onPaste={handlePaste}
onDragEnter={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
onDragOver={e => { if (disabled) return; e.preventDefault(); setDragOver(true); }}
onDragLeave={e => {
if (zoneRef.current && !zoneRef.current.contains(e.relatedTarget as Node)) {
setDragOver(false);
}
}}
onDrop={handleDrop}
>
{loading && issueId != null && !isDraft && images.length === 0 ? (
<p className="vbd-attach-hint"> </p>
) : showEmptyHint ? (
<p className="vbd-attach-hint">
{isDraft || saveOnSubmit
? '저장 시 함께 반영됩니다. 드래그·우클릭 붙여넣기·파일 선택'
: '이미지를 드래그하거나, 우클릭 → 클립보드 붙여넣기, 또는 「파일 선택」'}
</p>
) : (
<div className="vbd-attach-grid">
{(isDraft || saveOnSubmit) && visibleServerImages.map(img => {
if (img.id == null) return null;
const src = verificationIssueImageUrl(issueId!, img.id);
return (
<figure key={img.id} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(src)}
>
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => void handleDeleteServer(img.id!)}
>
</button>
</figure>
);
})}
{(isDraft || saveOnSubmit)
? draftPreviews.map(p => (
<figure key={p.key} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${p.fileName} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(p.src)}
>
<img src={p.src} alt={p.fileName} draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={p.fileName}>
{p.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => handleDeleteDraft(p.index)}
>
</button>
</figure>
))
: !saveOnSubmit && images.map(img => {
if (img.id == null) return null;
const src = verificationIssueImageUrl(issueId!, img.id);
return (
<figure key={img.id} className="vbd-attach-item">
<button
type="button"
className="vbd-attach-thumb"
title={`${img.fileName ?? '첨부 이미지'} — 더블클릭하여 열기`}
onDoubleClick={() => openImage(src)}
>
<img src={src} alt={img.fileName ?? '첨부 이미지'} loading="lazy" draggable={false} />
</button>
<figcaption className="vbd-attach-caption" title={img.fileName}>
{img.fileName}
</figcaption>
<button
type="button"
className="vbd-attach-remove"
title="삭제"
disabled={disabled}
onClick={() => void handleDeleteServer(img.id!)}
>
</button>
</figure>
);
})}
</div>
)}
{uploading && <div className="vbd-attach-uploading"> </div>}
</div>
{ctxMenu && (
<div
className="vbd-attach-ctx"
style={{ left: ctxMenu.x, top: ctxMenu.y }}
onClick={e => e.stopPropagation()}
>
<button type="button" onClick={() => { setCtxMenu(null); fileInputRef.current?.click(); }}>
</button>
<button type="button" onClick={() => void pasteFromClipboard()}>
</button>
</div>
)}
</div>
);
};
export default VerificationIssueAttachmentsSection;
@@ -0,0 +1,231 @@
/**
* 검증 이슈 댓글 — 목록·추가·수정·삭제
*/
import React, { useCallback, useEffect, useState } from 'react';
import { getAuthSession } from '../../utils/auth';
import {
createVerificationIssueComment,
deleteVerificationIssueComment,
loadVerificationIssueComments,
updateVerificationIssueComment,
type VerificationIssueCommentDto,
} from '../../utils/verificationBoardApi';
interface Props {
issueId: number;
}
function formatDateTime(iso?: string): string {
if (!iso) return '';
try {
return new Date(iso).toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
} catch { return ''; }
}
function defaultAuthorName(): string {
const session = getAuthSession();
if (session?.displayName) return session.displayName;
return '게스트';
}
const VerificationIssueCommentsSection: React.FC<Props> = ({ issueId }) => {
const [comments, setComments] = useState<VerificationIssueCommentDto[]>([]);
const [loading, setLoading] = useState(false);
const [newContent, setNewContent] = useState('');
const [submitting, setSubmitting] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState('');
const reload = useCallback(async () => {
setLoading(true);
try {
const list = await loadVerificationIssueComments(issueId);
setComments(list);
} catch (e) {
console.warn('[VerificationBoard] comments load', e);
} finally {
setLoading(false);
}
}, [issueId]);
useEffect(() => {
setEditingId(null);
setEditContent('');
setNewContent('');
void reload();
}, [issueId, reload]);
const handleAdd = async () => {
const content = newContent.trim();
if (!content) {
window.alert('댓글 내용을 입력하세요.');
return;
}
setSubmitting(true);
try {
await createVerificationIssueComment(issueId, {
content,
authorName: defaultAuthorName(),
});
setNewContent('');
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment add', e);
window.alert('댓글 등록에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
const startEdit = (c: VerificationIssueCommentDto) => {
if (c.id == null) return;
setEditingId(c.id);
setEditContent(c.content);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent('');
};
const handleSaveEdit = async (commentId: number) => {
const content = editContent.trim();
if (!content) {
window.alert('댓글 내용을 입력하세요.');
return;
}
setSubmitting(true);
try {
await updateVerificationIssueComment(issueId, commentId, { content });
cancelEdit();
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment edit', e);
window.alert('댓글 수정에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (commentId: number) => {
if (!window.confirm('이 댓글을 삭제할까요?')) return;
setSubmitting(true);
try {
await deleteVerificationIssueComment(issueId, commentId);
if (editingId === commentId) cancelEdit();
await reload();
} catch (e) {
console.warn('[VerificationBoard] comment delete', e);
window.alert('댓글 삭제에 실패했습니다.');
} finally {
setSubmitting(false);
}
};
return (
<div className="vbd-comments">
<div className="vbd-comments-header">
<h3 className="vbd-comments-title"> {comments.length > 0 && `(${comments.length})`}</h3>
</div>
<div className="vbd-comments-list">
{loading && comments.length === 0 ? (
<p className="vbd-comments-empty"> </p>
) : comments.length === 0 ? (
<p className="vbd-comments-empty"> .</p>
) : comments.map(c => {
const isEditing = c.id === editingId;
return (
<article key={c.id} className="vbd-comment">
<div className="vbd-comment-head">
<span className="vbd-comment-author">{c.authorName ?? '익명'}</span>
<span className="vbd-comment-date">
{formatDateTime(c.updatedAt ?? c.createdAt)}
{c.updatedAt && c.createdAt && c.updatedAt !== c.createdAt && ' (수정됨)'}
</span>
<div className="vbd-comment-actions">
{!isEditing && (
<>
<button
type="button"
className="vbd-comment-action"
onClick={() => startEdit(c)}
disabled={submitting}
>
</button>
<button
type="button"
className="vbd-comment-action vbd-comment-action--danger"
onClick={() => c.id != null && void handleDelete(c.id)}
disabled={submitting}
>
</button>
</>
)}
</div>
</div>
{isEditing ? (
<div className="vbd-comment-edit">
<textarea
className="vbd-comment-textarea"
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={3}
disabled={submitting}
/>
<div className="vbd-comment-edit-actions">
<button
type="button"
className="vbd-btn vbd-btn--sm"
onClick={cancelEdit}
disabled={submitting}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--sm vbd-btn--primary"
onClick={() => c.id != null && void handleSaveEdit(c.id)}
disabled={submitting}
>
</button>
</div>
</div>
) : (
<p className="vbd-comment-body">{c.content}</p>
)}
</article>
);
})}
</div>
<div className="vbd-comment-compose">
<textarea
className="vbd-comment-textarea"
placeholder="댓글을 입력하세요…"
value={newContent}
onChange={e => setNewContent(e.target.value)}
rows={3}
disabled={submitting}
/>
<div className="vbd-comment-compose-actions">
<button
type="button"
className="vbd-btn vbd-btn--primary"
onClick={() => void handleAdd()}
disabled={submitting || !newContent.trim()}
>
{submitting ? '등록 중…' : '댓글 등록'}
</button>
</div>
</div>
</div>
);
};
export default VerificationIssueCommentsSection;
@@ -0,0 +1,210 @@
/**
* 검증게시판 — 우측 이슈 상세 (인라인 편집 + 저장)
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
stageColor,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import ReproductionPathInput from './ReproductionPathInput';
import VerificationIssueCommentsSection from './VerificationIssueCommentsSection';
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
interface Props {
issue: VerificationIssueDto | null;
attachmentsRefreshKey?: number;
saving?: boolean;
onSave?: (
dto: VerificationIssueDto,
pendingImages: File[],
pendingDeleteIds: number[],
) => void | Promise<void>;
}
function formatDateTime(iso?: string): string {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('ko-KR');
} catch { return iso; }
}
const VerificationIssueDetailPanel: React.FC<Props> = ({
issue,
attachmentsRefreshKey = 0,
saving = false,
onSave,
}) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reproductionPath, setReproductionPath] = useState('');
const [stage, setStage] = useState<VerificationIssueStage>('REGISTERED');
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[]>([]);
const resetFromIssue = useCallback((src: VerificationIssueDto) => {
setTitle(src.title ?? '');
setContent(src.content ?? '');
setReproductionPath(src.reproductionPath ?? '');
setStage(src.stage ?? 'REGISTERED');
setPendingImages([]);
setPendingDeleteIds([]);
}, []);
useEffect(() => {
if (issue) resetFromIssue(issue);
}, [issue?.id, attachmentsRefreshKey, resetFromIssue]); // eslint-disable-line react-hooks/exhaustive-deps
const dirty = useMemo(() => {
if (!issue) return false;
const pathTrimmed = reproductionPath.trim();
const issuePath = (issue.reproductionPath ?? '').trim();
return (
title.trim() !== (issue.title ?? '').trim()
|| content.trim() !== (issue.content ?? '').trim()
|| pathTrimmed !== issuePath
|| stage !== issue.stage
|| pendingImages.length > 0
|| pendingDeleteIds.length > 0
);
}, [issue, title, content, reproductionPath, stage, pendingImages, pendingDeleteIds]);
const handleCancel = () => {
if (issue) resetFromIssue(issue);
};
const handleSave = () => {
if (!issue?.id || !onSave) return;
const trimmed = title.trim();
if (!trimmed) {
window.alert('제목을 입력하세요.');
return;
}
const pathTrimmed = reproductionPath.trim();
void onSave(
{
id: issue.id,
title: trimmed,
content: content.trim(),
reproductionPath: pathTrimmed || undefined,
stage,
},
pendingImages,
pendingDeleteIds,
);
};
if (!issue) {
return (
<div className="vbd-detail vbd-detail--empty">
<p> .</p>
</div>
);
}
return (
<div className="vbd-detail">
<div className="vbd-detail-scroll">
<div className="vbd-detail-title-row">
<input
type="text"
className="vbd-detail-title-input"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={300}
disabled={saving}
placeholder="검증 이슈 제목"
aria-label="제목"
/>
<select
value={stage}
onChange={e => setStage(e.target.value as VerificationIssueStage)}
disabled={saving}
className="vbd-detail-stage-select"
aria-label="단계"
title="처리 단계"
style={{
borderColor: `${stageColor(stage)}55`,
color: stageColor(stage),
backgroundColor: `${stageColor(stage)}14`,
}}
>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
<div className="vbd-detail-meta">
<span> {formatDateTime(issue.createdAt)}</span>
<span> {formatDateTime(issue.updatedAt)}</span>
</div>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<ReproductionPathInput
value={reproductionPath}
onChange={setReproductionPath}
disabled={saving}
/>
</label>
<label className="vbd-field vbd-field--grow">
<span className="vbd-field-label"></span>
<textarea
className="vbd-detail-content-input"
value={content}
onChange={e => setContent(e.target.value)}
rows={12}
disabled={saving}
placeholder="문제점·재현 방법·기대 결과 등"
/>
</label>
{issue.id != null && (
<VerificationIssueAttachmentsSection
key={`${issue.id}-${attachmentsRefreshKey}`}
issueId={issue.id}
deferUpload
draftFiles={pendingImages}
onDraftFilesChange={setPendingImages}
pendingDeleteIds={pendingDeleteIds}
onPendingDeleteIdsChange={setPendingDeleteIds}
disabled={saving}
/>
)}
{issue.id != null && (
<VerificationIssueCommentsSection issueId={issue.id} />
)}
</div>
<div className="vbd-detail-footer">
<span className="vbd-detail-dirty-hint">
{dirty ? '저장하지 않은 변경 사항이 있습니다.' : ''}
</span>
<div className="vbd-detail-footer-actions">
<button
type="button"
className="vbd-btn"
onClick={handleCancel}
disabled={saving || !dirty}
>
</button>
<button
type="button"
className="vbd-btn vbd-btn--primary"
onClick={handleSave}
disabled={saving || !dirty}
>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</div>
</div>
);
};
export default VerificationIssueDetailPanel;
@@ -0,0 +1,134 @@
/**
* 검증 이슈 등록·수정 모달
*/
import React, { useEffect, useState } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import ReproductionPathInput from './ReproductionPathInput';
import VerificationIssueAttachmentsSection from './VerificationIssueAttachmentsSection';
interface Props {
open: boolean;
initial: VerificationIssueDto | null;
saving: boolean;
onSave: (dto: VerificationIssueDto, pendingImages: File[]) => void;
onCancel: () => void;
}
const EMPTY: VerificationIssueDto = {
title: '',
content: '',
reproductionPath: '',
stage: 'REGISTERED',
};
const VerificationIssueFormModal: React.FC<Props> = ({
open, initial, saving, onSave, onCancel,
}) => {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [reproductionPath, setReproductionPath] = useState('');
const [stage, setStage] = useState<VerificationIssueStage>('REGISTERED');
const [draftImages, setDraftImages] = useState<File[]>([]);
useEffect(() => {
if (!open) return;
setTitle(initial?.title ?? '');
setContent(initial?.content ?? '');
setReproductionPath(initial?.reproductionPath ?? '');
setStage(initial?.stage ?? 'REGISTERED');
setDraftImages([]);
}, [open, initial]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = title.trim();
if (!trimmed) {
window.alert('제목을 입력하세요.');
return;
}
const pathTrimmed = reproductionPath.trim();
onSave({
...(initial?.id != null ? { id: initial.id } : {}),
title: trimmed,
content: content.trim(),
reproductionPath: pathTrimmed || undefined,
stage,
}, initial?.id == null ? draftImages : []);
};
return (
<div className="vbd-modal-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}>
<div className="vbd-modal vbd-modal--wide" role="dialog" aria-modal="true" aria-labelledby="vbd-modal-title">
<div className="vbd-modal-header">
<h3 id="vbd-modal-title">{initial?.id != null ? '검증 이슈 수정' : '검증 이슈 등록'}</h3>
<button type="button" className="vbd-modal-close" onClick={onCancel} aria-label="닫기"></button>
</div>
<form className="vbd-modal-form" onSubmit={handleSubmit}>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<select
value={stage}
onChange={e => setStage(e.target.value as VerificationIssueStage)}
>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</label>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={300}
autoFocus
placeholder="검증 이슈 제목"
/>
</label>
<label className="vbd-field">
<span className="vbd-field-label"></span>
<ReproductionPathInput
value={reproductionPath}
onChange={setReproductionPath}
disabled={saving}
/>
<span className="vbd-field-hint">
, .
</span>
</label>
<label className="vbd-field vbd-field--grow">
<span className="vbd-field-label"></span>
<textarea
value={content}
onChange={e => setContent(e.target.value)}
rows={10}
placeholder="문제점·재현 방법·기대 결과 등"
/>
</label>
<VerificationIssueAttachmentsSection
issueId={initial?.id ?? null}
draftFiles={draftImages}
onDraftFilesChange={setDraftImages}
disabled={saving}
compact
/>
<div className="vbd-modal-actions">
<button type="button" className="vbd-btn" onClick={onCancel} disabled={saving}></button>
<button type="submit" className="vbd-btn vbd-btn--primary" disabled={saving}>
{saving ? '저장 중…' : '저장'}
</button>
</div>
</form>
</div>
</div>
);
};
export default VerificationIssueFormModal;
@@ -0,0 +1,118 @@
/**
* 검증게시판 — 좌측 이슈 목록 (단계 필터 + 검색)
*/
import React, { useMemo } from 'react';
import type { VerificationIssueDto } from '../../utils/verificationBoardApi';
import {
VERIFICATION_ISSUE_STAGES,
stageColor,
stageLabel,
type VerificationIssueStage,
} from '../../utils/verificationBoardStages';
import VerificationStageIcon from './VerificationStageIcon';
interface Props {
issues: VerificationIssueDto[];
selectedId: number | null;
stageFilter: VerificationIssueStage | '';
searchQuery: string;
onStageFilterChange: (stage: VerificationIssueStage | '') => void;
onSearchChange: (q: string) => void;
onSelect: (issue: VerificationIssueDto) => void;
}
function formatDate(iso?: string): string {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleString('ko-KR', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
} catch { return ''; }
}
const VerificationIssueListPanel: React.FC<Props> = ({
issues,
selectedId,
stageFilter,
searchQuery,
onStageFilterChange,
onSearchChange,
onSelect,
}) => {
const filtered = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return issues;
return issues.filter(i =>
i.title.toLowerCase().includes(q)
|| i.content.toLowerCase().includes(q)
|| (i.reproductionPath?.toLowerCase().includes(q) ?? false),
);
}, [issues, searchQuery]);
return (
<div className="vbd-list-panel">
<div className="vbd-list-filters">
<select
className="vbd-stage-select"
value={stageFilter}
onChange={e => onStageFilterChange(e.target.value as VerificationIssueStage | '')}
aria-label="이슈 단계 필터"
>
<option value=""> </option>
{VERIFICATION_ISSUE_STAGES.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<input
type="search"
className="vbd-search-input"
placeholder="제목·내용·재현경로 검색…"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
aria-label="검색"
/>
</div>
<div className="vbd-list-count">
{filtered.length}
{searchQuery.trim() && issues.length !== filtered.length && (
<span className="vbd-list-count-sub"> / {issues.length} </span>
)}
</div>
<ul className="vbd-list">
{filtered.length === 0 ? (
<li className="vbd-list-empty"> .</li>
) : filtered.map(issue => {
const active = issue.id === selectedId;
return (
<li key={issue.id}>
<button
type="button"
className={`vbd-list-item${active ? ' vbd-list-item--active' : ''}`}
onClick={() => onSelect(issue)}
>
<VerificationStageIcon stage={issue.stage} size={34} />
<span className="vbd-list-item-body">
<span className="vbd-list-item-top">
<span
className="vbd-stage-badge"
style={{ backgroundColor: `${stageColor(issue.stage)}22`, color: stageColor(issue.stage) }}
>
{stageLabel(issue.stage)}
</span>
<span className="vbd-list-meta">{formatDate(issue.updatedAt ?? issue.createdAt)}</span>
</span>
<span className="vbd-list-title">{issue.title}</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
);
};
export default VerificationIssueListPanel;
@@ -0,0 +1,86 @@
/**
* 검증 이슈 등록·단계 변경 팝업 알림
*/
import React from 'react';
import { useVerificationIssueNotification } from '../../contexts/VerificationIssueNotificationContext';
import VerificationStageIcon from './VerificationStageIcon';
import { stageColor, stageLabel, type VerificationIssueStage } from '../../utils/verificationBoardStages';
import type { VerificationIssueToastItem } from '../../utils/verificationIssueEvents';
interface Props {
onGoToIssue: (issueId: number) => void;
}
const IcListGo = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="5" height="12" rx="1" />
<path d="M9 5h5M9 8h5M9 11h3" />
</svg>
);
function headline(eventType: string, stage: VerificationIssueStage, previousStage?: VerificationIssueStage | null): string {
if (eventType === 'CREATED') return '새 검증 이슈가 등록되었습니다';
const prev = previousStage ? stageLabel(previousStage) : '—';
return `단계 변경: ${prev}${stageLabel(stage)}`;
}
const VerificationIssueToastStack: React.FC<Props> = ({ onGoToIssue }) => {
const { toasts, dismissToast, dismissAll } = useVerificationIssueNotification();
if (toasts.length === 0) return null;
return (
<div className="vbd-toast-stack" aria-live="polite" aria-label="검증 이슈 알림">
<div className="vbd-toast-toolbar">
<span className="vbd-toast-toolbar-label"> </span>
<button type="button" className="vbd-toast-dismiss-all" onClick={dismissAll}>
({toasts.length})
</button>
</div>
{toasts.map((item: VerificationIssueToastItem) => (
<article key={item.id} className="vbd-toast-card">
<div className="vbd-toast-head">
<VerificationStageIcon stage={item.stage} size={18} />
<div className="vbd-toast-head-text">
<p className="vbd-toast-kind">{headline(item.eventType, item.stage, item.previousStage)}</p>
<h4 className="vbd-toast-title">{item.title}</h4>
</div>
<button
type="button"
className="vbd-toast-close"
onClick={() => dismissToast(item.id)}
aria-label="알림 닫기"
>
</button>
</div>
<div className="vbd-toast-meta">
<span
className="vbd-stage-badge"
style={{ backgroundColor: `${stageColor(item.stage)}22`, color: stageColor(item.stage) }}
>
{stageLabel(item.stage)}
</span>
</div>
<div className="vbd-toast-actions">
<button
type="button"
className="vbd-toast-go"
title="검증게시판에서 이슈 열기"
aria-label="목록으로 이동하여 이슈 상세 보기"
onClick={() => {
dismissToast(item.id);
onGoToIssue(item.issueId);
}}
>
<IcListGo />
<span> </span>
</button>
</div>
</article>
))}
</div>
);
};
export default VerificationIssueToastStack;
@@ -0,0 +1,87 @@
import React from 'react';
import { stageColor, stageLabel, type VerificationIssueStage } from '../../utils/verificationBoardStages';
interface Props {
stage: VerificationIssueStage;
size?: number;
className?: string;
title?: string;
}
function StageGlyph({ stage }: { stage: VerificationIssueStage }) {
switch (stage) {
case 'REGISTERED':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<rect x="3" y="2" width="10" height="12" rx="1.5" />
<path d="M8 5v4M6 7h4" />
</svg>
);
case 'IN_FIX':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M11.5 2.5l2 2-6.5 6.5H5v-2L11.5 2.5z" />
<path d="M9.5 4.5l2 2" />
</svg>
);
case 'FIX_COMPLETE':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<circle cx="8" cy="8" r="5.5" />
<path d="M5.5 8l1.8 1.8L10.8 6.2" />
</svg>
);
case 'RE_REGISTERED':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M11 2.5h2.5V5" />
<path d="M4.5 13.5H2V11" />
<path d="M12.8 5.2A4.5 4.5 0 1 0 6.5 11.5" />
</svg>
);
case 'COMPLETE':
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M3 4.5h10v8H3z" />
<path d="M6 4.5V3.5a2 2 0 0 1 2-2h0a2 2 0 0 1 2 2v1" />
<path d="M6 8.5l1.5 1.5L10 7" />
</svg>
);
default:
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<circle cx="8" cy="8" r="4" />
</svg>
);
}
}
const VerificationStageIcon: React.FC<Props> = ({
stage,
size = 32,
className = '',
title,
}) => {
const color = stageColor(stage);
const label = title ?? stageLabel(stage);
return (
<span
className={`vbd-stage-icon ${className}`.trim()}
style={{
width: size,
height: size,
color,
backgroundColor: `${color}18`,
borderColor: `${color}44`,
}}
title={label}
aria-label={label}
role="img"
>
<StageGlyph stage={stage} />
</span>
);
};
export default VerificationStageIcon;
@@ -0,0 +1,82 @@
/**
* 검증 이슈 등록·단계 변경 실시간 알림 (STOMP)
*/
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { subscribeStompTopic } from '../utils/stompChartBroker';
import {
VERIFICATION_ISSUE_EVENT_TOPIC,
eventToToast,
parseVerificationIssueEvent,
shouldShowVerificationIssueEvent,
type VerificationIssueToastItem,
} from '../utils/verificationIssueEvents';
interface ContextValue {
toasts: VerificationIssueToastItem[];
dismissToast: (id: string) => void;
dismissAll: () => void;
}
const VerificationIssueNotificationContext = createContext<ContextValue | null>(null);
interface ProviderProps {
/** 검증게시판 메뉴 접근 가능 */
enabled: boolean;
/** 일반 설정 — 검증 이슈 알림 ON */
notifyEnabled: boolean;
children: React.ReactNode;
}
export const VerificationIssueNotificationProvider: React.FC<ProviderProps> = ({
enabled,
notifyEnabled,
children,
}) => {
const [toasts, setToasts] = useState<VerificationIssueToastItem[]>([]);
const active = enabled && notifyEnabled;
const dismissToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const dismissAll = useCallback(() => {
setToasts([]);
}, []);
useEffect(() => {
if (!active) {
setToasts([]);
return;
}
return subscribeStompTopic(VERIFICATION_ISSUE_EVENT_TOPIC, msg => {
const event = parseVerificationIssueEvent(msg.body);
if (!event || !shouldShowVerificationIssueEvent(event)) return;
const toast = eventToToast(event);
setToasts(prev => {
const withoutDup = prev.filter(t => t.id !== toast.id);
return [toast, ...withoutDup].slice(0, 8);
});
});
}, [active]);
const value = useMemo(
() => ({ toasts, dismissToast, dismissAll }),
[toasts, dismissToast, dismissAll],
);
return (
<VerificationIssueNotificationContext.Provider value={value}>
{children}
</VerificationIssueNotificationContext.Provider>
);
};
export function useVerificationIssueNotification(): ContextValue {
const ctx = useContext(VerificationIssueNotificationContext);
if (!ctx) {
throw new Error('useVerificationIssueNotification must be used within VerificationIssueNotificationProvider');
}
return ctx;
}
+1
View File
@@ -110,6 +110,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
tradeAlertPopupPosition: s.tradeAlertPopupPosition ?? 'right',
tradeAlertPopupLayout: s.tradeAlertPopupLayout ?? 'stack',
tradeAlertPopupGridCols: s.tradeAlertPopupGridCols ?? 2,
verificationIssueNotify: s.verificationIssueNotify ?? true,
liveStrategyCheck: s.liveStrategyCheck ?? false,
liveStrategyId: s.liveStrategyId ?? null,
liveExecutionType: s.liveExecutionType ?? 'CANDLE_CLOSE',
+953
View File
@@ -0,0 +1,953 @@
/* 검증게시판 */
.bps-page--vbd .bps-center {
min-width: 0;
}
.vbd-header-actions {
display: flex;
align-items: center;
gap: 6px;
}
.vbd-icon-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--panel, #1e222d);
color: var(--text, #d1d4dc);
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.vbd-icon-btn:hover:not(:disabled) {
background: var(--hover, #2a2e39);
border-color: var(--accent, #2196f3);
}
.vbd-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vbd-icon-btn--danger:hover:not(:disabled) {
border-color: #ef5350;
color: #ef5350;
}
.vbd-icon-btn-label {
font-size: 11px;
}
/* 좌측 목록 */
.vbd-list-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 8px;
}
.vbd-list-filters {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 2px;
}
.vbd-stage-select,
.vbd-search-input {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-size: 12px;
}
.vbd-list-count {
font-size: 11px;
color: var(--text-muted, #787b86);
padding: 0 4px;
}
.vbd-list-count-sub {
opacity: 0.8;
}
.vbd-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.vbd-list-empty {
padding: 24px 12px;
text-align: center;
color: var(--text-muted, #787b86);
font-size: 12px;
}
.vbd-list-item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
width: 100%;
padding: 10px 10px;
border: none;
border-bottom: 1px solid var(--border, #2a2e39);
background: transparent;
color: var(--text, #d1d4dc);
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.vbd-list-item:hover {
background: var(--hover, #2a2e39);
}
.vbd-list-item--active {
background: rgba(33, 150, 243, 0.12);
border-left: 3px solid var(--accent, #2196f3);
padding-left: 7px;
}
.vbd-stage-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 1px solid;
}
.vbd-stage-icon svg {
width: 58%;
height: 58%;
}
.vbd-list-item-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.vbd-list-item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
flex-wrap: wrap;
}
.vbd-stage-badge {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
line-height: 1.4;
}
.vbd-stage-badge--lg {
font-size: 12px;
padding: 4px 10px;
}
.vbd-list-title {
font-size: 13px;
font-weight: 500;
line-height: 1.35;
word-break: break-word;
}
.vbd-list-meta {
font-size: 10px;
color: var(--text-muted, #787b86);
white-space: nowrap;
}
/* 우측 상세 */
.vbd-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.vbd-detail-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 8px;
}
.vbd-detail-footer {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 20px 14px;
border-top: 1px solid var(--border, #2a2e39);
background: var(--panel, #1e222d);
}
.vbd-detail-dirty-hint {
font-size: 11px;
color: var(--accent, #2196f3);
min-width: 0;
flex: 1;
}
.vbd-detail-footer-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.vbd-detail--empty {
align-items: center;
justify-content: center;
color: var(--text-muted, #787b86);
font-size: 14px;
}
.vbd-detail-title-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.vbd-detail-title {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 1.35;
flex: 1;
min-width: 0;
word-break: break-word;
}
.vbd-detail-title-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-size: 16px;
font-weight: 600;
}
.vbd-detail-stage-select {
flex-shrink: 0;
min-width: 108px;
padding: 8px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.vbd-field--inline {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
}
.vbd-detail-content-input {
width: 100%;
min-height: 160px;
padding: 10px 12px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-family: inherit;
font-size: 13px;
line-height: 1.65;
resize: vertical;
}
.vbd-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 11px;
color: var(--text-muted, #787b86);
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border, #2a2e39);
}
.vbd-detail-path {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(33, 150, 243, 0.06);
border: 1px solid rgba(33, 150, 243, 0.2);
}
.vbd-detail-path-label {
font-size: 11px;
font-weight: 600;
color: var(--accent, #2196f3);
flex-shrink: 0;
}
.vbd-detail-path-value {
font-size: 12px;
color: var(--text, #d1d4dc);
word-break: break-all;
}
.vbd-detail-body {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-bottom: 8px;
}
.vbd-detail-content {
margin: 0;
font-family: inherit;
font-size: 13px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
color: var(--text, #d1d4dc);
}
/* 모달 */
.vbd-modal-overlay {
position: fixed;
inset: 0;
z-index: 6000;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.vbd-modal {
width: min(520px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
background: var(--panel, #1e222d);
border: 1px solid var(--border, #363a45);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.vbd-modal--wide {
width: min(640px, 100%);
}
.vbd-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #363a45);
}
.vbd-modal-header h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.vbd-modal-close {
border: none;
background: transparent;
color: var(--text-muted, #787b86);
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
}
.vbd-modal-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
overflow-y: auto;
}
.vbd-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.vbd-field--grow {
flex: 1;
}
.vbd-field-label {
font-size: 11px;
color: var(--text-muted, #787b86);
}
.vbd-field-hint {
font-size: 10px;
color: var(--text-muted, #787b86);
line-height: 1.4;
}
.vbd-path-wrap {
position: relative;
}
.vbd-path-input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-size: 13px;
font-family: inherit;
box-sizing: border-box;
}
.vbd-path-suggest {
position: absolute;
z-index: 7100;
left: 0;
right: 0;
top: calc(100% + 4px);
margin: 0;
padding: 4px 0;
list-style: none;
max-height: 220px;
overflow-y: auto;
background: var(--panel, #1e222d);
border: 1px solid var(--border, #363a45);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.vbd-path-suggest-item {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: var(--text, #d1d4dc);
font-size: 12px;
text-align: left;
cursor: pointer;
line-height: 1.35;
}
.vbd-path-suggest-item:hover,
.vbd-path-suggest-item--active {
background: var(--hover, #2a2e39);
color: var(--accent, #2196f3);
}
.vbd-path-hint {
margin: 4px 0 0;
font-size: 10px;
color: var(--text-muted, #787b86);
}
.vbd-field input,
.vbd-field select,
.vbd-field textarea {
padding: 8px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-size: 13px;
font-family: inherit;
}
.vbd-field textarea {
resize: vertical;
min-height: 160px;
}
.vbd-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 4px;
}
.vbd-btn {
padding: 8px 16px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--panel, #1e222d);
color: var(--text, #d1d4dc);
font-size: 13px;
cursor: pointer;
}
.vbd-btn--primary {
background: var(--accent, #2196f3);
border-color: var(--accent, #2196f3);
color: #fff;
}
.vbd-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.vbd-btn--sm {
padding: 5px 12px;
font-size: 12px;
}
/* 댓글 */
.vbd-comments {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--border, #2a2e39);
max-height: 45%;
min-height: 180px;
}
.vbd-comments-header {
flex-shrink: 0;
}
.vbd-comments-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text, #d1d4dc);
}
.vbd-comments-list {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
}
.vbd-comments-empty {
margin: 0;
padding: 12px 0;
font-size: 12px;
color: var(--text-muted, #787b86);
text-align: center;
}
.vbd-comment {
padding: 10px 12px;
border: 1px solid var(--border, #2a2e39);
border-radius: 8px;
background: var(--panel-alt, rgba(255, 255, 255, 0.02));
}
.vbd-comment-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.vbd-comment-author {
font-size: 12px;
font-weight: 600;
color: var(--accent, #2196f3);
}
.vbd-comment-date {
font-size: 10px;
color: var(--text-muted, #787b86);
flex: 1;
}
.vbd-comment-actions {
display: flex;
gap: 6px;
margin-left: auto;
}
.vbd-comment-action {
border: none;
background: transparent;
color: var(--text-muted, #787b86);
font-size: 11px;
cursor: pointer;
padding: 2px 4px;
}
.vbd-comment-action:hover:not(:disabled) {
color: var(--text, #d1d4dc);
}
.vbd-comment-action--danger:hover:not(:disabled) {
color: #ef5350;
}
.vbd-comment-action:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vbd-comment-body {
margin: 0;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
color: var(--text, #d1d4dc);
}
.vbd-comment-textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
background: var(--input-bg, #131722);
color: var(--text, #d1d4dc);
font-size: 13px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.vbd-comment-edit {
display: flex;
flex-direction: column;
gap: 8px;
}
.vbd-comment-edit-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.vbd-comment-compose {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.vbd-comment-compose-actions {
display: flex;
justify-content: flex-end;
}
/* 첨부 이미지 */
.vbd-attachments {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
border-top: 1px solid var(--border, #2a2e39);
border-bottom: 1px solid var(--border, #2a2e39);
margin-bottom: 8px;
}
.vbd-attachments--compact {
padding: 8px 0;
margin-bottom: 0;
border-bottom: none;
}
.vbd-attachments--compact .vbd-attach-zone {
min-height: 80px;
}
.vbd-attach-zone--disabled {
opacity: 0.6;
pointer-events: none;
}
.vbd-attachments-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.vbd-attachments-title {
margin: 0;
font-size: 14px;
font-weight: 600;
flex: 1;
}
.vbd-attach-zone {
position: relative;
min-height: 100px;
padding: 10px;
border: 1px dashed var(--border, #444);
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
outline: none;
transition: border-color 0.15s, background 0.15s;
}
.vbd-attach-zone:focus {
border-color: var(--accent, #2196f3);
}
.vbd-attach-zone--over {
border-color: var(--accent, #2196f3);
background: rgba(33, 150, 243, 0.08);
}
.vbd-attach-hint {
margin: 0;
padding: 20px 8px;
text-align: center;
font-size: 12px;
color: var(--text-muted, #787b86);
line-height: 1.5;
}
.vbd-attach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
}
.vbd-attach-item {
position: relative;
margin: 0;
border: 1px solid var(--border, #363a45);
border-radius: 6px;
overflow: hidden;
background: var(--panel, #1e222d);
}
.vbd-attach-thumb {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
cursor: zoom-in;
}
.vbd-attach-item img {
display: block;
width: 100%;
height: 100px;
object-fit: cover;
pointer-events: none;
}
.vbd-attach-caption {
padding: 4px 6px;
font-size: 10px;
color: var(--text-muted, #787b86);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vbd-attach-remove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
font-size: 12px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.vbd-attach-item:hover .vbd-attach-remove {
opacity: 1;
}
.vbd-attach-uploading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
border-radius: 8px;
}
.vbd-attach-ctx {
position: fixed;
z-index: 7000;
min-width: 180px;
padding: 4px 0;
background: var(--panel, #1e222d);
border: 1px solid var(--border, #363a45);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.vbd-attach-ctx button {
display: block;
width: 100%;
padding: 8px 14px;
border: none;
background: transparent;
color: var(--text, #d1d4dc);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.vbd-attach-ctx button:hover {
background: var(--hover, #2a2e39);
}
/* 검증 이슈 알림 팝업 */
.vbd-toast-stack {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 6500;
display: flex;
flex-direction: column;
gap: 10px;
width: min(360px, calc(100vw - 32px));
pointer-events: none;
}
.vbd-toast-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
pointer-events: auto;
}
.vbd-toast-toolbar-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted, #787b86);
}
.vbd-toast-dismiss-all {
border: none;
background: transparent;
color: var(--accent, #2196f3);
font-size: 11px;
cursor: pointer;
padding: 2px 4px;
}
.vbd-toast-card {
pointer-events: auto;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border, #363a45);
background: var(--panel, #1e222d);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
animation: vbd-toast-in 0.22s ease-out;
}
@keyframes vbd-toast-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.vbd-toast-head {
display: flex;
align-items: flex-start;
gap: 10px;
}
.vbd-toast-head-text {
flex: 1;
min-width: 0;
}
.vbd-toast-kind {
margin: 0 0 4px;
font-size: 11px;
color: var(--text-muted, #787b86);
}
.vbd-toast-title {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.35;
word-break: break-word;
}
.vbd-toast-close {
border: none;
background: transparent;
color: var(--text-muted, #787b86);
font-size: 14px;
cursor: pointer;
padding: 0 2px;
line-height: 1;
}
.vbd-toast-meta {
margin-top: 8px;
}
.vbd-toast-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.vbd-toast-go {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border, #444);
border-radius: 6px;
background: rgba(33, 150, 243, 0.08);
color: var(--accent, #2196f3);
font-size: 12px;
cursor: pointer;
}
.vbd-toast-go:hover {
background: rgba(33, 150, 243, 0.16);
}
+148
View File
@@ -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);
}
+2
View File
@@ -404,6 +404,8 @@ export interface AppSettingsDto {
tradeAlertPopupLayout?: string;
/** 그리드 배치 열 개수 (2~4) */
tradeAlertPopupGridCols?: number;
/** 검증 이슈 등록·단계 변경 알림 팝업 (기본 true) */
verificationIssueNotify?: boolean;
/** 실시간 전략 체크 마스터 ON — ON이면 DB 관심종목 전체가 체크 대상 */
liveStrategyCheck?: boolean;
/** 관심종목 공통 전략 ID */
+183
View File
@@ -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;
}
+3 -2
View File
@@ -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: '백테스팅',
+213
View File
@@ -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,
};
}