feat: add typo-tolerant and Korean initial search to Q&A list
This commit is contained in:
1 parent
ccf48c8224
commit
688d16555e
7 files changed
+328
-21
No files matched your search
@@ -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 세션 또는 토큰을 서버 측에서 검증해야 합니다.
|
||||
|
||||
+2
-1
@@ -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); }
|
||||
|
||||
+126
-13
@@ -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 @@
|
||||
"<td>" + escapeHtml(post.dateTime || post.date) + "</td><td>" + (status ? "<span class=\"status " + statusClass + "\">" + escapeHtml(status) + "</span>" : "-") + "</td></tr>";
|
||||
}).join("");
|
||||
qsa("#qaRows tr").forEach(function (row) { row.addEventListener("click", function () { window.location.href = "detail.html?id=" + encodeURIComponent(row.dataset.id); }); });
|
||||
pagination.innerHTML = Array.from({ length: totalPages }, function (_, i) { return "<button type=\"button\" class=\"" + (i + 1 === page ? "active" : "") + "\" data-page=\"" + (i + 1) + "\">" + (i + 1) + "</button>"; }).join("");
|
||||
const visiblePages = Array.from(new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2])).filter(function (value) { return value >= 1 && value <= totalPages; }).sort(function (a, b) { return a - b; });
|
||||
pagination.innerHTML = '<button type="button" data-page="' + Math.max(1, page - 1) + '" aria-label="이전 페이지"' + (page === 1 ? ' disabled' : '') + '>‹</button>' + visiblePages.map(function (value, index) {
|
||||
const gap = index > 0 && value - visiblePages[index - 1] > 1 ? '<span aria-hidden="true">…</span>' : '';
|
||||
return gap + '<button type="button" class="' + (value === page ? 'active' : '') + '" data-page="' + value + '"' + (value === page ? ' aria-current="page"' : '') + '>' + value + '</button>';
|
||||
}).join('') + '<button type="button" data-page="' + Math.min(totalPages, page + 1) + '" aria-label="다음 페이지"' + (page === totalPages ? ' disabled' : '') + '>›</button>';
|
||||
qsa("#pagination button").forEach(function (button) { button.addEventListener("click", function () { page = Number(button.dataset.page); render(); window.scrollTo({ top: 360, behavior: "smooth" }); }); });
|
||||
}
|
||||
|
||||
@@ -522,15 +634,16 @@
|
||||
qsa("input[name=category], #onlyMine").forEach(function (input) { input.addEventListener("change", function () { page = 1; render(); }); });
|
||||
render();
|
||||
if (authReady) authReady.then(render);
|
||||
loadRemotePosts().then(function (result) {
|
||||
loadRemotePosts(function (result) { receivedRemotePage = true; posts = result.posts; render(); }).then(function (result) {
|
||||
posts = result.posts;
|
||||
remoteLoaded = true;
|
||||
render();
|
||||
}).catch(function (error) {
|
||||
console.warn("[QA] feedback list load failed:", error.message);
|
||||
remoteLoaded = true;
|
||||
loadFailed = true;
|
||||
render();
|
||||
refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
|
||||
if (!receivedRemotePage) refreshRemoteStatuses(posts).then(function (changed) { if (changed) render(); });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20260929-3"></head>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 상세 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20261030-search-1"></head>
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div id="completionNotice" class="completion-notice" hidden><div><strong>처리 완료 안내</strong><p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p></div><button class="button button-primary" id="completionButton" type="button">완료 확인</button></div><div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
|
||||
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
|
||||
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
|
||||
</body></html>
|
||||
+4
-3
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="description" content="EG-BIM Q&A 문의 게시판">
|
||||
<title>EG-BIM Q&A | 바론컨설턴트</title>
|
||||
<link rel="stylesheet" href="../assets/styles.css?v=20260929-3">
|
||||
<link rel="stylesheet" href="../assets/styles.css?v=20261030-search-1">
|
||||
</head>
|
||||
<body>
|
||||
<main class="qa-list-page">
|
||||
@@ -16,13 +16,14 @@
|
||||
<section class="page-shell">
|
||||
<div class="qa-list-heading"><h1>문의하기(Q&A)</h1></div>
|
||||
<form class="filter-panel" id="searchForm">
|
||||
<div class="filter-row"><div class="chip-group"><label class="chip"><input type="radio" name="category" value="전체" checked>전체</label><label class="chip"><input type="radio" name="category" value="일반문의">일반</label><label class="chip"><input type="radio" name="category" value="개선문의">개선</label><label class="chip"><input type="radio" name="category" value="오류문의">오류</label></div><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label><span class="filter-spacer"></span><div class="search-form"><input id="query" type="search" placeholder="제목+내용 검색어를 입력하세요" aria-label="게시글 검색"><button class="button search-button" type="submit"><span aria-hidden="true">⌕</span> 검색</button></div></div>
|
||||
<div class="filter-row"><div class="chip-group"><label class="chip"><input type="radio" name="category" value="전체" checked>전체</label><label class="chip"><input type="radio" name="category" value="일반문의">일반</label><label class="chip"><input type="radio" name="category" value="개선문의">개선</label><label class="chip"><input type="radio" name="category" value="오류문의">오류</label></div><label class="check"><input type="checkbox" id="onlyMine">내가 작성한 글</label><span class="filter-spacer"></span><div class="search-form"><input id="query" type="search" maxlength="120" placeholder="검색어 입력 (오타·초성 지원)" aria-label="제목·내용·회사·부서·작성자 검색 (오타·초성 지원)"><button class="button search-button" type="submit"><span aria-hidden="true">⌕</span> 검색</button></div></div>
|
||||
</form>
|
||||
<p id="searchStatus" class="help-text" role="status" aria-live="polite" hidden></p>
|
||||
<div class="table-wrap"><table class="qa-table"><caption class="sr-only">EG-BIM 문의 목록</caption><colgroup><col style="width:6%"><col style="width:8%"><col style="width:13%"><col style="width:11%"><col style="width:7%"><col style="width:38%"><col style="width:11%"><col style="width:6%"></colgroup><thead><tr><th>번호</th><th>구분</th><th>회사</th><th>부서</th><th>작성자</th><th>제목</th><th>등록일</th><th>상태</th></tr></thead><tbody id="qaRows"></tbody></table><div class="table-empty" id="tableEmpty">검색 조건에 맞는 문의가 없습니다.</div></div>
|
||||
<div class="list-footer"><div class="pagination" id="pagination" aria-label="페이지 이동"></div><a class="button button-primary" href="write.html">문의등록</a></div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
|
||||
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 등록 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20260929-3"></head>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>문의 등록 | EG-BIM Q&A</title><link rel="stylesheet" href="../assets/styles.css?v=20261030-search-1"></head>
|
||||
<body>
|
||||
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
|
||||
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
|
||||
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
|
||||
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261030-search-1"></script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,188 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const source = readFileSync(new URL('../egbim/app.js', import.meta.url), 'utf8');
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const post = (id, title, extra = {}) => ({ id, title, content: '', company: '회사', department: '', author: '', ...extra });
|
||||
const record = (id, title, extra = {}) => ({ id, title, contents: '문의 내용', Category: 'GENERAL_QNA', requester_id: 'owner', ...extra });
|
||||
const ids = (posts) => Array.from(posts, (item) => item.id);
|
||||
|
||||
function element() {
|
||||
return { value: '', textContent: '', innerHTML: '', hidden: false, checked: false, style: {}, listeners: {},
|
||||
addEventListener(name, callback) { this.listeners[name] = callback; } };
|
||||
}
|
||||
|
||||
function pageContext(fetchPage = async () => ({ items: [] })) {
|
||||
const elements = new Map(['#qaRows', '#searchForm', '#query', '#onlyMine', '#tableEmpty', '#pagination', '#searchStatus', '#resultCount'].map((selector) => [selector, element()]));
|
||||
const category = element();
|
||||
category.value = '전체';
|
||||
let buttons = [];
|
||||
const calls = [];
|
||||
const warnings = [];
|
||||
const context = vm.createContext({
|
||||
window: { QA_CONFIG: { pageSize: 10 }, location: { origin: 'https://qa.example.test' }, scrollTo() {} },
|
||||
document: {
|
||||
cookie: '', addEventListener() {},
|
||||
querySelector: (selector) => selector === 'input[name=category]:checked' ? category : elements.get(selector) || null,
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[name=category], #onlyMine') return [category, elements.get('#onlyMine')];
|
||||
if (selector === '#pagination button') {
|
||||
buttons = Array.from(elements.get('#pagination').innerHTML.matchAll(/<button([^>]*)>/g), ([, attributes]) => {
|
||||
const button = element();
|
||||
button.dataset = { page: /data-page="(\d+)"/.exec(attributes)[1] };
|
||||
button.disabled = attributes.includes(' disabled');
|
||||
return button;
|
||||
});
|
||||
return buttons;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
localStorage: { getItem: () => null, setItem() {} }, sessionStorage: { getItem: () => null },
|
||||
URL, URLSearchParams,
|
||||
console: { warn: (...args) => warnings.push(args) },
|
||||
async fetch(path, options) {
|
||||
assert.equal(options.credentials, 'include');
|
||||
const page = Number(new URL(path, 'https://qa.example.test').searchParams.get('page'));
|
||||
calls.push(page);
|
||||
return Response.json(await fetchPage(page));
|
||||
}
|
||||
});
|
||||
vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.pageSearch = { searchPosts, loadRemotePosts, initList, setUser(user) { resolvedAuthUser = user; } }; })();'), context);
|
||||
return {
|
||||
search: context.pageSearch.searchPosts, load: context.pageSearch.loadRemotePosts, elements, calls, warnings, category,
|
||||
html: () => elements.get('#qaRows').innerHTML,
|
||||
async init() { context.pageSearch.setUser({ requesterId: 'owner', ssoSubject: 'owner' }); context.pageSearch.initList(); await flush(); },
|
||||
submit(query) { elements.get('#query').value = query; elements.get('#searchForm').listeners.submit({ preventDefault() {} }); },
|
||||
clickPage(page) { const button = buttons.find((item) => item.dataset.page === String(page) && !item.disabled); assert.ok(button); button.listeners.click(); },
|
||||
buttonCount: () => buttons.length
|
||||
};
|
||||
}
|
||||
|
||||
test('search ranks exact title matches ahead of content matches and typos, preserving ties and input order', () => {
|
||||
const page = pageContext();
|
||||
const posts = [post('typo', '로그임 오류'), post('body', '사용 문의', { content: '로그인 문제' }), post('title', '로그인 오류'), post('title2', '로그인 방법')];
|
||||
assert.deepEqual(ids(page.search(posts, '로그인')), ['title', 'title2', 'body', 'typo']);
|
||||
assert.deepEqual(ids(page.search(posts, '')), ids(posts));
|
||||
assert.deepEqual(ids(posts), ['typo', 'body', 'title', 'title2']);
|
||||
});
|
||||
|
||||
test('search tolerates Korean and English typos, missing or extra letters and adjacent transposition', () => {
|
||||
const page = pageContext();
|
||||
const posts = [post('korean', '로그인 오류'), post('english', 'Login error'), post('attachment', '파일 첨부 기능'), post('unrelated', '결제 승인 요청')];
|
||||
for (const [query, expected] of [['로그임', 'korean'], ['logni', 'english'], ['loggin', 'english'], ['파일첨뷰', 'attachment']]) {
|
||||
assert.ok(ids(page.search(posts, query)).includes(expected), query);
|
||||
}
|
||||
assert.deepEqual(ids(page.search(posts, 'qzxv')), []);
|
||||
});
|
||||
|
||||
test('search supports spacing, case, Unicode normalization and Korean initials', () => {
|
||||
const page = pageContext();
|
||||
const posts = [post('login', '로그인 오류'), post('bim', 'EG-BIM 파일 첨부')];
|
||||
for (const query of ['로그인오류', 'ㄹㄱㅇ', '로그인']) assert.equal(page.search(posts, query)[0].id, 'login', query);
|
||||
for (const query of ['파일첨부', 'eg bim', 'EG-BIM']) assert.equal(page.search(posts, query)[0].id, 'bim', query);
|
||||
});
|
||||
|
||||
test('all query words are required and may match title, body, company, department or author', () => {
|
||||
const page = pageContext();
|
||||
const posts = [post('match', '로그인 오류', { company: '바론 컨설턴트', department: '설계부', author: '김작성', content: '첨부 문의' }), post('other', '로그인 오류', { department: '관리부' })];
|
||||
for (const query of ['로그임 설계부', '바론컨설턴트 김작성', '첨부 ㅅㄱㅂ']) assert.deepEqual(ids(page.search(posts, query)), ['match'], query);
|
||||
assert.deepEqual(ids(page.search(posts, '로그인 없는검색어')), []);
|
||||
});
|
||||
|
||||
test('one and two character queries require a literal match to limit unrelated results', () => {
|
||||
const page = pageContext();
|
||||
const posts = [post('dept', '문의', { department: 'IS3' })];
|
||||
assert.deepEqual(ids(page.search(posts, 'is')), ['dept']);
|
||||
assert.deepEqual(ids(page.search(posts, 'it')), []);
|
||||
assert.deepEqual(ids(page.search(posts, '오류')), []);
|
||||
});
|
||||
|
||||
test('all remote pages are loaded and duplicate IDs do not duplicate search results', async () => {
|
||||
const first = Array.from({ length: 100 }, (_, index) => record(String(index + 1), '일반 문의 ' + index));
|
||||
const page = pageContext(async (number) => ({ items: number === 1 ? first : [first[99], record('older', '로그인 오류')], meta: { total: 101 } }));
|
||||
const updates = [];
|
||||
const result = await page.load((update) => updates.push(update));
|
||||
assert.equal(result.posts.length, 101);
|
||||
assert.deepEqual(page.calls, [1, 2]);
|
||||
assert.equal(updates[0].complete, false);
|
||||
assert.equal(updates[1].complete, true);
|
||||
assert.deepEqual(ids(page.search(result.posts, '로그임')), ['older']);
|
||||
});
|
||||
|
||||
test('page metadata and full pages without metadata both terminate correctly', async () => {
|
||||
const withPages = pageContext(async (number) => ({ items: [record('page-' + number, '문의')], meta: { totalPages: 2 } }));
|
||||
assert.equal((await withPages.load()).posts.length, 2);
|
||||
assert.deepEqual(withPages.calls, [1, 2]);
|
||||
const withoutMeta = pageContext(async (number) => ({ items: number === 1 ? Array.from({ length: 100 }, (_, index) => record(String(index), '문의')) : [] }));
|
||||
assert.equal((await withoutMeta.load()).posts.length, 100);
|
||||
assert.deepEqual(withoutMeta.calls, [1, 2]);
|
||||
const empty = pageContext(async () => ({ items: [], meta: { total: 0, totalPages: 1 } }));
|
||||
assert.equal((await empty.load()).posts.length, 0);
|
||||
assert.deepEqual(empty.calls, [1]);
|
||||
});
|
||||
|
||||
test('repeated or missing remote pages fail instead of looping or claiming all results were loaded', async () => {
|
||||
const records = Array.from({ length: 100 }, (_, index) => record(String(index), '문의'));
|
||||
const repeated = pageContext(async () => ({ items: records }));
|
||||
await assert.rejects(repeated.load(), /다음 페이지/);
|
||||
assert.deepEqual(repeated.calls, [1, 2]);
|
||||
const missing = pageContext(async (number) => ({ items: number === 1 ? records : [], meta: { total: 101 } }));
|
||||
await assert.rejects(missing.load(), /끝까지/);
|
||||
});
|
||||
|
||||
test('list form uses fuzzy results while category and ownership filters continue to apply', async () => {
|
||||
const page = pageContext(async () => ({ items: [
|
||||
record('mine', '로그인 오류'), record('other', '로그인 오류', { requester_id: 'someone-else' }),
|
||||
record('improvement', '로그인 개선', { Category: 'IMPROVEMENT_QNA' }), record('unrelated', '결제 승인')
|
||||
] }));
|
||||
await page.init();
|
||||
page.submit('로그임');
|
||||
assert.match(page.html(), /data-id="mine"/);
|
||||
assert.match(page.html(), /data-id="other"/);
|
||||
assert.doesNotMatch(page.html(), /data-id="unrelated"/);
|
||||
page.category.value = '일반문의';
|
||||
page.category.listeners.change();
|
||||
assert.doesNotMatch(page.html(), /data-id="improvement"/);
|
||||
const mine = page.elements.get('#onlyMine');
|
||||
mine.checked = true;
|
||||
mine.listeners.change();
|
||||
assert.match(page.html(), /data-id="mine"/);
|
||||
assert.doesNotMatch(page.html(), /data-id="other"/);
|
||||
page.submit('qzxv');
|
||||
assert.equal(page.html(), '');
|
||||
assert.equal(page.elements.get('#tableEmpty').style.display, 'block');
|
||||
assert.equal(page.elements.get('#searchStatus').textContent, '검색 결과 0건');
|
||||
});
|
||||
|
||||
test('older pages remain reachable and a new search resets pagination before showing a matching old post', async () => {
|
||||
const records = Array.from({ length: 201 }, (_, index) => record(String(index + 1), index === 200 ? '고유마지막글' : '일반 문의 ' + index));
|
||||
const page = pageContext(async (number) => ({ items: records.slice((number - 1) * 100, number * 100), meta: { total: 201 } }));
|
||||
await page.init();
|
||||
assert.deepEqual(page.calls, [1, 2, 3]);
|
||||
assert.ok(page.buttonCount() <= 9);
|
||||
page.clickPage(21);
|
||||
assert.match(page.html(), /data-id="201"/);
|
||||
page.submit('고유마지막글');
|
||||
assert.match(page.html(), /data-id="201"/);
|
||||
assert.match(page.elements.get('#pagination').innerHTML, /class="active" data-page="1"/);
|
||||
page.submit('');
|
||||
assert.match(page.html(), /data-id="1"/);
|
||||
assert.doesNotMatch(page.html(), /data-id="201"/);
|
||||
});
|
||||
|
||||
test('partial loading remains searchable and visibly reports a page fetch failure', async () => {
|
||||
const page = pageContext(async (number) => {
|
||||
if (number === 2) throw new Error('fixture connection failure');
|
||||
return { items: [record('loaded', '로그인 오류')], meta: { totalPages: 2 } };
|
||||
});
|
||||
await page.init();
|
||||
page.submit('로그임');
|
||||
assert.match(page.html(), /data-id="loaded"/);
|
||||
assert.match(page.elements.get('#searchStatus').textContent, /목록을 모두 불러오지 못했습니다/);
|
||||
assert.equal(page.elements.get('#searchStatus').hidden, false);
|
||||
assert.equal(page.warnings.length, 1);
|
||||
assert.deepEqual(page.calls, [1, 2]);
|
||||
});
|
||||
Reference in new issue
Block a user