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:
@@ -60,6 +60,7 @@ $routes->get('bag/help', 'Bag::help');
|
|||||||
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
|
// 사용자 매뉴얼(설명서) — 로그인 사용자 전용
|
||||||
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
|
$routes->group('bag', ['filter' => 'loginAuth'], static function ($routes): void {
|
||||||
$routes->get('manual', 'Bag::manual');
|
$routes->get('manual', 'Bag::manual');
|
||||||
|
$routes->get('manual/search', 'Bag::manualSearch'); // (:segment) 보다 먼저
|
||||||
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
$routes->get('manual/(:segment)', 'Bag::manualPage/$1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3588,6 +3588,17 @@ SQL);
|
|||||||
return redirect()->to($url);
|
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.
|
* 사용자 매뉴얼 개별 페이지 (slug = 화이트리스트). 미등록 slug 는 404.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -58,6 +58,61 @@ class ManualRenderer
|
|||||||
return $this->config->pages[$slug] ?? null;
|
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.
|
* slug 페이지를 HTML 로 변환해 반환. 미등록 slug·파일 없음·변환 실패 시 null.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -105,6 +105,17 @@ if ($effectiveLgIdx) {
|
|||||||
catch (e) { return u + (u.indexOf('?') >= 0 ? '&' : '?') + 'embed=1'; }
|
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) {
|
function activate(id) {
|
||||||
if (!tabs[id]) return;
|
if (!tabs[id]) return;
|
||||||
activeId = id;
|
activeId = id;
|
||||||
@@ -113,6 +124,7 @@ if ($effectiveLgIdx) {
|
|||||||
tabs[k].btn.classList.toggle('active', k === id);
|
tabs[k].btn.classList.toggle('active', k === id);
|
||||||
});
|
});
|
||||||
if (empty) empty.style.display = 'none';
|
if (empty) empty.style.display = 'none';
|
||||||
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
function reloadTab(id) {
|
function reloadTab(id) {
|
||||||
@@ -132,6 +144,7 @@ if ($effectiveLgIdx) {
|
|||||||
var next = order[order.length - 1];
|
var next = order[order.length - 1];
|
||||||
if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; }
|
if (next) activate(next); else { activeId = null; if (empty) empty.style.display = 'flex'; }
|
||||||
}
|
}
|
||||||
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTab(url, title) {
|
function openTab(url, title) {
|
||||||
@@ -194,17 +207,18 @@ if ($effectiveLgIdx) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 브라우저 전체 새로고침/이탈 시 경고 (기본 대시보드 외 탭이 열려 있을 때)
|
// 첫 화면: 세션에 저장된 탭이 있으면 복원(관리자 페이지 등 전체 이동 후 복귀·새로고침 대응),
|
||||||
window.addEventListener('beforeunload', function (e) {
|
// 없으면 대시보드 탭 자동 열기
|
||||||
if (order.length > 1) {
|
(function restore() {
|
||||||
e.preventDefault();
|
var saved = null;
|
||||||
e.returnValue = '열어 둔 탭이 모두 닫힙니다. 계속할까요?';
|
try { saved = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); } catch (e) {}
|
||||||
return e.returnValue;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ $slugs = array_keys($pages);
|
|||||||
$pos = array_search($current, $slugs, true);
|
$pos = array_search($current, $slugs, true);
|
||||||
$prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null;
|
$prevSlug = ($pos !== false && $pos > 0) ? $slugs[$pos - 1] : null;
|
||||||
$nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null;
|
$nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : null;
|
||||||
|
$searchQ = (string) (service('request')->getGet('q') ?? '');
|
||||||
?>
|
?>
|
||||||
<style>
|
<style>
|
||||||
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 Tailwind 에 typography 플러그인 없음) */
|
/* 매뉴얼 본문 스코프 스타일 (사이트 레이아웃 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">
|
<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="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">
|
<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): ?>
|
<?php foreach ($pages as $slug => $p): ?>
|
||||||
<li>
|
<li>
|
||||||
@@ -90,3 +101,75 @@ $nextSlug = ($pos !== false && $pos < count($slugs) - 1) ? $slugs[$pos + 1] : nu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { '&': '&', '<': '<', '>': '>' }[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>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
|
var on = activeChildHref ? (chHref === activeChildHref) : (ci === 0);
|
||||||
if (child.href) {
|
if (child.href) {
|
||||||
li.innerHTML = '<a href="' + child.url + '" class="' + (on ? 'active' : '') + '">' +
|
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 {
|
} else {
|
||||||
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
|
li.innerHTML = '<span class="menu-sub" style="opacity:.65;"><span class="menu-ico">+</span>' + child.name + '</span>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
|||||||
<li>
|
<li>
|
||||||
<?php if ($child['href'] !== ''): ?>
|
<?php if ($child['href'] !== ''): ?>
|
||||||
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
|
<a href="<?= esc($child['url']) ?>" class="<?= $isChildActive ? 'active' : '' ?>">
|
||||||
<span class="menu-ico"><?= $isChildActive ? '×' : '+' ?></span>
|
<span class="menu-ico"><?= $isChildActive ? '▸' : '·' ?></span>
|
||||||
<?= esc($child['name']) ?>
|
<?= esc($child['name']) ?>
|
||||||
</a>
|
</a>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="menu-sub" style="opacity:.65;cursor:default;">
|
<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>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
@@ -35,7 +35,7 @@ $activeChildHref = strtolower(ltrim((string) ($govActiveChildHref ?? ''), '/'));
|
|||||||
<?php elseif ($activeParent['href'] !== ''): ?>
|
<?php elseif ($activeParent['href'] !== ''): ?>
|
||||||
<li>
|
<li>
|
||||||
<a href="<?= esc($activeParent['url']) ?>" class="active">
|
<a href="<?= esc($activeParent['url']) ?>" class="active">
|
||||||
<span class="menu-ico">×</span><?= esc($activeParent['name']) ?>
|
<span class="menu-ico">▸</span><?= esc($activeParent['name']) ?>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -39,4 +39,31 @@ test.describe('워크스페이스 탭', () => {
|
|||||||
await page.waitForTimeout(300);
|
await page.waitForTimeout(300);
|
||||||
await expect(page.locator('.ws-tab')).toHaveCount(1);
|
await expect(page.locator('.ws-tab')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('관리자 페이지로 이동 후 복귀해도 열어둔 탭 유지', async ({ page }) => {
|
||||||
|
await login(page, 'admin');
|
||||||
|
await page.goto('/admin/select-local-government');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const r = document.querySelector('input[name="lg_idx"][value="1"]');
|
||||||
|
if (r) { r.checked = true; r.form.submit(); }
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
|
||||||
|
await page.goto('/workspace');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
|
||||||
|
// 소메뉴를 탭으로 추가 → 탭 2개
|
||||||
|
await page.locator('.sidebar .my-menu-list a').first().click();
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
await expect(page.locator('.ws-tab')).toHaveCount(2);
|
||||||
|
|
||||||
|
// 관리자 페이지로 전체 이동(워크스페이스를 떠남)
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
|
||||||
|
// 다시 워크스페이스로 복귀 → 세션에서 탭 복원
|
||||||
|
await page.goto('/workspace');
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await expect(page.locator('.ws-tab')).toHaveCount(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user