diff --git a/admin-server/main.go b/admin-server/main.go index 1cb90b8..9aea8b6 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -531,6 +531,172 @@ func handleSections(w http.ResponseWriter, r *http.Request) { jsonResp(w, cfg.Sections) } +// ── 分组(section)增 / 改 / 删 ── +// +// 「历史访问」分组由 history-server 依据浏览记录重建,改名或删除都会被它 +// 覆写回来;这里明确拒绝,避免用户以为删掉了、下次同步又“长”回来。 + +const historySectionName = "历史访问" + +func isHistorySection(s Section) bool { + return strings.TrimSpace(s.Name) == historySectionName +} + +func handleAddSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Name string `json:"name"` + Icon string `json:"icon"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + jsonErr(w, 400, "分组名不能为空") + return + } + if name == historySectionName { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能手工创建") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + for _, s := range cfg.Sections { + if strings.TrimSpace(s.Name) == name { + jsonErr(w, 409, "已存在同名分组") + return + } + } + icon := strings.TrimSpace(req.Icon) + if icon == "" { + icon = "fas fa-star" + } + cfg.Sections = append(cfg.Sections, Section{Name: name, Icon: icon}) + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + +func handleUpdateSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Index int `json:"index"` + Name string `json:"name"` + Icon string `json:"icon"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if req.Index < 0 || req.Index >= len(cfg.Sections) { + jsonErr(w, 400, "分组不存在") + return + } + if isHistorySection(cfg.Sections[req.Index]) { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能修改") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + jsonErr(w, 400, "分组名不能为空") + return + } + if name == historySectionName { + jsonErr(w, 409, "不能使用分组名「"+historySectionName+"」(它由浏览历史自动维护)") + return + } + for i, s := range cfg.Sections { + if i != req.Index && strings.TrimSpace(s.Name) == name { + jsonErr(w, 409, "已存在同名分组") + return + } + } + cfg.Sections[req.Index].Name = name + if icon := strings.TrimSpace(req.Icon); icon != "" { + cfg.Sections[req.Index].Icon = icon + } + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + +// handleDeleteSection 删除分组。默认拒绝删除非空分组(需 force=1), +// 避免误点一下就把一组服务连带删掉。 +func handleDeleteSection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + var req struct { + Index int `json:"index"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + cfg, err := loadConfig() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + if req.Index < 0 || req.Index >= len(cfg.Sections) { + jsonErr(w, 400, "分组不存在") + return + } + if isHistorySection(cfg.Sections[req.Index]) { + jsonErr(w, 409, "「"+historySectionName+"」由浏览历史自动维护,不能删除") + return + } + if n := len(cfg.Sections[req.Index].Items); n > 0 && r.URL.Query().Get("force") != "1" { + jsonErr(w, 409, fmt.Sprintf("该分组下还有 %d 个服务,删除会一并移除;确认请加 force=1", n)) + return + } + cfg.Sections = append(cfg.Sections[:req.Index], cfg.Sections[req.Index+1:]...) + if err := saveConfig(cfg); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg.Sections) +} + func handleAddItem(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, 405, "method not allowed") @@ -845,6 +1011,17 @@ func main() { http.HandleFunc("/api/sections", cors(handleSections)) http.HandleFunc("/api/item", cors(handleAddItem)) http.HandleFunc("/api/item/delete", cors(handleDeleteItem)) + http.HandleFunc("/api/section", cors(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + handleAddSection(w, r) + case http.MethodDelete: + handleDeleteSection(w, r) + default: + jsonErr(w, 405, "method not allowed") + } + })) + http.HandleFunc("/api/section/update", cors(handleUpdateSection)) http.HandleFunc("/api/pageinfo", cors(handlePageInfo)) http.HandleFunc("/api/icon", cors(handleIconUpload)) http.HandleFunc("/api/wallpaper", cors(handleWallpaper)) diff --git a/admin-server/main_test.go b/admin-server/main_test.go index 8a2010a..2c4cb6e 100644 --- a/admin-server/main_test.go +++ b/admin-server/main_test.go @@ -3,6 +3,8 @@ package main import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" ) @@ -164,3 +166,79 @@ func TestFrameableVerdictWithoutCookieIsInconclusive(t *testing.T) { t.Fatalf("无 Cookie 应判为无法确认,got %+v", without) } } + +// 分组增删改:历史分组受保护;删非空分组必须显式 force。 +func TestSectionHandlers(t *testing.T) { + dir := t.TempDir() + oldConf := confFile + confFile = filepath.Join(dir, "conf.yml") + defer func() { confFile = oldConf }() + + orig := `appConfig: {} +pageInfo: {} +sections: + - name: 常用服务 + icon: fas fa-star + items: + - title: A + url: https://a.example + - name: 历史访问 + icon: fas fa-history + items: [] +` + if err := os.WriteFile(confFile, []byte(orig), 0o644); err != nil { + t.Fatal(err) + } + + call := func(h http.HandlerFunc, method, target, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + rec := httptest.NewRecorder() + h(rec, req) + return rec + } + + // 新增 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 200 { + t.Fatalf("新增分组应成功,got %d %s", rec.Code, rec.Body.String()) + } + // 重名 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 409 { + t.Fatalf("同名分组应 409,got %d", rec.Code) + } + // 不能手工创建历史分组 + if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"历史访问"}`); rec.Code != 409 { + t.Fatalf("手工创建历史分组应 409,got %d", rec.Code) + } + // 改名 + if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":2,"name":"改名后"}`); rec.Code != 200 { + t.Fatalf("改名应成功,got %d %s", rec.Code, rec.Body.String()) + } + // 历史分组不能改名 / 删除 + if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":1,"name":"X"}`); rec.Code != 409 { + t.Fatalf("改历史分组应 409,got %d", rec.Code) + } + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":1}`); rec.Code != 409 { + t.Fatalf("删历史分组应 409,got %d", rec.Code) + } + // 非空分组不给 force 应 409 + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":0}`); rec.Code != 409 { + t.Fatalf("删非空分组应 409,got %d", rec.Code) + } + // 给 force 才删 + if rec := call(handleDeleteSection, http.MethodDelete, "/api/section?force=1", `{"index":0}`); rec.Code != 200 { + t.Fatalf("force 删除应成功,got %d %s", rec.Code, rec.Body.String()) + } + + cfg, err := loadConfig() + if err != nil { + t.Fatal(err) + } + names := make([]string, 0, len(cfg.Sections)) + for _, s := range cfg.Sections { + names = append(names, s.Name) + } + want := []string{"历史访问", "改名后"} + if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] { + t.Fatalf("删除/改名后分组 = %v; want %v", names, want) + } +} diff --git a/admin/index.html b/admin/index.html index f7e0be6..8cbadfa 100644 --- a/admin/index.html +++ b/admin/index.html @@ -112,6 +112,24 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
+ +
+

