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:
@ -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))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
111
admin/index.html
111
admin/index.html
@ -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> </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', {
|
||||
|
||||
Reference in New Issue
Block a user