feat(admin): 历史记录可在后台逐条删除/清空

此前后台**没有任何历史管理入口**,只有一条需要 admin token 的
DELETE /api/history/(清空全部),所以用户根本无法删除记录。

- history-server: 新增 DELETE /item?url=<url> 删除单条并重建分组;
  CORS 允许 Authorization 头
- admin-server: 新增 /admin/api/history(GET 列表 / DELETE 清空)与
  /admin/api/history/item(DELETE 单条),服务端带 token 转发给
  history-server —— 令牌只在容器内部使用,浏览器侧无需保存
- admin UI: 「历史访问」卡片,列出记录 + 逐条 ✕ + 清空全部(均二次确认)
  标题为空或本身是 URL 时显示简洁域名
This commit is contained in:
HomeDash Bot
2026-09-11 17:03:33 +08:00
parent 80b0e5f51e
commit c04d00d4de
3 changed files with 196 additions and 1 deletions

View File

@ -8,6 +8,7 @@ import (
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
@ -30,6 +31,8 @@ var (
requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN"))
// 浏览器插件同步用的令牌(供安装引导页展示,方便用户拷贝)
syncToken = os.Getenv("NAVI_SYNC_TOKEN")
// history-server 地址(管理后台转发历史记录管理请求用)
historyBaseURL = envOr("NAVI_HISTORY_URL", "http://127.0.0.1:3004")
// 本地上传的图标目录(Dashy 通过 /item-icons/ 读取)
iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons"))
// 壁纸目录(nginx 通过 /wallpapers/ 提供)
@ -354,6 +357,71 @@ func jsonResp(w http.ResponseWriter, v any) {
json.NewEncoder(w).Encode(v)
}
// ── 历史记录管理 ──
//
// 门户对 /api/history/ 是放行的(不走登录),所以那条路径的 DELETE 必须自带 token。
// 管理后台改走 /admin/api/history*:门户登录已是关卡,admin token 只在容器内部使用,
// 不必让用户在浏览器里保存它。
func historyProxy(method, path, token string) (*http.Response, error) {
req, err := http.NewRequest(method, historyBaseURL+path, nil)
if err != nil {
return nil, err
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(req)
}
func relayHistory(w http.ResponseWriter, res *http.Response) {
defer res.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(res.StatusCode)
_, _ = io.Copy(w, res.Body)
}
// handleHistoryList 列出历史记录(按访问次数降序)。
func handleHistoryList(w http.ResponseWriter, r *http.Request) {
res, err := historyProxy(http.MethodGet, "/", syncToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryClear 清空全部历史。
func handleHistoryClear(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
res, err := historyProxy(http.MethodDelete, "/", adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
// handleHistoryDeleteItem 删除单条历史。
func handleHistoryDeleteItem(w http.ResponseWriter, r *http.Request) {
if !requireAdminToken(w, r) {
return
}
raw := r.URL.Query().Get("url")
if raw == "" {
jsonErr(w, 400, "url query parameter is required")
return
}
res, err := historyProxy(http.MethodDelete, "/item?url="+url.QueryEscape(raw), adminToken)
if err != nil {
jsonErr(w, 502, "history-server unreachable: "+err.Error())
return
}
relayHistory(w, res)
}
func jsonErr(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
@ -689,6 +757,24 @@ func main() {
http.HandleFunc("/api/icon", cors(handleIconUpload))
http.HandleFunc("/api/wallpaper", cors(handleWallpaper))
http.HandleFunc("/api/sync-info", cors(handleSyncInfo))
// 历史记录管理:列表 / 清空全部 / 删除单条
http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleHistoryList(w, r)
case http.MethodDelete:
handleHistoryClear(w, r)
default:
jsonErr(w, 405, "method not allowed")
}
}))
http.HandleFunc("/api/history/item", cors(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
jsonErr(w, 405, "method not allowed")
return
}
handleHistoryDeleteItem(w, r)
}))
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
jsonResp(w, map[string]string{"status": "saved"})
}))