From cd1fc8ae680a0d21047a0f8c2f236e8e43be6c8b Mon Sep 17 00:00:00 2001 From: chengma Date: Tue, 11 Aug 2026 15:18:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=9D=E6=8C=81=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=AD=9B=E9=80=89=E5=92=8C=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E7=8A=B6=E6=80=81=20(#156)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/main_test.go | 54 ++++++++++++++++++ admin/static/js/app.js | 82 ++++++++++++++++++++++++++++ admin/templates/partials/header.html | 14 ++--- docs/admin/05-ui-specification.md | 12 ++++ 4 files changed, 155 insertions(+), 7 deletions(-) diff --git a/admin/main_test.go b/admin/main_test.go index bba59e4..37afe24 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -36,6 +36,60 @@ func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T) } } +func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *testing.T) { + header, err := os.ReadFile("templates/partials/header.html") + if err != nil { + t.Fatal(err) + } + js, err := os.ReadFile("static/js/app.js") + if err != nil { + t.Fatal(err) + } + headerSource := string(header) + for _, want := range []string{ + "data-module-navigation", + "data-ui-state-user=\"{{if .CurrentUser}}{{.CurrentUser.UserID}}{{end}}\"", + "data-module-root=\"/shopee\"", + "data-module-root=\"/pdd\"", + "data-module-root=\"/syb\"", + "data-module-root=\"/tasks\"", + "data-module-root=\"/clients\"", + "data-module-root=\"/users\"", + } { + if !strings.Contains(headerSource, want) { + t.Errorf("公共导航缺少模块状态标识 %q", want) + } + } + + jsSource := string(js) + for _, want := range []string{ + "var MODULE_QUERY_KEYS = {", + "\"/shopee\": [\"goods_id\", \"status\", \"shop\", \"image\", \"page\"]", + "\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"date_from\", \"date_to\"]", + "var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"", + "parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot", + "sessionStorage.setItem(", + "sessionStorage.removeItem(moduleStateStorageKey(userID, moduleRoot))", + "setupModuleNavigationState();", + } { + if !strings.Contains(jsSource, want) { + t.Errorf("模块状态脚本缺少安全或交互约束 %q", want) + } + } + + whitelistStart := strings.Index(jsSource, "var MODULE_QUERY_KEYS") + whitelistEnd := strings.Index(jsSource, "var MODULE_STATE_PREFIX") + if whitelistStart == -1 || whitelistEnd <= whitelistStart { + t.Fatal("没有找到模块查询参数白名单") + } + whitelist := jsSource[whitelistStart:whitelistEnd] + for _, forbidden := range []string{"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\""} { + if strings.Contains(whitelist, forbidden) { + t.Errorf("稳定状态白名单不应包含临时参数 %s", forbidden) + } + } +} + func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) { files := map[string][]string{ "static/css/app.css": { diff --git a/admin/static/js/app.js b/admin/static/js/app.js index ba5cbc2..ab8985a 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -9,6 +9,87 @@ (function () { "use strict"; + /* ── 模块导航状态 ──────────────────────────── + 列表筛选和页码本来就在 URL 中。这里按账号、按浏览器标签页记住每个 + 主模块最后一次稳定 URL,让采购员切走再回来时不用重复筛选。 + + 只接受已知主列表路径和查询参数;勾选、弹窗、临时提示及写操作状态 + 都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时 + 仍可正常导航。 */ + var MODULE_QUERY_KEYS = { + "/shopee": ["goods_id", "status", "shop", "image", "page"], + "/pdd": ["q", "status", "page"], + "/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"], + "/tasks": ["type", "status", "creator", "q", "page"], + "/clients": ["name", "page"], + "/users": ["q", "status", "page"] + }; + var MODULE_STATE_PREFIX = "cmautobuy:module-state:"; + + function moduleStateStorageKey(userID, moduleRoot) { + return MODULE_STATE_PREFIX + userID + ":" + moduleRoot; + } + + function stableModuleURL(moduleRoot, rawURL) { + if (!Object.prototype.hasOwnProperty.call(MODULE_QUERY_KEYS, moduleRoot)) return moduleRoot; + if (!rawURL || rawURL.length > 2048) return moduleRoot; + + var parsed; + try { + parsed = new URL(rawURL, window.location.origin); + } catch (error) { + return moduleRoot; + } + if (parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot) return moduleRoot; + + var allowed = MODULE_QUERY_KEYS[moduleRoot]; + var stable = new URLSearchParams(); + parsed.searchParams.forEach(function (value, key) { + if (allowed.indexOf(key) !== -1 && value !== "") stable.set(key, value); + }); + var query = stable.toString(); + return moduleRoot + (query ? "?" + query : ""); + } + + function setupModuleNavigationState() { + var nav = document.querySelector("[data-module-navigation]"); + if (!nav) return; + var userID = nav.getAttribute("data-ui-state-user") || ""; + if (!userID) return; + + var currentRoot = Object.prototype.hasOwnProperty.call(MODULE_QUERY_KEYS, window.location.pathname) + ? window.location.pathname + : ""; + try { + if (currentRoot) { + sessionStorage.setItem( + moduleStateStorageKey(userID, currentRoot), + stableModuleURL(currentRoot, window.location.href) + ); + } + nav.querySelectorAll("[data-module-root]").forEach(function (link) { + var moduleRoot = link.getAttribute("data-module-root") || ""; + var saved = sessionStorage.getItem(moduleStateStorageKey(userID, moduleRoot)); + link.href = stableModuleURL(moduleRoot, saved); + }); + } catch (error) { + /* 隐私模式或浏览器策略可能禁用存储。默认 href 仍是安全回退。 */ + } + + var logoutForm = nav.querySelector(".nav-logout"); + if (logoutForm) { + logoutForm.addEventListener("submit", function () { + try { + Object.keys(MODULE_QUERY_KEYS).forEach(function (moduleRoot) { + sessionStorage.removeItem(moduleStateStorageKey(userID, moduleRoot)); + }); + } catch (error) { + /* 登出不能因为清理界面状态失败而被阻止。 */ + } + }); + } + } + /* ── 全选 / 反选 ────────────────────────────── 表头的勾选框控制本表格所有行的勾选框。 */ function setupCheckAll(root) { @@ -484,6 +565,7 @@ } document.addEventListener("DOMContentLoaded", function () { + setupModuleNavigationState(); document.querySelectorAll("table").forEach(setupCheckAll); setupConfirmDelete(); setupConfirmSubmit(); diff --git a/admin/templates/partials/header.html b/admin/templates/partials/header.html index 2ffa7c6..96b1a45 100644 --- a/admin/templates/partials/header.html +++ b/admin/templates/partials/header.html @@ -8,15 +8,15 @@ -