feat: 保持模块列表筛选和分页状态 (#156)
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -41,6 +41,18 @@
|
||||
但从输入框或弹窗内容开始拖选、在遮罩上松开时不得关闭。遮罩关闭逻辑必须同时确认
|
||||
指针按下和最终点击都发生在遮罩本身,避免误丢用户尚未提交的输入。
|
||||
|
||||
### 2.1 跨模块导航状态
|
||||
|
||||
顶部导航切换六个主模块时,应在当前浏览器标签页内记住每个模块最后一次稳定的
|
||||
搜索、筛选和页码。返回模块时使用带真实查询参数的 URL,因此 F5、复制链接以及
|
||||
浏览器前进、后退仍按浏览器原生行为工作。
|
||||
|
||||
- 状态按登录账号隔离,退出登录时清理当前账号在本标签页的记录。
|
||||
- 只保存模块主列表的白名单查询参数;清除筛选后,模块根地址会覆盖旧记录。
|
||||
- 不保存复选框、弹窗、临时成功或错误提示、未提交表单和敏感信息。
|
||||
- 不跨标签页、浏览器或设备同步;浏览器存储不可用时,导航退回模块根地址。
|
||||
- 导航链接本身必须保留可用的模块根地址,不能依赖 JavaScript 才能进入页面。
|
||||
|
||||
## 3. 表格通用规则
|
||||
|
||||
- `[必须]` 第一列是勾选框,表头有全选。
|
||||
|
||||
Reference in New Issue
Block a user