diff --git a/admin-server/main.go b/admin-server/main.go index d26ada9..c0361b9 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -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"}) })) diff --git a/admin/index.html b/admin/index.html index 287ef34..f7e0be6 100644 --- a/admin/index.html +++ b/admin/index.html @@ -104,6 +104,14 @@ details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
+ +
+

历史访问

+

删除单条或清空全部浏览记录;删除后 Dashy 的「历史访问」分组会立即重建。

+
读取中…
+
+
+

添加服务

@@ -300,6 +308,63 @@ document.getElementById('save-info').addEventListener('click', async () => { else toast('保存失败: ' + (await r.text()).slice(0, 120), '#ef4444'); }); +/* ── 历史访问管理 ── */ +let HIST = []; + +/* 标题为空或本身就是 URL 时,显示简洁域名(与服务端 friendlyHistoryTitle 一致)*/ +function friendlyTitle(it) { + const t = (it.title || '').trim(); + if (t && !/^https?:\/\//i.test(t)) return t; + try { return new URL(it.url).hostname.replace(/^www\./, ''); } catch (e) { return t || it.url; } +} + +function renderHistory() { + const el = document.getElementById('hist'); + if (!HIST.length) { el.innerHTML = '
暂无历史记录
'; return; } + el.innerHTML = HIST.map((it, i) => ` +
+
+
${esc(friendlyTitle(it))}
+
${esc(it.url)} · ${Number(it.total_visits) || 0} 次
+
+ +
`).join(''); +} + +async function loadHistory() { + try { + const r = await fetch(A + '/history', { headers: authHeaders() }); + if (!r.ok) throw new Error('HTTP ' + r.status); + const d = await r.json(); + HIST = (d && d.items) || []; + } catch (e) { + HIST = []; + document.getElementById('hist').innerHTML = '
读取历史失败:' + esc(e.message) + '
'; + return; + } + renderHistory(); +} + +document.getElementById('hist').addEventListener('click', async (ev) => { + const btn = ev.target.closest('[data-hist]'); + if (!btn) return; + const it = HIST[Number(btn.dataset.hist)]; + if (!it) return; + if (!confirm('删除这条历史记录?\n' + friendlyTitle(it))) return; + const r = await fetch(A + '/history/item?url=' + encodeURIComponent(it.url), { + method: 'DELETE', headers: authHeaders(), + }); + if (r.ok) { toast('已删除该记录'); await loadHistory(); } + else toast('删除失败: ' + (await r.text()).slice(0, 120), '#ef4444'); +}); + +document.getElementById('clear-hist').addEventListener('click', async () => { + if (!confirm('清空全部浏览历史?此操作无法撤销;下次插件同步会重新累积。')) return; + const r = await fetch(A + '/history', { method: 'DELETE', headers: authHeaders() }); + if (r.ok) { toast('已清空历史'); await loadHistory(); } + else toast('清空失败: ' + (await r.text()).slice(0, 120), '#ef4444'); +}); + /* ── 分组与条目 ── */ async function loadSections() { const r = await fetch(A + '/sections', { headers: authHeaders() }); @@ -507,6 +572,7 @@ document.getElementById('file-wall').addEventListener('change', async (ev) => { await loadPageInfo(); await loadSections(); await loadWallpaper(); + await loadHistory(); })(); diff --git a/history-server/main.go b/history-server/main.go index 4f47022..1b0e1c9 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -81,7 +81,7 @@ func cors(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(204) return @@ -209,6 +209,38 @@ func handleClear(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "cleared"}) } +// handleDeleteItem 删除单条历史(?url=),随后重建 Dashy 分组。 +// 与「清空全部」分开:管理后台需要能逐条删。 +func handleDeleteItem(w http.ResponseWriter, r *http.Request) { + raw := r.URL.Query().Get("url") + if raw == "" { + http.Error(w, "url query parameter is required", 400) + return + } + key, err := normalizeURL(raw) + if err != nil { + http.Error(w, "invalid url", 400) + return + } + + mu.Lock() + _, existed := history[key] + if existed { + delete(history, key) + save() + } + mu.Unlock() + + w.Header().Set("Content-Type", "application/json") + if !existed { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]any{"error": "not found", "url": key}) + return + } + rebuildDashySections() + json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "url": key}) +} + func handleStats(w http.ResponseWriter, r *http.Request) { mu.RLock() defer mu.RUnlock() @@ -593,6 +625,17 @@ func main() { } })) http.HandleFunc("/stats", cors(handleStats)) + // 逐条删除历史(?url=),需 admin token;供管理后台转发调用 + http.HandleFunc("/item", cors(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", 405) + return + } + if !requireToken(w, r, adminToken) { + return + } + handleDeleteItem(w, r) + })) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"})