feat: 매뉴얼 검색·소메뉴 아이콘 개선·워크스페이스 탭 세션 유지

- 매뉴얼: 전체 검색 박스(slug별 hit 카운트·스니펫)와 본문 하이라이트 추가
  - ManualRenderer::plainText()/search(), Bag::manualSearch(), bag/manual/search 라우트
- 사이드바 소메뉴 선택 아이콘 변경: 닫기처럼 보이던 × → ▸, + → · (정적/동적 일관)
- 워크스페이스: 탭 목록을 sessionStorage에 저장·복원
  - 관리자 페이지 이동 후 복귀·새로고침해도 열어둔 탭 유지(세션 범위)
  - 복원으로 무의미해진 beforeunload 새로고침 경고 제거
- e2e: 관리자 이동 후 탭 복원 케이스 추가

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
taekyoungc
2026-06-08 19:52:53 +09:00
parent e8d58b5837
commit 1a443de02e
8 changed files with 205 additions and 14 deletions

View File

@@ -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');
});

View File

@@ -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.
*/

View File

@@ -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<array{slug:string,title:string,snippet:string,hits:int}>
*/
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.
*/

View File

@@ -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('<?= base_url('/') ?>', '업무 현황');
openTab('<?= base_url('/') ?>', '업무 현황');
})();
})();
</script>
</body>

View File

@@ -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') ?? '');
?>
<style>
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 Tailwind 에 typography 플러그인 없음) */
@@ -54,6 +55,16 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
<!-- 좌측 목차 -->
<nav class="manual-toc no-print w-56 shrink-0 sticky top-0 self-start">
<div class="bg-title-bar text-white text-sm font-bold px-3 py-2 rounded-t">사용자 매뉴얼</div>
<!-- 매뉴얼 전체 검색 -->
<div class="manual-search" style="position:relative;padding:6px;border:1px solid #d1d5db;border-top:0;background:#fff;">
<div style="position:relative;">
<input id="manualSearchInput" type="search" autocomplete="off" placeholder="매뉴얼 검색 (예: LOT, 불출)"
value="<?= esc($searchQ, 'attr') ?>"
class="w-full border border-gray-300 rounded px-2 py-1.5 pr-7 text-xs focus:outline-none focus:ring-2 focus:ring-[#243a5e]/30"/>
<i class="fa-solid fa-magnifying-glass" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:11px;"></i>
</div>
<ul id="manualSearchResults" class="hidden" style="position:absolute;left:6px;right:6px;top:100%;z-index:40;max-height:300px;overflow-y:auto;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 6px 16px rgba(0,0,0,.12);margin:2px 0 0;padding:0;list-style:none;"></ul>
</div>
<ul class="border border-t-0 border-gray-300 rounded-b divide-y divide-gray-100 bg-white text-sm">
<?php foreach ($pages as $slug => $p): ?>
<li>
@@ -90,3 +101,75 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
</div>
</div>
</div>
<script>
(function () {
// 현재 출처 기준 경로(앱 baseURL 호스트와 접속 호스트가 달라도 동일 출처로 요청)
var SEARCH_URL = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/search'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var BASE = location.origin + <?= json_encode((string) parse_url(base_url('bag/manual/'), PHP_URL_PATH), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
var Q0 = <?= json_encode($searchQ, JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS) ?>;
var input = document.getElementById('manualSearchInput');
var box = document.getElementById('manualSearchResults');
function esc(s) { return String(s || '').replace(/[&<>]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]; }); }
function doSearch(q) {
q = (q || '').trim();
if (!q) { box.classList.add('hidden'); box.innerHTML = ''; return; }
fetch(SEARCH_URL + '?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (d) {
var res = (d && d.results) || [];
if (!res.length) {
box.innerHTML = '<li style="padding:7px 10px;color:#9ca3af;font-size:12px;">검색 결과가 없습니다.</li>';
box.classList.remove('hidden'); return;
}
box.innerHTML = res.map(function (m) {
return '<li><a href="' + BASE + encodeURIComponent(m.slug) + '?q=' + encodeURIComponent(q) +
'" style="display:block;padding:7px 10px;border-bottom:1px solid #f1f5f9;text-decoration:none;">' +
'<span style="font-size:12px;font-weight:700;color:#1a2b4b;">' + esc(m.title) +
' <span style="color:#94a3b8;font-weight:400;">(' + (m.hits || 1) + ')</span></span>' +
'<span style="display:block;font-size:11px;color:#64748b;margin-top:2px;line-height:1.4;">' + esc(m.snippet) + '</span></a></li>';
}).join('');
box.classList.remove('hidden');
})
.catch(function () {});
}
if (input) {
var timer = null;
input.addEventListener('input', function () { clearTimeout(timer); var v = input.value; timer = setTimeout(function () { doSearch(v); }, 250); });
input.addEventListener('focus', function () { if (input.value.trim()) doSearch(input.value); });
document.addEventListener('click', function (e) { if (!e.target.closest('.manual-search')) box.classList.add('hidden'); });
}
// 현재 페이지 본문에서 검색어 하이라이트
if (Q0 && Q0.trim()) {
var prose = document.querySelector('.manual-prose');
if (prose) {
var needle = Q0.trim().toLowerCase();
var walker = document.createTreeWalker(prose, NodeFilter.SHOW_TEXT, null);
var nodes = [], first = null;
while (walker.nextNode()) { nodes.push(walker.currentNode); }
nodes.forEach(function (node) {
var t = node.nodeValue, lt = t.toLowerCase(), idx = lt.indexOf(needle);
if (idx < 0) return;
var frag = document.createDocumentFragment(), last = 0;
while (idx >= 0) {
frag.appendChild(document.createTextNode(t.slice(last, idx)));
var mark = document.createElement('mark');
mark.textContent = t.slice(idx, idx + needle.length);
mark.style.background = '#fde68a';
frag.appendChild(mark);
if (!first) first = mark;
last = idx + needle.length;
idx = lt.indexOf(needle, last);
}
frag.appendChild(document.createTextNode(t.slice(last)));
node.parentNode.replaceChild(frag, node);
});
if (first) { try { first.scrollIntoView({ block: 'center' }); } catch (e) {} }
}
}
})();
</script>

View File

@@ -28,7 +28,7 @@
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
if (child.href) {
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
'<span class="menu-ico">' + (on ? '×' : '+') + '</span>' + child.name + '</a>';
'<span class="menu-ico">' + (on ? '' : '·') + '</span>' + child.name + '</a>';
} else {
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
}

View File

@@ -22,12 +22,12 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<li>
<?php if ($child['href'] !== ''): ?>
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
<span class="menu-ico"><?= $isChildActive ? '×' : '+' ?></span>
<span class="menu-ico"><?= $isChildActive ? '' : '·' ?></span>
<?= esc($child['name']) ?>
</a>
<?php else: ?>
<span class="menu-sub" style="opacity:.65;cursor:default;">
<span class="menu-ico">+</span><?= esc($child['name']) ?>
<span class="menu-ico">·</span><?= esc($child['name']) ?>
</span>
<?php endif; ?>
</li>
@@ -35,7 +35,7 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
<?php elseif ($activeParent['href'] !== ''): ?>
<li>
<a href="<?= esc($activeParent['url']) ?>" class="active">
<span class="menu-ico">×</span><?= esc($activeParent['name']) ?>
<span class="menu-ico"></span><?= esc($activeParent['name']) ?>
</a>
</li>
<?php endif; ?>