feat(admin): 导航分组可增 / 可改名 / 可删;初始状态无分组

后台此前只能往已有分组里加服务,分组本身是写死在 conf.yml 里的,
用户无法增删。现在新增「导航分组」卡片:

- POST   /admin/api/section         新增(重名 409、空名 400)
- POST   /admin/api/section/update  改名/换图标
- DELETE /admin/api/section         删除;非空分组必须 force=1(二次确认),
                                    避免误点一下连带删掉一组服务

「历史访问」分组由 history-server 依据浏览记录重建,改名/删除/手工新建
都会被它覆写回来,所以一律 409 并说明原因 —— 否则用户以为删掉了,
下次同步又"长"回来。

初始状态无分组:conf.example.yml 的 sections 改为空数组,后台在两个位置
给空态提示(「还没有任何分组」/「先创建分组」,并把"添加服务"置灰)。

补上原本缺失的一环:rebuildDashySections() 原先只更新已存在的
「历史访问」分组、从不创建它,新装用户永远看不到历史分组;现在缺了就补建
(完全空配置时不补,保持"初始无分组")。

测试覆盖:新增/重名/改历史分组/删历史分组/删非空(无 force 拒绝)/force 删除,
以及删除后的分组名列表断言。
This commit is contained in:
HomeDash Bot
2026-09-11 19:52:19 +08:00
parent f40e54287b
commit 0d9619e446
3 changed files with 361 additions and 5 deletions

View File

@ -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))

View File

@ -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)
}
}

View File

@ -112,6 +112,24 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
<div style="margin-top:14px"><button class="ghost" id="clear-hist">清空全部历史</button></div>
</div>
<!-- 导航分组 -->
<div class="card">
<h2>导航分组</h2>
<p class="sub" style="margin:0 0 12px">
分组就是导航页上的栏目,可以按需增删。初始可以一个都没有。
「历史访问」由浏览记录自动维护,不能改名或删除。
</p>
<div id="sec-list"><div class="muted">读取中…</div></div>
<div class="row" style="margin-top:14px">
<div class="fg" style="max-width:240px"><label>新分组名称</label><input id="sec-name" placeholder="例如 常用服务"></div>
<div class="fg" style="max-width:240px"><label>图标(可留空)</label><input id="sec-icon" placeholder="fas fa-star"></div>
<div class="fg" style="max-width:170px;justify-content:flex-end">
<label>&nbsp;</label>
<button class="primary" id="sec-add">添加分组</button>
</div>
</div>
</div>
<!-- 添加服务 -->
<div class="card">
<h2>添加服务</h2>
@ -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) => `<option value="${i}">${esc(s.name)}</option>`).join('');
document.getElementById('app').innerHTML = S.map((s, si) => `
const sel = document.getElementById('sel');
sel.innerHTML = S.length
? S.map((s, i) => `<option value="${i}">${esc(s.name)}</option>`).join('')
: '<option value="-1">先创建分组</option>';
document.getElementById('do-add').disabled = S.length === 0;
renderSections();
document.getElementById('app').innerHTML = S.length ? S.map((s, si) => `
<div class="card">
<div class="sec-hd">
<span class="sec-nm">${esc(s.name)}</span>
@ -385,8 +407,8 @@ async function loadSections() {
<div class="itm-u">${esc(it.url)}</div>
</div>
<button class="icon-btn danger" data-del="${si}:${ii}">✕</button>
</div>`).join('')}
</div>`).join('');
</div>`).join('') || '<p class="hint" style="margin:6px 0 0">这个分组还没有服务。</p>'}
</div>`).join('') : '<div class="card"><p class="hint" style="margin:0">现在还没有分组。先在上面的「导航分组」里创建一个,再把服务加进去。</p></div>';
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 = '<p class="hint" style="margin:0">还没有任何分组。</p>';
return;
}
box.innerHTML = S.map((s, i) => {
const locked = String(s.name || '').trim() === '历史访问';
return `
<div class="itm">
<div class="itm-i" style="display:flex;gap:8px;flex:1;min-width:0;align-items:center">
<input data-sec-name="${i}" value="${esc(s.name || '')}" placeholder="分组名"
style="max-width:200px" ${locked ? 'disabled' : ''}>
<input data-sec-icon="${i}" value="${esc(s.icon || '')}" placeholder="图标(fas fa-star)"
style="max-width:220px" ${locked ? 'disabled' : ''}>
<span class="badge">${s.items.length}</span>
${locked ? '<span class="hint">自动维护</span>' : ''}
</div>
${locked ? '' : `<button class="icon-btn" data-sec-save="${i}" title="保存改名">保存</button>
<button class="icon-btn danger" data-sec-del="${i}" title="删除分组">✕</button>`}
</div>`;
}).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', {