From 1a443de02ec15ea6bb5b5073522a8b0948966b4f Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Mon, 8 Jun 2026 19:52:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=A4=EB=89=B4=EC=96=BC=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=C2=B7=EC=86=8C=EB=A9=94=EB=89=B4=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98=20=EA=B0=9C=EC=84=A0=C2=B7=EC=9B=8C=ED=81=AC=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=ED=83=AD=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가 - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트 - 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관) - 워크스페이스: 탭 목록을 sessionStorage에 저장·복원 - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위) - 복원으로 무의미해진 beforeunload 새로고침 경고 제거 - e2e: 관리자 이동 후 탭 복원 케이스 추가 Co-Authored-By: Claude Opus 4.8 --- app/Config/Routes.php | 1 + app/Controllers/Bag.php | 11 +++ app/Libraries/ManualRenderer.php | 55 ++++++++++++ app/Views/bag/layout/workspace.php | 34 +++++--- app/Views/bag/manual.php | 83 +++++++++++++++++++ .../_dashboard_gov_portal_nav_script_base.php | 2 +- .../home/_dashboard_gov_portal_sidebar.php | 6 +- e2e/workspace.spec.js | 27 ++++++ 8 files changed, 205 insertions(+), 14 deletions(-) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index cd32c2b..0259e17 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -60,6 +60,7 @@ $routes->get('bag/help', 'Bag::help'); // 사용자 매뉴얼(설명서) — 로그인 사용자 전용 $routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void { $routes->get('manual', 'Bag::manual'); + $routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저 $routes->get('manual/(:segment)', 'Bag::manualPage/$1'); }); diff --git a/app/Controllers/Bag.php b/app/Controllers/Bag.php index 896eda4..d048fbc 100644 --- a/app/Controllers/Bag.php +++ b/app/Controllers/Bag.php @@ -3588,6 +3588,17 @@ SQL); return redirect()->to($url); } + /** + * 사용자 매뉴얼 전체 검색 (JSON). q 와 일치하는 페이지·스니펫 목록. + */ + public function manualSearch(): \CodeIgniter\HTTP\ResponseInterface + { + $q = (string) ($this->request->getGet('q') ?? ''); + $results = (new \App\Libraries\ManualRenderer())->search($q); + + return $this->response->setJSON(['q' => $q, 'results' => $results]); + } + /** * 사용자 매뉴얼 개별 페이지 (slug = 화이트리스트). 미등록 slug 는 404. */ diff --git a/app/Libraries/ManualRenderer.php b/app/Libraries/ManualRenderer.php index 665d9a2..e0b3f89 100644 --- a/app/Libraries/ManualRenderer.php +++ b/app/Libraries/ManualRenderer.php @@ -58,6 +58,61 @@ class ManualRenderer return $this->config->pages[$slug] ?? null; } + /** + * 페이지 본문을 일반 텍스트로(검색용). 미존재 시 ''. + */ + public function plainText(string $slug): string + { + $html = $this->render($slug); + if ($html === null || $html === '') { + return ''; + } + $text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/u', ' ', $text)); + } + + /** + * 모든 매뉴얼 페이지 본문에서 질의어를 찾아 결과 반환(일치 많은 순). + * + * @return list + */ + public function search(string $q): array + { + $q = trim($q); + if ($q === '' || mb_strlen($q) < 1) { + return []; + } + $needle = mb_strtolower($q); + $out = []; + foreach ($this->pages() as $slug => $page) { + $text = $this->plainText((string) $slug); + if ($text === '') { + continue; + } + $hay = mb_strtolower($text); + $pos = mb_strpos($hay, $needle); + if ($pos === false) { + continue; + } + $hits = mb_substr_count($hay, $needle); + $start = max(0, $pos - 30); + $snippet = mb_substr($text, $start, 100); + if ($start > 0) { + $snippet = '…' . $snippet; + } + $out[] = [ + 'slug' => (string) $slug, + 'title' => (string) $page['title'], + 'snippet' => trim($snippet), + 'hits' => $hits, + ]; + } + usort($out, static fn ($a, $b): int => $b['hits'] <=> $a['hits']); + + return $out; + } + /** * slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null. */ diff --git a/app/Views/bag/layout/workspace.php b/app/Views/bag/layout/workspace.php index e809b31..d9ea06a 100644 --- a/app/Views/bag/layout/workspace.php +++ b/app/Views/bag/layout/workspace.php @@ -105,6 +105,17 @@ if ($effectiveLgIdx) { catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; } } + var STORE_KEY = 'jrj_ws_tabs'; + function persist() { + try { + var data = { + tabs: order.map(function (id) { return { url: tabs[id].url, title: tabs[id].title }; }), + active: activeId + }; + sessionStorage.setItem(STORE_KEY, JSON.stringify(data)); + } catch (e) {} + } + function activate(id) { if (!tabs[id]) return; activeId = id; @@ -113,6 +124,7 @@ if ($effectiveLgIdx) { tabs[k].btn.classList.toggle('active', k === id); }); if (empty) empty.style.display = 'none'; + persist(); } function reloadTab(id) { @@ -132,6 +144,7 @@ if ($effectiveLgIdx) { var next = order[order.length - 1]; if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; } } + persist(); } function openTab(url, title) { @@ -194,17 +207,18 @@ if ($effectiveLgIdx) { }); }); - // 브라우저 전체 새로고침/이탈 시 경고 (기본 대시보드 외 탭이 열려 있을 때) - window.addEventListener('beforeunload', function (e) { - if (order.length > 1) { - e.preventDefault(); - e.returnValue = '열어 둔 탭이 모두 닫힙니다. 계속할까요?'; - return e.returnValue; + // 첫 화면: 세션에 저장된 탭이 있으면 복원(관리자 페이지 등 전체 이동 후 복귀·새로고침 대응), + // 없으면 대시보드 탭 자동 열기 + (function restore() { + var saved = null; + try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {} + if (saved && saved.tabs && saved.tabs.length) { + saved.tabs.forEach(function (t) { if (t && t.url) openTab(t.url, t.title); }); + if (saved.active && tabs[saved.active]) activate(saved.active); + return; } - }); - - // 첫 화면: 대시보드 탭 자동 열기 - openTab('', '업무 현황'); + openTab('', '업무 현황'); + })(); })(); diff --git a/app/Views/bag/manual.php b/app/Views/bag/manual.php index 3e61647..29d4d4b 100644 --- a/app/Views/bag/manual.php +++ b/app/Views/bag/manual.php @@ -17,6 +17,7 @@ $slugs = array_keys($pages); $pos = array_search($current, $slugs, true); $prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null; $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null; +$searchQ = (string) (service('request')->getGet('q') ?? ''); ?>