From 92fd32e7a98826d8c2abbef15d2923f056f570f1 Mon Sep 17 00:00:00 2001 From: HomeDash Bot Date: Sat, 12 Sep 2026 10:19:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=85=A8=E9=87=8F=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B9=E6=9A=B4=E9=9C=B2=E4=B8=BA=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=20+=20=E5=90=8E=E5=8F=B0=E5=A2=9E=E5=8A=A0=E9=97=A8=E6=88=B7?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E7=BC=96=E8=BE=91=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求:所有可配置项都应当是 API,方便 agent 直接把站点配成用户喜欢的样子。 - 新增 GET /api/schema:一次返回 · items:全部单值配置项(站点信息、登录页/门户页文案、备案号、页脚…), 含当前值、类型、写入口与请求体示例;**当前为空的键也会列出**,否则 agent 发现不了 · resources:结构化资源与其真正的写入口(导航、路由表+apply、门户卡片、壁纸、 门户口令、历史、图标上传、重载),并在 hint 里写明"路由表改完必须 apply"这类约束 - 后台「站点信息」新增「门户页服务卡片」编辑框:载入 / 保存 / 清空即回内置, 与 /api/portal-cards 同一份数据(界面能改的 agent 都能改) - README 新增「配置接口(agent 可直接改)」:schema 用法、写入口对照表、可直接复制的示例 - 测试:schema 必须列出关键项与写入口;卡片接口写入生效、清空返回 reset 并回落内置 --- README.md | 44 +++++++++++ admin-server/main.go | 2 + admin-server/pages_test.go | 45 +++++++++++ admin-server/schema.go | 151 +++++++++++++++++++++++++++++++++++++ admin/index.html | 30 ++++++++ 5 files changed, 272 insertions(+) create mode 100644 admin-server/schema.go diff --git a/README.md b/README.md index c34cf79..d968e4b 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,50 @@ curl -sk -o /dev/null -w '%{http_code}\n' --resolve nobody.invalid:3080:127.0.0. https://nobody.invalid:3080/ # 未知域名 → 404 ``` +## 配置接口(agent 可直接改,无需读代码) + +**所有可配置项集中在一个清单里**,agent/脚本按图配置即可,不必猜字段名或路径: + +```bash +curl -s http://127.0.0.1:18080/api/schema | jq # 人读:items + resources +``` + +返回两部分: + +- `items`:单值配置项(站点标题/描述、登录页标题/副标题/徽标、门户页标题/主标题/副标题、 + ICP 与公安备案号、页脚 HTML、页脚导航数组…),每项含 `value`(当前值)、`type`、 + `write.path` 与 `write.bodyExample`。**即使当前为空的键也会列出**,避免"存在但发现不了"。 +- `resources`:结构化资源与它们真正的写入口—— + 导航分组与条目、服务转发路由表、门户页卡片、壁纸、统一登录账号、浏览器历史、图标上传、重载。 + +写操作需要 `Authorization: Bearer `(读与后台页面同源,可直接 GET)。 + +| 想改什么 | 接口 | +| --- | --- | +| 站点信息 / 各页文案 / 备案号 | `POST /api/pageinfo`(字段见 `/api/schema`;空串=回落默认) | +| 门户页服务卡片 | `POST /api/portal-cards` `{"html":"…"}`(空串=恢复内置) | +| 服务转发路由 | `POST /api/gateway/routes` 后**必须** `POST /api/gateway/apply`(失败保留旧配置) | +| 导入现有 nginx 配置 | `POST /api/gateway/import` | +| 导航分组 / 条目 | `POST /api/section`、`/api/section/update`、`DELETE /api/section`、`POST /api/item`、`/api/item/delete` | +| 壁纸 | `POST /api/wallpaper` | +| 门户口令 | `POST /api/portal-auth` | +| 历史记录 | `DELETE /api/history/item?url=…`、`DELETE /api/history` | +| 图标上传 | `POST /api/icon`(multipart,字段 `file`) | +| 重建导航分区 | `POST /api/reload` | + +**示例:让 agent 一次性把站点改成你要的样子** + +```bash +TOK="$NAVI_ADMIN_TOKEN"; A=http://127.0.0.1:18080 +curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ + -d '{"title":"我的导航","heroTitle":"欢迎回家","icp":"","authSubtitle":""}' "$A/api/pageinfo" +curl -s -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \ + -d '{"html":"我的服务"}' "$A/api/portal-cards" +curl -s -X POST -H "Authorization: Bearer $TOK" "$A/api/gateway/apply" +``` + +后台页面用同一批接口,所以"界面能改的,agent 都能改"。 + ## 部署需要的全部自定义文件 仓库即完整部署物料,没有额外依赖仓库外的文件: diff --git a/admin-server/main.go b/admin-server/main.go index 98e26d4..5ba550e 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -1242,6 +1242,8 @@ func main() { http.HandleFunc("/portal-page", handlePortalPage) // 门户页服务卡片片段(数据卷 portal/cards.html),改链接不必重建镜像 http.HandleFunc("/api/portal-cards", cors(handlePortalCards)) + // 全部可配置项 + 写入口清单(供 agent/脚本按图配置) + http.HandleFunc("/api/schema", cors(handleSchema)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/admin-server/pages_test.go b/admin-server/pages_test.go index ff549ab..76a93da 100644 --- a/admin-server/pages_test.go +++ b/admin-server/pages_test.go @@ -1,6 +1,8 @@ package main import ( + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -91,3 +93,46 @@ func TestCardsOverrideReplacesMarkedBlock(t *testing.T) { t.Fatalf("空白片段应回落内置卡片: %s", got) } } + +// /api/schema 必须列出全部可配置项与写入口(agent 靠它按图配置)。 +func TestSchemaListsConfigurableItems(t *testing.T) { + rec := httptest.NewRecorder() + handleSchema(rec, httptest.NewRequest(http.MethodGet, "/api/schema", nil)) + if rec.Code != 200 { + t.Fatalf("期望 200,实际 %d", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{"pageInfo.heroTitle", "pageInfo.icp", "pageInfo.authLogo", + "portalCards", "gatewayRoutes", "portalAuth", "wallpaper", "/api/gateway/apply"} { + if !strings.Contains(body, want) { + t.Fatalf("schema 缺少 %q", want) + } + } +} + +// 卡片接口:写入后读得到,写空串等于重置。 +func TestPortalCardsAPIRoundTrip(t *testing.T) { + dir := t.TempDir() + old := confFile + defer func() { confFile = old }() + confFile = filepath.Join(dir, "conf.yml") + + post := func(html string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/portal-cards", strings.NewReader(`{"html":"`+html+`"}`)) + rec := httptest.NewRecorder() + handlePortalCards(rec, req) + return rec + } + if rec := post(`我的服务`); rec.Code != 200 { + t.Fatalf("写入失败 %d: %s", rec.Code, rec.Body.String()) + } + if got := string(applyCardsOverride(cardsStartMarker + "old" + cardsEndMarker)); !strings.Contains(got, "mine.example.org") { + t.Fatalf("写入后未生效: %s", got) + } + if rec := post(""); rec.Code != 200 || !strings.Contains(rec.Body.String(), "reset") { + t.Fatalf("清空应返回 reset: %d %s", rec.Code, rec.Body.String()) + } + if got := string(applyCardsOverride(cardsStartMarker + "old" + cardsEndMarker)); !strings.Contains(got, "old") { + t.Fatalf("清空后应回落内置: %s", got) + } +} diff --git a/admin-server/schema.go b/admin-server/schema.go new file mode 100644 index 0000000..3ed9658 --- /dev/null +++ b/admin-server/schema.go @@ -0,0 +1,151 @@ +package main + +import ( + "net/http" + "sort" +) + +// schema.go —— 可配置项总目录(给 agent 用) +// +// 目的:让 agent/脚本不必猜就有据可依——把所有可配置项连同**怎么改**(方法、路径、 +// 请求体字段)一次性暴露出来。人用后台界面,agent 用这里的接口,改的是同一份数据。 +// +// GET /api/schema → {"note":…,"items":[…],"resources":[…]} +// +// items : 单值配置项(站点信息、认证页/门户页文案、备案号…),读用 GET、写用 POST。 +// resources : 结构化资源(导航分组与条目、服务转发路由表、门户卡片、壁纸、门户口令、历史…)。 + +// pageInfoFields 描述 conf.yml → pageInfo 下所有可配置文案字段。 +// 列全"即使当前为空"的字段,agent 才能发现它们的存在。 +var pageInfoFields = []struct{ Key, Label, Hint string }{ + {"title", "站点标题", "浏览器标题与导航页标题"}, + {"description", "站点描述", "导航页副标题/搜索页描述"}, + {"authTitle", "登录页标题", "留空回落内置默认"}, + {"authSubtitle", "登录页副标题", "留空回落内置默认"}, + {"authLogo", "登录页徽标", "短文字,如 JF"}, + {"portalTitle", "门户页浏览器标题", "留空回落内置默认"}, + {"heroTitle", "门户页主标题", "留空回落内置默认"}, + {"heroSubtitle", "门户页副标题", "留空回落内置默认"}, + {"icp", "ICP 备案号", "显示于各页页脚;被代理页面也会注入"}, + {"gongan", "公安备案号", "可选,显示于页脚"}, + {"footer", "自定义页脚 HTML", "高级:整段替换页脚内容"}, + {"navLinks", "页脚导航链接数组", "数组,元素形如 {name,url}"}, +} + +type schemaItem struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Value any `json:"value"` + Hint string `json:"hint,omitempty"` + Write struct { + Method string `json:"method"` + Path string `json:"path"` + Body any `json:"bodyExample"` + } `json:"write"` +} + +type schemaResource struct { + ID string `json:"id"` + Label string `json:"label"` + Read struct { + Method string `json:"method"` + Path string `json:"path"` + } `json:"read"` + Write struct { + Method string `json:"method"` + Path string `json:"path"` + Body any `json:"bodyExample,omitempty"` + } `json:"write"` + Hint string `json:"hint,omitempty"` +} + +func newSchemaItem(key, label, typ string, value any, hint, path string, body any) schemaItem { + it := schemaItem{Key: key, Label: label, Type: typ, Value: value, Hint: hint} + it.Write.Method = http.MethodPost + it.Write.Path = path + it.Write.Body = body + return it +} + +// handleSchema 返回全部可配置项及其写入口,供 agent 直接按图配置。 +func handleSchema(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonErr(w, 405, "只支持 GET") + return + } + info := map[string]any{} + if cfg, err := loadConfig(); err == nil && cfg != nil && cfg.PageInfo != nil { + info = cfg.PageInfo + } + + items := make([]schemaItem, 0, len(pageInfoFields)) + for _, f := range pageInfoFields { + typ := "string" + if f.Key == "navLinks" { + typ = "array" + } + items = append(items, newSchemaItem( + "pageInfo."+f.Key, f.Label, typ, info[f.Key], f.Hint, + "/api/pageinfo", map[string]any{f.Key: "<新值>"}, + )) + } + // 实际存在于配置但未在 catalog 里登记的键也一并暴露(避免"改了看不到") + var extra []string + for k := range info { + known := false + for _, f := range pageInfoFields { + if f.Key == k { + known = true + break + } + } + if !known { + extra = append(extra, k) + } + } + sort.Strings(extra) + for _, k := range extra { + items = append(items, newSchemaItem("pageInfo."+k, "自定义键 "+k, "any", info[k], "配置里已存在的额外键", + "/api/pageinfo", map[string]any{k: "<新值>"})) + } + + res := func(id, label, readPath, writePath string, body any, hint string) schemaResource { + var sr schemaResource + sr.ID, sr.Label, sr.Hint = id, label, hint + sr.Read.Method, sr.Read.Path = http.MethodGet, readPath + sr.Write.Method, sr.Write.Path = http.MethodPost, writePath + sr.Write.Body = body + return sr + } + resources := []schemaResource{ + res("navSections", "导航分组与条目", "/api/sections", "/api/item", + map[string]any{"name": "服务名", "url": "https://…", "section": "常用服务", "icon": "auto", "description": "说明"}, + "分组:/api/section(POST 新建、PUT 改名)、/api/section/update、DELETE /api/section;条目:/api/item(POST 新建)、/api/item/delete"), + res("gatewayRoutes", "服务转发路由表", "/api/gateway/routes", "/api/gateway/routes", + map[string]any{"host": "app.example.com", "upstream": "http://127.0.0.1:3000", "requireLogin": true, "websocket": false, "maxBodyMB": 64, "kind": "proxy", "navEnabled": true, "navSection": "常用服务", "navName": "我的应用"}, + "写完后必须 POST /api/gateway/apply 才会生效(内部会 nginx -t 校验,失败保留旧配置)。导入现有 nginx 配置:POST /api/gateway/import"), + res("portalCards", "门户页服务卡片 HTML", "/api/portal-cards", "/api/portal-cards", + map[string]any{"html": "示例"}, + "替换门户页 之间的内容;空串=恢复内置卡片。整页替换可写数据卷 portal/index.html"), + res("wallpaper", "导航页壁纸", "/api/wallpaper", "/api/wallpaper", + map[string]any{"url": "https://…/wall.jpg"}, + "置空字符串表示移除壁纸;深浅色模式下都会保留壁纸"), + res("portalAuth", "统一登录账号", "/api/portal-auth", "/api/portal-auth", + map[string]any{"username": "admin", "password": "<新口令>"}, + "口令以加盐哈希存数据卷,改完旧会话失效"), + res("history", "浏览器历史记录", "/api/history", "/api/history/item", + nil, + "删除单条:DELETE /api/history/item?url=…;清空:DELETE /api/history;同步接口 /api/history/sync 用 NAVI_SYNC_TOKEN"), + res("reload", "重载导航配置", "/api/sync-info", "/api/reload", nil, + "改完 conf.yml 或需要重建导航分区时调用"), + res("icon", "上传图标", "/api/schema", "/api/icon", nil, + "multipart/form-data,字段名 file;支持 png/svg/jpg/ico"), + } + + jsonResp(w, map[string]any{ + "note": "所有可配置项集中于此。读用 GET;写用 POST,需 Authorization: Bearer (后台页面已登录则可直接用界面)。结构化资源的写入口见 resources。", + "items": items, + "resources": resources, + }) +} diff --git a/admin/index.html b/admin/index.html index 25b084f..263b883 100644 --- a/admin/index.html +++ b/admin/index.html @@ -182,6 +182,17 @@ html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }

