feat: 保持模块列表筛选和分页状态 (#156)

This commit is contained in:
chengma
2026-08-11 15:18:00 +08:00
parent d7e548a60f
commit cd1fc8ae68
4 changed files with 155 additions and 7 deletions
+54
View File
@@ -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": {
+82
View File
@@ -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();
+7 -7
View File
@@ -8,15 +8,15 @@
</head>
<body>
<nav class="nav">
<nav class="nav" data-module-navigation data-ui-state-user="{{if .CurrentUser}}{{.CurrentUser.UserID}}{{end}}">
<span class="nav-brand">采集采购管理端</span>
<a href="/shopee" class="{{if eq .Active "shopee"}}active{{end}}">蝦皮数据</a>
<a href="/pdd" class="{{if eq .Active "pdd"}}active{{end}}">PDD 商品</a>
<a href="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
<a href="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
<a href="/shopee" data-module-root="/shopee" class="{{if eq .Active "shopee"}}active{{end}}">蝦皮数据</a>
<a href="/pdd" data-module-root="/pdd" class="{{if eq .Active "pdd"}}active{{end}}">PDD 商品</a>
<a href="/syb" data-module-root="/syb" class="{{if eq .Active "syb"}}active{{end}}">顺运宝数据</a>
<a href="/tasks" data-module-root="/tasks" class="{{if eq .Active "tasks"}}active{{end}}">采集采购</a>
<a href="/clients" data-module-root="/clients" class="{{if eq .Active "clients"}}active{{end}}">客户端列表</a>
{{if and .CurrentUser .CurrentUser.IsAdmin}}
<a href="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
<a href="/users" data-module-root="/users" class="{{if eq .Active "users"}}active{{end}}">用户管理</a>
{{end}}
{{if .CurrentUser}}
<span class="nav-user">{{.CurrentUser.Username}}</span>
+12
View File
@@ -41,6 +41,18 @@
但从输入框或弹窗内容开始拖选、在遮罩上松开时不得关闭。遮罩关闭逻辑必须同时确认
指针按下和最终点击都发生在遮罩本身,避免误丢用户尚未提交的输入。
### 2.1 跨模块导航状态
顶部导航切换六个主模块时,应在当前浏览器标签页内记住每个模块最后一次稳定的
搜索、筛选和页码。返回模块时使用带真实查询参数的 URL,因此 F5、复制链接以及
浏览器前进、后退仍按浏览器原生行为工作。
- 状态按登录账号隔离,退出登录时清理当前账号在本标签页的记录。
- 只保存模块主列表的白名单查询参数;清除筛选后,模块根地址会覆盖旧记录。
- 不保存复选框、弹窗、临时成功或错误提示、未提交表单和敏感信息。
- 不跨标签页、浏览器或设备同步;浏览器存储不可用时,导航退回模块根地址。
- 导航链接本身必须保留可用的模块根地址,不能依赖 JavaScript 才能进入页面。
## 3. 表格通用规则
- `[必须]` 第一列是勾选框,表头有全选。