feat(search): AI natural-language search over work data (grounded)
SearchAiService interprets the query via AiTextRouter and grounds results on existing search providers; degraded fallback keeps plain search working when AI is unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ef7400632e
commit
0922e5660b
@ -0,0 +1,7 @@
|
|||||||
|
package com.zioinfo.kintex.work.search;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
/** 자연어 통합검색 요청. */
|
||||||
|
public record AiSearchRequest(@NotBlank String q, Integer limit) {
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.zioinfo.kintex.work.search;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자연어 통합검색 결과.
|
||||||
|
*
|
||||||
|
* @param question 원 질문
|
||||||
|
* @param keywords AI 가 추출한 검색 키워드(적용값)
|
||||||
|
* @param types AI 가 지목한 대상 유형(WORKLOG|NOTICE|MEETING|REPORT|OPINION|EVENT|COMPANY|AUCTION|BOOTH|LEAD|CMS). 빈 배열=전체
|
||||||
|
* @param summary 검색 결과 근거 기반 요약(nullable — 결과 없음/요약 미가용 시 null)
|
||||||
|
* @param results 키워드 검색 결과(병합·중복 제거·유형 필터 적용)
|
||||||
|
* @param total 결과 수
|
||||||
|
* @param degraded AI 해석 미가용으로 원문 키워드 검색으로 강등했는지 여부
|
||||||
|
* @param provider 실제 응답 provider("claude"|"ollama"|"none")
|
||||||
|
*/
|
||||||
|
public record AiSearchResult(String question, List<String> keywords, List<String> types,
|
||||||
|
String summary, List<SearchResultItem> results, int total,
|
||||||
|
boolean degraded, String provider) {
|
||||||
|
}
|
||||||
@ -0,0 +1,247 @@
|
|||||||
|
package com.zioinfo.kintex.work.search;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.zioinfo.kintex.ai.AiTextRouter;
|
||||||
|
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
|
||||||
|
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||||
|
import com.zioinfo.kintex.system.SystemAccessGuard;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* G-07 자연어 통합검색 — 질문을 {@link AiTextRouter}로 구조화(키워드·대상 유형 JSON)한 뒤 기존 키워드 검색을
|
||||||
|
* 조합 호출하고, 실제 결과를 근거로 요약을 생성한다.
|
||||||
|
*
|
||||||
|
* <p><b>파이프라인</b>: 질문 → (1)구조화 추출(Claude→Ollama, JSON) → (2)키워드별 {@link SearchMapper} 검색 병합·중복제거·유형필터
|
||||||
|
* → (3)결과 제목 근거 요약(2차 호출, 결과 있을 때만). 어느 단계든 AI 미가용이면 <b>원문 키워드 검색으로 강등</b>(degraded=true).
|
||||||
|
*
|
||||||
|
* <p><b>환각 방지</b>: 요약은 실제 조회된 결과 제목만 근거로 생성하도록 지시하고, 없는 사실을 지어내지 않게 한다.
|
||||||
|
* 결과 자체는 DB 검색이 권위이며 AI 는 해석·요약만 담당한다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class SearchAiService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(SearchAiService.class);
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
/** 검색 가능한 유형(AI 지목값 검증 화이트리스트). */
|
||||||
|
private static final Set<String> VALID_TYPES = Set.of(
|
||||||
|
"WORKLOG", "NOTICE", "MEETING", "REPORT", "OPINION",
|
||||||
|
"EVENT", "COMPANY", "AUCTION", "BOOTH", "LEAD", "CMS");
|
||||||
|
|
||||||
|
private final SearchMapper mapper;
|
||||||
|
private final SystemAccessGuard scope;
|
||||||
|
private final AiTextRouter router;
|
||||||
|
|
||||||
|
public SearchAiService(SearchMapper mapper, SystemAccessGuard scope, AiTextRouter router) {
|
||||||
|
this.mapper = mapper;
|
||||||
|
this.scope = scope;
|
||||||
|
this.router = router;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AiSearchResult search(KintexPrincipal principal, String question, int limit) {
|
||||||
|
if (question == null || question.isBlank()) {
|
||||||
|
return new AiSearchResult("", List.of(), List.of(), null, List.of(), 0, true, "none");
|
||||||
|
}
|
||||||
|
int size = limit <= 0 ? 30 : Math.min(limit, 100);
|
||||||
|
boolean seeAll = scope.canSeeAll(principal);
|
||||||
|
String q = question.trim();
|
||||||
|
|
||||||
|
// (1) 구조화 추출
|
||||||
|
AiResult ext = router.generate(buildExtractPrompt(q), 384);
|
||||||
|
List<String> keywords = new ArrayList<>();
|
||||||
|
List<String> types = new ArrayList<>();
|
||||||
|
String provider = ext.provider();
|
||||||
|
boolean degraded = !ext.usable();
|
||||||
|
if (!degraded) {
|
||||||
|
Parsed parsed = parse(ext.text());
|
||||||
|
if (parsed == null) {
|
||||||
|
degraded = true;
|
||||||
|
} else {
|
||||||
|
keywords = parsed.keywords();
|
||||||
|
types = parsed.types();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 강등: 원문 전체를 단일 키워드로 검색.
|
||||||
|
if (degraded || keywords.isEmpty()) {
|
||||||
|
keywords = new ArrayList<>(List.of(q));
|
||||||
|
if (degraded) {
|
||||||
|
types = new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) 키워드별 검색 병합(중복 제거) + 유형 필터.
|
||||||
|
Set<String> typeFilter = new LinkedHashSet<>(types);
|
||||||
|
Map<String, SearchResultItem> merged = new LinkedHashMap<>();
|
||||||
|
for (String kw : keywords) {
|
||||||
|
if (kw == null || kw.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> rows = mapper.searchAll(kw.trim(), principal.userId(), seeAll, size);
|
||||||
|
for (Map<String, Object> r : rows) {
|
||||||
|
String type = str(r.get("type"));
|
||||||
|
if (!typeFilter.isEmpty() && (type == null || !typeFilter.contains(type))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String id = str(r.get("id"));
|
||||||
|
SearchResultItem item = new SearchResultItem(type, id, str(r.get("title")),
|
||||||
|
str(r.get("createdAt")), routeFor(type, id));
|
||||||
|
merged.putIfAbsent(type + "|" + id, item);
|
||||||
|
if (merged.size() >= size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merged.size() >= size) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<SearchResultItem> results = new ArrayList<>(merged.values());
|
||||||
|
|
||||||
|
// (3) 근거 요약(결과 있고 AI 해석이 살아 있을 때만 시도).
|
||||||
|
String summary = null;
|
||||||
|
if (!degraded && !results.isEmpty()) {
|
||||||
|
AiResult sum = router.generate(buildSummaryPrompt(q, results), 384);
|
||||||
|
if (sum.usable()) {
|
||||||
|
summary = sum.text().trim();
|
||||||
|
provider = sum.provider();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AiSearchResult(q, keywords, new ArrayList<>(typeFilter), summary,
|
||||||
|
results, results.size(), degraded, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 프롬프트 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private String buildExtractPrompt(String question) {
|
||||||
|
return "당신은 킨텍스 전시운영 통합검색의 질의 분석기다. 사용자의 자연어 질문에서 검색에 쓸 키워드와 대상 유형을 뽑아 JSON 객체 하나로만 응답하라.\n"
|
||||||
|
+ "대상 유형(정확히 이 값만 사용): WORKLOG(업무일지), NOTICE(공지), MEETING(회의록), REPORT(업무보고), OPINION(의견), "
|
||||||
|
+ "EVENT(행사), COMPANY(업체), AUCTION(옥션), BOOTH(부스), LEAD(리드), CMS(콘텐츠).\n"
|
||||||
|
+ "규칙(엄수):\n"
|
||||||
|
+ "1) 키는 정확히 keywords(핵심 검색어 문자열 배열, 1~5개, 조사·불용어 제외), types(대상 유형 문자열 배열, 특정할 수 없으면 빈 배열) 2개.\n"
|
||||||
|
+ "2) 없는 유형을 지어내지 말고, 유형이 불분명하면 types 를 빈 배열로 둔다.\n"
|
||||||
|
+ "3) 설명·머리말·마크다운·코드펜스 없이 JSON 객체 텍스트만 출력하라.\n\n"
|
||||||
|
+ "질문: " + question;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSummaryPrompt(String question, List<SearchResultItem> results) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int n = Math.min(results.size(), 20);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
SearchResultItem r = results.get(i);
|
||||||
|
sb.append("- [").append(typeLabel(r.type())).append("] ").append(r.title()).append('\n');
|
||||||
|
}
|
||||||
|
return "당신은 통합검색 결과 요약기다. 아래 '검색 결과 목록'만 근거로 사용자의 질문에 대한 2~3문장 한국어 요약을 작성하라.\n"
|
||||||
|
+ "규칙: 목록에 없는 사실·수치를 지어내지 마라. 목록이 질문과 관련 없으면 관련 결과가 없다고 답하라. 요약 문장만 출력(머리말 없이).\n\n"
|
||||||
|
+ "질문: " + question + "\n\n검색 결과 목록:\n" + sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 파싱 ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private record Parsed(List<String> keywords, List<String> types) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Parsed parse(String raw) {
|
||||||
|
String json = stripToJson(raw);
|
||||||
|
if (json == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode root = MAPPER.readTree(json);
|
||||||
|
List<String> keywords = new ArrayList<>();
|
||||||
|
JsonNode kw = root.get("keywords");
|
||||||
|
if (kw != null && kw.isArray()) {
|
||||||
|
for (JsonNode n : kw) {
|
||||||
|
String v = text(n);
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
keywords.add(v.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> types = new ArrayList<>();
|
||||||
|
JsonNode tp = root.get("types");
|
||||||
|
if (tp != null && tp.isArray()) {
|
||||||
|
for (JsonNode n : tp) {
|
||||||
|
String v = text(n);
|
||||||
|
if (v != null) {
|
||||||
|
String u = v.trim().toUpperCase();
|
||||||
|
if (VALID_TYPES.contains(u) && !types.contains(u)) {
|
||||||
|
types.add(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Parsed(keywords, types);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stripToJson(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String s = raw.trim();
|
||||||
|
int start = s.indexOf('{');
|
||||||
|
int end = s.lastIndexOf('}');
|
||||||
|
return (start < 0 || end <= start) ? null : s.substring(start, end + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SearchService 와 동일한 route 파생(민감정보 미포함). */
|
||||||
|
private static String routeFor(String type, String id) {
|
||||||
|
if (type == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return switch (type) {
|
||||||
|
case "WORKLOG" -> "/work/worklog";
|
||||||
|
case "NOTICE" -> "/work/notice";
|
||||||
|
case "MEETING" -> "/work/meeting";
|
||||||
|
case "REPORT" -> "/work/report";
|
||||||
|
case "OPINION" -> "/work/opinion";
|
||||||
|
case "EVENT" -> "/schedule";
|
||||||
|
case "COMPANY" -> "/admin/companies";
|
||||||
|
case "AUCTION" -> id == null ? "/auctions" : "/auctions/" + id;
|
||||||
|
case "BOOTH" -> "/booth-sales";
|
||||||
|
case "LEAD" -> "/leads";
|
||||||
|
case "CMS" -> "/cms";
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String typeLabel(String type) {
|
||||||
|
if (type == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return switch (type) {
|
||||||
|
case "WORKLOG" -> "업무일지";
|
||||||
|
case "NOTICE" -> "공지";
|
||||||
|
case "MEETING" -> "회의록";
|
||||||
|
case "REPORT" -> "업무보고";
|
||||||
|
case "OPINION" -> "의견";
|
||||||
|
case "EVENT" -> "행사";
|
||||||
|
case "COMPANY" -> "업체";
|
||||||
|
case "AUCTION" -> "옥션";
|
||||||
|
case "BOOTH" -> "부스";
|
||||||
|
case "LEAD" -> "리드";
|
||||||
|
case "CMS" -> "콘텐츠";
|
||||||
|
default -> type;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode n) {
|
||||||
|
return (n == null || n.isNull()) ? null : n.asText();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String str(Object o) {
|
||||||
|
return o == null ? null : String.valueOf(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,21 +3,24 @@ package com.zioinfo.kintex.work.search;
|
|||||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||||
import com.zioinfo.kintex.common.ApiResponse;
|
import com.zioinfo.kintex.common.ApiResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/** 통합검색 API (/api/work/search). 인증 사용자. */
|
/** 통합검색 API (/api/work/search). 인증 사용자. 키워드(GET) + 자연어 AI(POST). */
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/work/search")
|
@RequestMapping("/api/work/search")
|
||||||
public class SearchController {
|
public class SearchController {
|
||||||
|
|
||||||
private final SearchService service;
|
private final SearchService service;
|
||||||
|
private final SearchAiService aiService;
|
||||||
private final EventAccessGuard guard;
|
private final EventAccessGuard guard;
|
||||||
|
|
||||||
public SearchController(SearchService service, EventAccessGuard guard) {
|
public SearchController(SearchService service, SearchAiService aiService, EventAccessGuard guard) {
|
||||||
this.service = service;
|
this.service = service;
|
||||||
|
this.aiService = aiService;
|
||||||
this.guard = guard;
|
this.guard = guard;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,4 +31,13 @@ public class SearchController {
|
|||||||
guard.require(principal);
|
guard.require(principal);
|
||||||
return ApiResponse.ok(service.search(principal, q, limit));
|
return ApiResponse.ok(service.search(principal, q, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** G-07 자연어 통합검색 — 질문을 AI 로 구조화 후 키워드 검색 조합 + 근거 요약. AI 불가 시 키워드 검색 강등. */
|
||||||
|
@PostMapping("/ai")
|
||||||
|
public ApiResponse<AiSearchResult> aiSearch(@AuthenticationPrincipal KintexPrincipal principal,
|
||||||
|
@Valid @RequestBody AiSearchRequest req) {
|
||||||
|
guard.require(principal);
|
||||||
|
int limit = req.limit() == null ? 30 : req.limit();
|
||||||
|
return ApiResponse.ok(aiService.search(principal, req.q(), limit));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
IconTag,
|
IconTag,
|
||||||
} from '../../components/ui/icons';
|
} from '../../components/ui/icons';
|
||||||
import { fmtDateTime, errMessage } from './workShared';
|
import { fmtDateTime, errMessage } from './workShared';
|
||||||
|
import { searchAiApi } from './workAiApi';
|
||||||
import './work.css';
|
import './work.css';
|
||||||
|
|
||||||
const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> = {
|
const TYPE_META: Record<string, { label: string; Icon: typeof IconDocument }> = {
|
||||||
@ -75,10 +76,16 @@ export function SearchPage() {
|
|||||||
const searchQ = useQuery({
|
const searchQ = useQuery({
|
||||||
queryKey: ['search', query],
|
queryKey: ['search', query],
|
||||||
queryFn: () => searchApi.search(query, 50),
|
queryFn: () => searchApi.search(query, 50),
|
||||||
enabled: query.trim().length > 0,
|
enabled: !aiMode && query.trim().length > 0,
|
||||||
|
});
|
||||||
|
const aiQ = useQuery({
|
||||||
|
queryKey: ['aiSearch', query],
|
||||||
|
queryFn: () => searchAiApi.ask(query, 50),
|
||||||
|
enabled: aiMode && query.trim().length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = searchQ.data ?? [];
|
const activeQ = aiMode ? aiQ : searchQ;
|
||||||
|
const results = (aiMode ? aiQ.data?.results : searchQ.data) ?? [];
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
const c: Record<string, number> = { ALL: results.length };
|
const c: Record<string, number> = { ALL: results.length };
|
||||||
for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1;
|
for (const r of results) c[r.type] = (c[r.type] ?? 0) + 1;
|
||||||
@ -130,7 +137,7 @@ export function SearchPage() {
|
|||||||
<button
|
<button
|
||||||
className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`}
|
className={`kx-search__ai-toggle ${aiMode ? '' : 'is-off'}`}
|
||||||
onClick={() => setAiMode((v) => !v)}
|
onClick={() => setAiMode((v) => !v)}
|
||||||
title="AI 자연어 검색(준비 중)"
|
title="AI 자연어 검색 — 질문을 이해해 관련 데이터를 찾고 요약합니다"
|
||||||
>
|
>
|
||||||
<IconSpark size={14} /> AI 자연어 검색
|
<IconSpark size={14} /> AI 자연어 검색
|
||||||
</button>
|
</button>
|
||||||
@ -138,14 +145,37 @@ export function SearchPage() {
|
|||||||
<Button onClick={() => setQuery(input)}>검색</Button>
|
<Button onClick={() => setQuery(input)}>검색</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{aiMode && (
|
{aiMode && !query.trim() && (
|
||||||
<div className="kx-meet__ai-card" role="note">
|
<div className="kx-meet__ai-card" role="note">
|
||||||
<AiLabel>AI 자연어 검색</AiLabel>{' '}
|
<AiLabel>AI 자연어 검색</AiLabel>{' '}
|
||||||
<span className="kx-list-table__muted">
|
<span className="kx-list-table__muted">
|
||||||
자연어 검색은 준비 중입니다. 현재는 키워드 검색으로 동작합니다.
|
"이번 달 KOAA 관련 회의록 찾아줘"처럼 질문하면 관련 데이터를 찾아 요약합니다.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{aiMode && query.trim() && aiQ.data && (
|
||||||
|
<div className="kx-meet__ai-card" role="note">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<AiLabel>AI 자연어 검색</AiLabel>
|
||||||
|
{aiQ.data.keywords.length > 0 && (
|
||||||
|
<span className="kx-list-table__muted">
|
||||||
|
키워드: {aiQ.data.keywords.join(', ')}
|
||||||
|
{aiQ.data.types.length > 0 &&
|
||||||
|
` · 대상: ${aiQ.data.types.map((t) => TYPE_META[t]?.label ?? t).join(', ')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{aiQ.data.summary ? (
|
||||||
|
<p style={{ margin: '8px 0 0' }}>{aiQ.data.summary}</p>
|
||||||
|
) : (
|
||||||
|
<span className="kx-list-table__muted" style={{ display: 'block', marginTop: 6 }}>
|
||||||
|
{aiQ.data.degraded
|
||||||
|
? 'AI 해석을 사용할 수 없어 키워드 검색으로 결과를 표시합니다.'
|
||||||
|
: '검색 결과를 기반으로 표시합니다.'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!query.trim() ? (
|
{!query.trim() ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@ -176,7 +206,7 @@ export function SearchPage() {
|
|||||||
|
|
||||||
{/* 결과 */}
|
{/* 결과 */}
|
||||||
<section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<section aria-label="검색 결과" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{searchQ.isLoading &&
|
{activeQ.isLoading &&
|
||||||
Array.from({ length: 5 }).map((_, i) => (
|
Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div className="kx-search__result" key={i}>
|
<div className="kx-search__result" key={i}>
|
||||||
<Skeleton height={36} width={36} radius={6} />
|
<Skeleton height={36} width={36} radius={6} />
|
||||||
@ -186,10 +216,10 @@ export function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{searchQ.isError && (
|
{activeQ.isError && (
|
||||||
<ErrorState message={errMessage(searchQ.error)} onRetry={() => searchQ.refetch()} />
|
<ErrorState message={errMessage(activeQ.error)} onRetry={() => activeQ.refetch()} />
|
||||||
)}
|
)}
|
||||||
{!searchQ.isLoading && !searchQ.isError && filtered.length === 0 && (
|
{!activeQ.isLoading && !activeQ.isError && filtered.length === 0 && (
|
||||||
<EmptyState title="검색 결과가 없습니다" description={`'${query}'에 대한 결과를 찾지 못했습니다.`} />
|
<EmptyState title="검색 결과가 없습니다" description={`'${query}'에 대한 결과를 찾지 못했습니다.`} />
|
||||||
)}
|
)}
|
||||||
{filtered.map((r: SearchResultItem) => {
|
{filtered.map((r: SearchResultItem) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user