整页完全自定义:把 index.html 放进数据卷的 portal/ 目录即生效(优先于以上字段)。

+
+ 门户页服务卡片(可配置,留空=内置卡片) +

这里的内容会替换门户页卡片区,改链接无需重新构建镜像;清空并保存即恢复内置卡片。

+ +
+ + +
+
高级:自定义页脚 HTML
@@ -1111,6 +1122,25 @@ document.getElementById('gw-apply').addEventListener('click', async () => { await loadGateway(); await loadPortalAuth(); })(); + +/* 门户页服务卡片:与 /api/portal-cards 对应(agent 可直接调同一接口) */ +async function naviLoadCards(){ + try { const r = await fetch(A + '/portal-cards', { headers: authHeaders() }); + const j = await r.json(); const el = document.getElementById('p-cards'); + if (el) el.value = j.html || ''; } catch (e) {} +} +async function naviSaveCards(){ + const el = document.getElementById('p-cards'), msg = document.getElementById('p-cards-msg'); + try { + const r = await fetch(A + '/portal-cards', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ html: el.value }) }); + const j = await r.json().catch(() => ({})); + msg.textContent = r.ok ? (j.status === 'reset' ? '已恢复内置卡片' : '已保存,刷新门户页即生效') : ('失败:' + (j.error || r.status)); + } catch (e) { msg.textContent = '失败:' + e; } +} +document.addEventListener('DOMContentLoaded', () => { + naviLoadCards(); + const b = document.getElementById('p-cards-save'); if (b) b.addEventListener('click', naviSaveCards); +});