diff --git a/app/helpers.php b/app/helpers.php
index 771aae0..d46a2ea 100755
--- a/app/helpers.php
+++ b/app/helpers.php
@@ -167,13 +167,8 @@ function directoryPickerItems(array $dirs): array
}
unset($children);
$pathMap = dirPathMap($dirs);
- $nameCounts = [];
- foreach ($dirs as $d) {
- $n = (string) $d['name'];
- $nameCounts[$n] = ($nameCounts[$n] ?? 0) + 1;
- }
$out = [];
- $walk = static function (int $parent) use (&$walk, $byParent, $pathMap, $nameCounts, &$out): void {
+ $walk = static function (int $parent) use (&$walk, $byParent, $pathMap, &$out): void {
foreach ($byParent[$parent] ?? [] as $d) {
$id = (int) $d['id'];
$name = (string) $d['name'];
@@ -182,7 +177,7 @@ function directoryPickerItems(array $dirs): array
'name' => $name,
'path' => (string) ($pathMap[$id] ?? $name),
'level' => (int) ($d['level'] ?? 1),
- 'dup' => ($nameCounts[$name] ?? 0) > 1,
+ 'parent_id' => $parent,
];
$walk($id);
}
diff --git a/public/assets/fusion.css b/public/assets/fusion.css
index 4b34fd1..d1718ba 100644
--- a/public/assets/fusion.css
+++ b/public/assets/fusion.css
@@ -287,28 +287,44 @@ img { display: block; }
max-height: min(420px, calc(100vh - 16px));
padding: 0; overflow: hidden;
}
-.v-fusion .dir-picker-search {
- padding: 8px; border-bottom: 1px solid var(--hairline);
- background: var(--surface);
-}
-.v-fusion[data-theme="night"] .dir-picker-search { background: var(--surface-2); }
-.v-fusion .dir-picker-search .search { min-width: 0; width: 100%; height: 32px; }
.v-fusion .dir-picker-list {
overflow-y: auto; overscroll-behavior: contain; padding: 6px;
flex: 1; min-height: 0;
}
-.v-fusion .dir-picker-group {
- font-size: 11px; font-weight: 600; letter-spacing: 0.04em;
- color: var(--text-3); padding: 8px 10px 4px;
+.v-fusion .dir-picker-reset {
+ margin: 0 0 4px; padding: 0 0 4px;
+ border-bottom: 1px solid var(--hairline);
}
-.v-fusion .dir-picker-opt {
- display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
- white-space: normal;
+.v-fusion .dir-tree-row {
+ display: flex; align-items: center; gap: 2px;
+ padding-left: calc(var(--dir-indent, 0) * 12px);
+}
+.v-fusion .dir-tree-toggle {
+ flex: 0 0 24px; width: 24px; height: 24px; border-radius: 7px;
+ display: inline-flex; align-items: center; justify-content: center;
+ color: var(--text-3);
+ transition: background-color 120ms ease, color 120ms ease, transform 160ms var(--ease-out);
+}
+.v-fusion .dir-tree-toggle.is-leaf { pointer-events: none; }
+.v-fusion .dir-tree-toggle:active:not(.is-leaf) { transform: scale(0.94); }
+@media (hover: hover) and (pointer: fine) {
+ .v-fusion .dir-tree-toggle:hover:not(.is-leaf) { background: var(--hover); color: var(--text); }
+}
+.v-fusion .dir-tree-toggle:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
+.v-fusion .dir-tree-chevron {
+ display: block; width: 12px; height: 12px;
+ transition: transform 120ms var(--ease-out);
+}
+.v-fusion .dir-tree-toggle[aria-expanded="true"] .dir-tree-chevron { transform: rotate(90deg); }
+@media (prefers-reduced-motion: reduce) {
+ .v-fusion .dir-tree-toggle, .v-fusion .dir-tree-chevron { transition: none; }
+}
+.v-fusion .dir-tree-row .dir-picker-opt {
+ flex: 1; min-width: 0;
+ display: block; white-space: nowrap;
+ overflow: hidden; text-overflow: ellipsis;
}
.v-fusion .dir-picker-opt-name { font-size: 13px; line-height: 1.35; }
-.v-fusion .dir-picker-opt-path {
- font-size: 11.5px; color: var(--text-3); font-weight: 400; line-height: 1.3;
-}
.v-fusion .dir-picker-opt.is-active:not([aria-selected="true"]) { background: var(--hover); }
/* ============================================================
diff --git a/public/assets/fusion.js b/public/assets/fusion.js
index 7cae0fc..f43da84 100644
--- a/public/assets/fusion.js
+++ b/public/assets/fusion.js
@@ -513,7 +513,7 @@ let dpView = null;
let dpFocusYmd = null;
let popRepositionBound = false;
let dirPickerItems = [];
-let dirPickerQuery = "";
+let dirExpanded = new Set();
function popRoot() {
return root() || document.body;
@@ -541,7 +541,7 @@ function ensurePops() {
dirPopEl.id = "jn-dir-picker";
dirPopEl.className = "jn-pop dir-picker-pop";
dirPopEl.hidden = true;
- dirPopEl.setAttribute("role", "listbox");
+ dirPopEl.setAttribute("role", "tree");
dirPopEl.setAttribute("aria-label", "选择归档目录");
host.appendChild(menuEl);
host.appendChild(dpEl);
@@ -664,100 +664,153 @@ function commitSelect(index) {
select.focus();
}
-const DIR_RECENT_KEY = "jn_recent_dirs";
-function dirRecentIds() {
- try {
- const raw = JSON.parse(localStorage.getItem(DIR_RECENT_KEY) || "[]");
- return Array.isArray(raw) ? raw.map((x) => +x).filter((id) => id > 0) : [];
- } catch (e) {
- return [];
- }
+function dirParentId(item) {
+ return +(item && item.parent_id ? item.parent_id : 0);
}
-function rememberDir(id) {
+function dirByParentMap() {
+ const map = new Map();
+ dirPickerItems.forEach((it) => {
+ const pid = dirParentId(it);
+ if (!map.has(pid)) map.set(pid, []);
+ map.get(pid).push(it);
+ });
+ return map;
+}
+function dirItemById(id) {
id = +id;
- if (!id) return;
- const next = [id, ...dirRecentIds().filter((x) => x !== id)].slice(0, 6);
- try { localStorage.setItem(DIR_RECENT_KEY, JSON.stringify(next)); } catch (e) {}
+ return dirPickerItems.find((it) => +it.id === id) || null;
}
-function dirMatches(item, q) {
- if (!q) return true;
- const n = (item.name || "").toLowerCase();
- const p = (item.path || "").toLowerCase();
- return n.includes(q) || p.includes(q);
+function dirAncestorIds(id) {
+ const out = [];
+ let cur = dirItemById(id);
+ while (cur) {
+ const pid = dirParentId(cur);
+ if (!pid) break;
+ out.push(pid);
+ cur = dirItemById(pid);
+ }
+ return out;
}
-function dirOptButton(item, selectedId, extraClass, indent) {
+function seedDirExpanded(selectedId) {
+ dirExpanded = new Set(dirAncestorIds(selectedId).map(String));
+}
+function dirChevron() {
+ const ns = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(ns, "svg");
+ svg.setAttribute("class", "dir-tree-chevron");
+ svg.setAttribute("width", "12");
+ svg.setAttribute("height", "12");
+ svg.setAttribute("viewBox", "0 0 12 12");
+ svg.setAttribute("aria-hidden", "true");
+ const path = document.createElementNS(ns, "path");
+ path.setAttribute("d", "M4.25 2.5 8.25 6l-4 3.5");
+ path.setAttribute("fill", "none");
+ path.setAttribute("stroke", "currentColor");
+ path.setAttribute("stroke-width", "1.5");
+ path.setAttribute("stroke-linecap", "round");
+ path.setAttribute("stroke-linejoin", "round");
+ svg.appendChild(path);
+ return svg;
+}
+function dirAllButton(selectedId) {
+ const wrap = document.createElement("div");
+ wrap.className = "dir-picker-reset";
const btn = document.createElement("button");
btn.type = "button";
- btn.className = "jn-menu-opt dir-picker-opt" + (extraClass ? " " + extraClass : "");
- btn.setAttribute("role", "option");
+ btn.className = "jn-menu-opt dir-picker-opt";
+ btn.setAttribute("role", "treeitem");
+ btn.setAttribute("aria-level", "1");
+ btn.dataset.id = "0";
+ const name = document.createElement("span");
+ name.className = "dir-picker-opt-name";
+ name.textContent = "全部目录";
+ btn.appendChild(name);
+ if (+selectedId === 0) btn.setAttribute("aria-selected", "true");
+ wrap.appendChild(btn);
+ return wrap;
+}
+function dirTreeRow(item, selectedId, hasKids, expanded) {
+ const row = document.createElement("div");
+ row.className = "dir-tree-row";
+ row.style.setProperty("--dir-indent", String(Math.max(0, (item.level || 1) - 1)));
+ if (hasKids) {
+ const tog = document.createElement("button");
+ tog.type = "button";
+ tog.className = "dir-tree-toggle";
+ tog.dataset.dirToggle = String(item.id);
+ tog.setAttribute("aria-expanded", expanded ? "true" : "false");
+ tog.setAttribute("aria-label", (expanded ? "收起 " : "展开 ") + item.name);
+ tog.appendChild(dirChevron());
+ row.appendChild(tog);
+ } else {
+ const sp = document.createElement("span");
+ sp.className = "dir-tree-toggle is-leaf";
+ sp.setAttribute("aria-hidden", "true");
+ row.appendChild(sp);
+ }
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "jn-menu-opt dir-picker-opt";
+ btn.setAttribute("role", "treeitem");
+ btn.setAttribute("aria-level", String(item.level || 1));
btn.dataset.id = String(item.id);
- if (indent) btn.style.paddingLeft = (10 + Math.max(0, (item.level || 1) - 1) * 14) + "px";
+ if (hasKids) btn.setAttribute("aria-expanded", expanded ? "true" : "false");
+ btn.title = item.path || item.name;
const name = document.createElement("span");
name.className = "dir-picker-opt-name";
name.textContent = item.name;
btn.appendChild(name);
- if (item.dup || (item.level || 1) > 1 || extraClass === "is-recent") {
- const path = document.createElement("span");
- path.className = "dir-picker-opt-path";
- path.textContent = item.path;
- btn.appendChild(path);
- }
if (+selectedId === +item.id) btn.setAttribute("aria-selected", "true");
- return btn;
+ row.appendChild(btn);
+ return row;
}
function renderDirPicker() {
if (!dirPopEl) return;
const wrap = popAnchor && popAnchor.closest("[data-dir-picker]");
const selectedId = wrap ? +(wrap.dataset.selected || 0) : 0;
- const q = dirPickerQuery.trim().toLowerCase();
- const search = dirPopEl.querySelector("[data-dir-picker-q]");
- const keepQ = search ? search.value : dirPickerQuery;
+ const byParent = dirByParentMap();
dirPopEl.innerHTML = "";
- const head = document.createElement("div");
- head.className = "dir-picker-search";
- head.innerHTML = `
⌕
`;
- const inp = head.querySelector("input");
- inp.value = keepQ;
- dirPopEl.appendChild(head);
const list = document.createElement("div");
list.className = "dir-picker-list";
- const allBtn = document.createElement("button");
- allBtn.type = "button";
- allBtn.className = "jn-menu-opt dir-picker-opt";
- allBtn.setAttribute("role", "option");
- allBtn.dataset.id = "0";
- const allName = document.createElement("span");
- allName.className = "dir-picker-opt-name";
- allName.textContent = "全部目录";
- allBtn.appendChild(allName);
- if (selectedId === 0) allBtn.setAttribute("aria-selected", "true");
- list.appendChild(allBtn);
- const byId = new Map(dirPickerItems.map((it) => [+it.id, it]));
- const recents = dirRecentIds().map((id) => byId.get(id)).filter(Boolean).filter((it) => dirMatches(it, q));
- if (recents.length) {
- const lab = document.createElement("div");
- lab.className = "dir-picker-group";
- lab.textContent = "最近使用";
- list.appendChild(lab);
- recents.forEach((it) => list.appendChild(dirOptButton(it, selectedId, "is-recent", false)));
- }
- const tree = dirPickerItems.filter((it) => dirMatches(it, q));
- const treeLab = document.createElement("div");
- treeLab.className = "dir-picker-group";
- treeLab.textContent = q ? "匹配目录" : "目录树";
- list.appendChild(treeLab);
- if (!tree.length) {
- const empty = document.createElement("div");
- empty.className = "dir-picker-empty";
- empty.textContent = "无匹配目录";
- list.appendChild(empty);
- } else {
- tree.forEach((it) => list.appendChild(dirOptButton(it, selectedId, "", true)));
- }
+ list.appendChild(dirAllButton(selectedId));
+ const walk = (parentId) => {
+ (byParent.get(parentId) || []).forEach((it) => {
+ const kids = byParent.get(+it.id) || [];
+ const expanded = dirExpanded.has(String(it.id));
+ list.appendChild(dirTreeRow(it, selectedId, kids.length > 0, expanded));
+ if (kids.length && expanded) walk(+it.id);
+ });
+ };
+ walk(0);
dirPopEl.appendChild(list);
}
function dirPickerOptions() {
- return dirPopEl ? $$(".jn-menu-opt", dirPopEl) : [];
+ return dirPopEl ? $$(".dir-picker-opt", dirPopEl) : [];
+}
+function dirPickerFocusables() {
+ if (!dirPopEl) return [];
+ return $$("button:not([disabled])", dirPopEl);
+}
+function restoreDirFocus(id, wantToggle) {
+ if (!dirPopEl || id == null) return;
+ const sel = wantToggle
+ ? dirPopEl.querySelector(`[data-dir-toggle="${id}"]`)
+ : dirPopEl.querySelector(`.dir-picker-opt[data-id="${id}"]`);
+ if (sel) sel.focus();
+}
+function toggleDirExpand(id, force) {
+ const key = String(id);
+ const open = force === true ? true : force === false ? false : !dirExpanded.has(key);
+ if (open) dirExpanded.add(key);
+ else dirExpanded.delete(key);
+ const ae = document.activeElement;
+ const keepId = ae && dirPopEl && dirPopEl.contains(ae)
+ ? (ae.dataset.dirToggle || ae.dataset.id)
+ : key;
+ const wantToggle = !!(ae && ae.hasAttribute && ae.hasAttribute("data-dir-toggle"));
+ renderDirPicker();
+ if (popAnchor) placePopover(dirPopEl, popAnchor);
+ restoreDirFocus(keepId, wantToggle);
}
function moveDirHighlight(delta) {
const opts = dirPickerOptions();
@@ -770,6 +823,10 @@ function moveDirHighlight(delta) {
opts[next].classList.add("is-active");
opts[next].focus();
}
+function dirOptHasKids(id) {
+ const kids = dirByParentMap().get(+id);
+ return !!(kids && kids.length);
+}
function openDirPicker(trigger) {
if (!trigger) return;
const wrap = trigger.closest("[data-dir-picker]");
@@ -780,7 +837,7 @@ function openDirPicker(trigger) {
try { dirPickerItems = JSON.parse(wrap.getAttribute("data-dirs") || "[]"); }
catch (e) { dirPickerItems = []; }
if (!Array.isArray(dirPickerItems)) dirPickerItems = [];
- dirPickerQuery = "";
+ seedDirExpanded(+(wrap.dataset.selected || 0));
popAnchor = trigger;
popKind = "dir";
trigger.setAttribute("aria-expanded", "true");
@@ -788,8 +845,11 @@ function openDirPicker(trigger) {
renderDirPicker();
placePopover(dirPopEl, trigger);
bindPopReposition(true);
- const q = dirPopEl.querySelector("[data-dir-picker-q]");
- if (q) q.focus();
+ const selected = dirPopEl.querySelector(".dir-picker-opt[aria-selected='true']") || dirPopEl.querySelector(".dir-picker-opt");
+ if (selected) {
+ selected.focus();
+ selected.scrollIntoView({ block: "nearest" });
+ }
}
function commitDirPicker(id) {
const trigger = popAnchor;
@@ -797,7 +857,6 @@ function commitDirPicker(id) {
const form = wrap && wrap.closest("form");
const hidden = wrap && wrap.querySelector('input[name="dir_id"]');
if (!hidden || !form) { closePops(); return; }
- rememberDir(id);
hidden.value = String(id || 0);
closePops();
form.submit();
@@ -1191,7 +1250,13 @@ document.addEventListener("click", (e) => {
document.addEventListener("click", (e) => {
const t = e.target;
if (!dirPopEl || dirPopEl.hidden || !dirPopEl.contains(t)) return;
- const opt = t.closest(".jn-menu-opt");
+ const tog = t.closest("[data-dir-toggle]");
+ if (tog) {
+ e.preventDefault();
+ toggleDirExpand(tog.dataset.dirToggle);
+ return;
+ }
+ const opt = t.closest(".dir-picker-opt");
if (opt && !opt.disabled) commitDirPicker(+opt.dataset.id);
});
@@ -1276,20 +1341,6 @@ document.addEventListener("change", (e) => {
document.addEventListener("input", (e) => {
if (e.target.closest(".form-field") || isDateField(e.target)) setErr(e.target, "");
- if (e.target.matches && e.target.matches("[data-dir-picker-q]") && popKind === "dir") {
- dirPickerQuery = e.target.value || "";
- const keep = e.target;
- const start = keep.selectionStart;
- const end = keep.selectionEnd;
- renderDirPicker();
- const next = dirPopEl.querySelector("[data-dir-picker-q]");
- if (next) {
- next.focus();
- try { next.setSelectionRange(start, end); } catch (err) {}
- }
- repositionOpenPop();
- return;
- }
if (e.target.closest("[data-calc-interest]") || e.target.closest("[data-calc-discount]")) {
const ci = e.target.closest("[data-calc-interest]");
if (ci) {
@@ -1315,31 +1366,59 @@ document.addEventListener("keydown", (e) => {
}
if (popKind === "dir" && dirPopEl && !dirPopEl.hidden) {
const opts = dirPickerOptions();
+ const focusables = dirPickerFocusables();
+ const activeOpt = document.activeElement && document.activeElement.classList.contains("dir-picker-opt")
+ ? document.activeElement
+ : (dirPopEl.querySelector(".dir-picker-opt.is-active") || null);
+ const activeId = activeOpt ? +activeOpt.dataset.id : 0;
if (e.key === "Escape") { e.preventDefault(); closePops(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); moveDirHighlight(1); return; }
if (e.key === "ArrowUp") { e.preventDefault(); moveDirHighlight(-1); return; }
+ if (e.key === "ArrowRight" && activeId) {
+ e.preventDefault();
+ if (dirOptHasKids(activeId) && !dirExpanded.has(String(activeId))) toggleDirExpand(activeId, true);
+ else moveDirHighlight(1);
+ return;
+ }
+ if (e.key === "ArrowLeft") {
+ e.preventDefault();
+ if (activeId && dirExpanded.has(String(activeId))) {
+ toggleDirExpand(activeId, false);
+ return;
+ }
+ const parentId = activeId ? dirParentId(dirItemById(activeId)) : 0;
+ if (parentId) restoreDirFocus(parentId, false);
+ else restoreDirFocus(0, false);
+ return;
+ }
if (e.key === "Home" && opts[0]) { e.preventDefault(); opts.forEach((el) => el.classList.remove("is-active")); opts[0].classList.add("is-active"); opts[0].focus(); return; }
if (e.key === "End" && opts.length) { e.preventDefault(); opts.forEach((el) => el.classList.remove("is-active")); opts[opts.length - 1].classList.add("is-active"); opts[opts.length - 1].focus(); return; }
- if (e.key === "Enter") {
- const active = document.activeElement && document.activeElement.classList.contains("jn-menu-opt")
- ? document.activeElement
- : (dirPopEl.querySelector(".jn-menu-opt.is-active") || opts[0]);
- if (active && dirPopEl.contains(active) && !e.target.matches("[data-dir-picker-q]")) {
+ if (e.key === "Enter" || e.key === " ") {
+ const tog = e.target.closest && e.target.closest("[data-dir-toggle]");
+ if (tog) {
e.preventDefault();
- commitDirPicker(+active.dataset.id);
+ toggleDirExpand(tog.dataset.dirToggle);
return;
}
- if (e.target.matches && e.target.matches("[data-dir-picker-q]")) {
+ const hit = (e.target.classList && e.target.classList.contains("dir-picker-opt") && dirPopEl.contains(e.target))
+ ? e.target
+ : (dirPopEl.querySelector(".dir-picker-opt.is-active") || opts[0]);
+ if (hit) {
e.preventDefault();
- const q = (dirPickerQuery || "").trim();
- const hit = q
- ? (opts.find((el) => el.classList.contains("is-active") && el.dataset.id !== "0") || opts.find((el) => el.dataset.id !== "0") || opts[0])
- : (dirPopEl.querySelector(".jn-menu-opt.is-active") || opts[0]);
- if (hit) commitDirPicker(+hit.dataset.id);
- return;
+ commitDirPicker(+hit.dataset.id);
}
+ return;
+ }
+ if (e.key === "Tab") {
+ const i = focusables.indexOf(document.activeElement);
+ if (e.shiftKey) {
+ if (i <= 0) { e.preventDefault(); closePops(); }
+ } else if (i === -1 || i >= focusables.length - 1) {
+ e.preventDefault();
+ closePops();
+ }
+ return;
}
- if (e.key === "Tab") { closePops(); return; }
}
if (popKind === "menu" && menuEl && !menuEl.hidden) {
const opts = $$(".jn-menu-opt:not(:disabled)", menuEl);
@@ -1505,8 +1584,6 @@ document.addEventListener("DOMContentLoaded", () => {
syncCheckAll();
initCalc();
enhanceDateInputs();
- const picker = document.querySelector("[data-dir-picker]");
- if (picker && +picker.dataset.selected > 0) rememberDir(+picker.dataset.selected);
const mo = new MutationObserver((recs) => {
recs.forEach((r) => {
r.addedNodes.forEach((n) => {
diff --git a/public/index.php b/public/index.php
index cc56278..7871a9b 100755
--- a/public/index.php
+++ b/public/index.php
@@ -2857,7 +2857,7 @@ pageHeader($me, $taskTotal);