导航分组

+

+ 分组就是导航页上的栏目,可以按需增删。初始可以一个都没有。 + 「历史访问」由浏览记录自动维护,不能改名或删除。 +

+
读取中…
+
+
+
+
+ + +
+
+
+

添加服务

@@ -369,9 +387,13 @@ document.getElementById('clear-hist').addEventListener('click', async () => { async function loadSections() { const r = await fetch(A + '/sections', { headers: authHeaders() }); S = await r.json(); - document.getElementById('sel').innerHTML = - S.map((s, i) => ``).join(''); - document.getElementById('app').innerHTML = S.map((s, si) => ` + const sel = document.getElementById('sel'); + sel.innerHTML = S.length + ? S.map((s, i) => ``).join('') + : ''; + document.getElementById('do-add').disabled = S.length === 0; + renderSections(); + document.getElementById('app').innerHTML = S.length ? S.map((s, si) => `
${esc(s.name)} @@ -385,8 +407,8 @@ async function loadSections() {
${esc(it.url)}
-
`).join('')} -
`).join(''); + `).join('') || '

这个分组还没有服务。

'} + `).join('') : '

现在还没有分组。先在上面的「导航分组」里创建一个,再把服务加进去。

'; document.querySelectorAll('button[data-del]').forEach(el => { el.addEventListener('click', () => { @@ -394,8 +416,87 @@ async function loadSections() { delItem(si, ii); }); }); + + document.querySelectorAll('button[data-sec-save]').forEach(el => { + el.addEventListener('click', () => saveSection(+el.dataset.secSave)); + }); + document.querySelectorAll('button[data-sec-del]').forEach(el => { + el.addEventListener('click', () => delSection(+el.dataset.secDel)); + }); } +/* 分组管理列表:可改名、可删;「历史访问」是系统分组,界面上直接锁住。 */ +function renderSections() { + const box = document.getElementById('sec-list'); + if (!S.length) { + box.innerHTML = '

还没有任何分组。

'; + return; + } + box.innerHTML = S.map((s, i) => { + const locked = String(s.name || '').trim() === '历史访问'; + return ` +
+
+ + + ${s.items.length} + ${locked ? '自动维护' : ''} +
+ ${locked ? '' : ` + `} +
`; + }).join(''); +} + +async function addSection() { + const name = document.getElementById('sec-name').value.trim(); + if (!name) { toast('请填写分组名称', '#ef4444'); return; } + const r = await fetch(A + '/section', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name: name, icon: document.getElementById('sec-icon').value.trim() }), + }); + if (r.ok) { + toast('已添加分组'); + document.getElementById('sec-name').value = ''; + document.getElementById('sec-icon').value = ''; + await loadSections(); + } else { + toast('添加失败: ' + (await r.text()).slice(0, 140), '#ef4444'); + } +} + +async function saveSection(i) { + const nameEl = document.querySelector(`input[data-sec-name="${i}"]`); + const iconEl = document.querySelector(`input[data-sec-icon="${i}"]`); + const r = await fetch(A + '/section/update', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ index: i, name: nameEl.value.trim(), icon: iconEl.value.trim() }), + }); + if (r.ok) { toast('已保存'); await loadSections(); } + else toast('保存失败: ' + (await r.text()).slice(0, 140), '#ef4444'); +} + +async function delSection(i) { + const s = S[i]; + if (!s) return; + if (!confirm('删除分组「' + s.name + '」?')) return; + const n = (s.items || []).length; + if (n > 0 && !confirm('该分组下还有 ' + n + ' 个服务,会一并删除。\n确定继续?')) return; + const r = await fetch(A + '/section' + (n > 0 ? '?force=1' : ''), { + method: 'DELETE', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ index: i }), + }); + if (r.ok) { toast('已删除分组'); await loadSections(); } + else toast('删除失败: ' + (await r.text()).slice(0, 140), '#ef4444'); +} + +document.getElementById('sec-add').addEventListener('click', addSection); + async function delItem(si, ii) { if (!confirm('确定删除「' + S[si].items[ii].title + '」?')) return; const r = await fetch(A + '/item/delete', {