문의 문의 제목
문의접수담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.
diff --git a/README.md b/README.md index 96e1b10..fe105cd 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ python3 -m http.server 4173 상세 페이지에는 공개 댓글 입력 UI가 있으며, 댓글은 같은 Worker를 통해 ABC의 `feedback_comments`에 저장하고 `is_internal=false`인 공개 댓글만 조회합니다. 댓글 이미지가 있으면 `attachments` multipart 필드로 Worker가 ABC에 전달하며, 응답으로 받은 이미지 URL을 썸네일로 표시합니다. 목록·상세 진입 시에는 ABC의 `feedback_status`를 다시 조회해 관리페이지에서 변경된 상태를 반영합니다. +목록 검색은 제목·내용·회사·부서·작성자를 대상으로 오타, 띄어쓰기·대소문자 차이, 두 글자 이상의 한글 초성을 처리합니다. 예를 들어 `로그임`, `ㄹㄱㅇ`으로 `로그인`을, `파일첨부`로 `파일 첨부`를 찾을 수 있습니다. 검색 버튼이나 Enter로 실행하며, 여러 단어를 입력하면 각 단어가 모두 일치해야 합니다. 정확한 제목 일치를 우선하고 같은 점수는 기존 목록 순서를 유지합니다. 분류와 `내가 작성한 글` 조건도 함께 적용합니다. + +너무 짧은 검색어의 과도한 일치를 줄이기 위해 1~2글자는 정확한 포함 검색을 사용합니다. 오타는 3~5글자에 1회, 6~32글자에 최대 2회의 삽입·삭제·치환·인접 문자 교환을 허용합니다. 긴 단어는 정확한 포함 검색을 사용합니다. 목록은 API가 반환하는 페이지를 순서대로 수집하므로 첫 100건 이후의 글도 검색합니다. 로딩 중과 일부 페이지 조회 실패는 화면에 표시하며, 조회하지 못한 글까지 검색했다고 안내하지 않습니다. + ## 연동 지점 - SSO: Q&A는 홈페이지 로그인 정보를 전달받지 않고, `https://sso.hmac.kr/oidc`에 등록된 독립적인 PKCE Public Client로 직접 인증합니다. callback은 `https://qa-test.baroncs.co.kr/auth/callback`이며, Worker가 검증한 claim을 버전이 지정된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 Q&A 세션에 저장된 작성자 정보를 반환합니다. feedback 서버는 반드시 SSO 세션 또는 토큰을 서버 측에서 검증해야 합니다. diff --git a/assets/styles.css b/assets/styles.css index 4a2377b..67f43f2 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -238,10 +238,11 @@ button { cursor: pointer; } .table-actions { display: flex; align-items: center; justify-content: space-between; padding-top: 23px; } .result-count { color: var(--muted); font-size: 13px; } -.pagination { display: flex; justify-content: center; gap: 5px; margin: 38px 0 0; } +.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 5px; margin: 38px 0 0; } .pagination button { width: 32px; height: 32px; border: 0; background: none; color: #555; } .pagination button:hover { color: var(--navy); } .pagination button.active { border-radius: 50%; background: var(--navy); color: #fff; } +.pagination button:disabled { color: #bbb; cursor: default; } .form-card, .detail-card { border-top: 2px solid #242424; } .form-row { display: grid; grid-template-columns: 145px 1fr; min-height: 68px; border-bottom: 1px solid var(--line); } diff --git a/egbim/app.js b/egbim/app.js index 1fd37e1..708d8c5 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -137,10 +137,114 @@ }; } - async function loadRemotePosts() { - const result = await requestJson("/api/feedbacks?limit=100&page=1", { method: "GET" }); - const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []); - return { posts: (Array.isArray(items) ? items : []).map(normalizeRemotePost).filter(function (post) { return post.id; }), meta: result.meta || result.data && result.data.meta || {} }; + async function loadRemotePosts(onPage) { + const posts = []; + const seen = new Set(); + let meta = {}; + for (let page = 1; ; page += 1) { + const result = await requestJson('/api/feedbacks?limit=100&page=' + page, { method: 'GET' }); + const items = Array.isArray(result) ? result : (result.items || result.data && result.data.items || []); + if (!Array.isArray(items)) throw new Error('문의 목록 응답을 확인할 수 없습니다.'); + const previousCount = posts.length; + items.map(normalizeRemotePost).forEach(function (post) { + if (post.id && !seen.has(post.id)) { seen.add(post.id); posts.push(post); } + }); + if (page > 1 && items.length && posts.length === previousCount) throw new Error('문의 목록의 다음 페이지를 불러오지 못했습니다.'); + meta = result.meta || result.data && result.data.meta || {}; + const totalPages = Number(meta.totalPages ?? meta.total_pages); + const total = Number(meta.total ?? meta.totalCount ?? meta.total_count); + if (!items.length && (Number.isFinite(total) && posts.length < total || Number.isFinite(totalPages) && page < totalPages)) { + throw new Error('문의 목록을 끝까지 불러오지 못했습니다.'); + } + const hasMore = items.length > 0 && (Number.isFinite(totalPages) ? page < totalPages : Number.isFinite(total) ? posts.length < total : items.length === 100); + if (onPage) onPage({ posts: posts.slice(), meta: meta, complete: !hasMore }); + if (!hasMore) return { posts: posts, meta: meta }; + } + } + + const HANGUL_INITIALS = 'ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ'; + const searchIndex = new WeakMap(); + + function normalizeSearchText(value) { + return String(value || '').normalize('NFKC').toLowerCase() + .replace(/[\u1100-\u1112]/g, function (char) { return HANGUL_INITIALS[char.charCodeAt(0) - 0x1100]; }) + .replace(/[\p{P}\p{S}]+/gu, ' ').replace(/\s+/g, ' ').trim(); + } + + function hangulInitials(value) { + return Array.from(value).map(function (char) { + const code = char.charCodeAt(0) - 0xac00; + return code >= 0 && code <= 11171 ? HANGUL_INITIALS[Math.floor(code / 588)] : char; + }).join(''); + } + + // Find a near substring, allowing insertions, deletions, substitutions and adjacent swaps. + function fuzzySubstringDistance(query, text, limit) { + const cap = limit + 1; + const characters = Array.from({ length: query.length }, function (_, index) { return query.charCodeAt(index); }); + let previous = new Uint8Array(query.length + 1); + let beforePrevious = new Uint8Array(query.length + 1); + let current = new Uint8Array(query.length + 1); + for (let j = 0; j <= query.length; j += 1) previous[j] = Math.min(j, cap); + let best = limit + 1; + for (let i = 0; i < text.length; i += 1) { + const character = text.charCodeAt(i); + const preceding = text.charCodeAt(i - 1); + current[0] = 0; + for (let j = 1; j <= query.length; j += 1) { + let distance = previous[j - 1] + (character === characters[j - 1] ? 0 : 1); + if (previous[j] + 1 < distance) distance = previous[j] + 1; + if (current[j - 1] + 1 < distance) distance = current[j - 1] + 1; + if (i > 0 && j > 1 && character === characters[j - 2] && preceding === characters[j - 1] && beforePrevious[j - 2] + 1 < distance) { + distance = beforePrevious[j - 2] + 1; + } + current[j] = distance > cap ? cap : distance; + } + if (current[query.length] < best) best = current[query.length]; + // Exact substrings are checked by the caller; one edit is the best remaining match. + if (best <= 1) return best; + const reusable = beforePrevious; + beforePrevious = previous; + previous = current; + current = reusable; + } + return best; + } + + function searchPosts(posts, query) { + const terms = normalizeSearchText(query).split(' ').filter(Boolean); + if (!terms.length) return posts.slice(); + return posts.map(function (post, index) { + let fields = searchIndex.get(post); + if (!fields) { + fields = [post.title, post.content, getPostCompanyLabel(post), post.department, post.author].map(function (value, field) { + const text = normalizeSearchText(value).replace(/ /g, ''); + return { text: text, initials: hangulInitials(text), characters: new Set(text), weight: field === 0 ? 0 : field === 1 ? 3 : 2 }; + }); + searchIndex.set(post, fields); + } + let score = 0; + for (const term of terms) { + let best = Infinity; + const initialsOnly = /^[ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ]{2,}$/.test(term); + const limit = term.length >= 6 ? 2 : term.length >= 3 ? 1 : 0; + for (const field of fields) { + if (field.text.includes(term)) best = Math.min(best, field.weight); + else if (initialsOnly && field.initials.includes(term)) best = Math.min(best, 1 + field.weight); + else if (!initialsOnly && limit && term.length <= 32 && field.text) { + if (Array.from(term).filter(function (char) { return !field.characters.has(char); }).length > limit) continue; + const distance = fuzzySubstringDistance(term, field.text, limit); + if (distance <= limit) best = Math.min(best, 10 * distance + field.weight); + } + if (best === 0) break; + } + if (!Number.isFinite(best)) return { post: post, index: index, score: Infinity }; + score += best; + } + return { post: post, index: index, score: score }; + }).filter(function (match) { return Number.isFinite(match.score); }) + .sort(function (a, b) { return a.score - b.score || a.index - b.index; }) + .map(function (match) { return match.post; }); } function savePost(post) { @@ -475,6 +579,9 @@ let page = 1; let posts = getPosts(); let remoteLoaded = false; + let loadFailed = false; + let receivedRemotePage = false; + const searchStatus = qs('#searchStatus'); function getPostCompany(post, user) { return getPostCompanyLabel(post, user); } @@ -484,19 +591,20 @@ const categoryFilter = selectedCategory ? selectedCategory.value : "전체"; const onlyMine = qs("#onlyMine").checked; const user = getAuthUser(); - const filtered = posts.filter(function (post) { + const filtered = searchPosts(posts.filter(function (post) { const matchesCategory = categoryFilter === "전체" || categoryFilter === post.category; - const company = getPostCompany(post, user); - const haystack = [post.title, post.content, company, post.department, post.author].join(" ").toLowerCase(); - const matchesQuery = !query || haystack.indexOf(query) > -1; const matchesMine = !onlyMine || isPostOwner(post, user); - return matchesCategory && matchesQuery && matchesMine; - }); + return matchesCategory && matchesMine; + }), query); const pageSize = config.pageSize || 10; const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); page = Math.min(page, totalPages); const rows = filtered.slice((page - 1) * pageSize, page * pageSize); if (resultCount) resultCount.textContent = "총 " + filtered.length + "건"; + if (searchStatus) { + searchStatus.hidden = remoteLoaded && !loadFailed && !query; + searchStatus.textContent = loadFailed ? '목록을 모두 불러오지 못했습니다. 현재 불러온 글에서 검색합니다. 새로고침해 주세요.' : !remoteLoaded ? '문의 목록을 불러오는 중입니다. 검색 결과가 추가될 수 있습니다.' : '검색 결과 ' + filtered.length + '건'; + } if (empty) { empty.textContent = remoteLoaded || posts.length ? "검색 조건에 맞는 문의가 없습니다." : "문의 목록을 불러오는 중입니다."; empty.style.display = rows.length ? "none" : "block"; @@ -514,7 +622,11 @@ "
EG-BIM 관련 문의하기
문의 내용과 답변을 확인할 수 있습니다.
담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.
| 번호 | 구분 | 회사 | 부서 | 작성자 | 제목 | 등록일 | 상태 |
|---|
답변 0