feat(api): 全量配置项暴露为接口 + 后台增加门户卡片编辑框
用户要求:所有可配置项都应当是 API,方便 agent 直接把站点配成用户喜欢的样子。
- 新增 GET /api/schema:一次返回
· items:全部单值配置项(站点信息、登录页/门户页文案、备案号、页脚…),
含当前值、类型、写入口与请求体示例;**当前为空的键也会列出**,否则 agent 发现不了
· resources:结构化资源与其真正的写入口(导航、路由表+apply、门户卡片、壁纸、
门户口令、历史、图标上传、重载),并在 hint 里写明"路由表改完必须 apply"这类约束
- 后台「站点信息」新增「门户页服务卡片」编辑框:载入 / 保存 / 清空即回内置,
与 /api/portal-cards 同一份数据(界面能改的 agent 都能改)
- README 新增「配置接口(agent 可直接改)」:schema 用法、写入口对照表、可直接复制的示例
- 测试:schema 必须列出关键项与写入口;卡片接口写入生效、清空返回 reset 并回落内置
This commit is contained in:
44
README.md
44
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 <NAVI_ADMIN_TOKEN>`(读与后台页面同源,可直接 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 class=\"card\" href=\"https://example.com\">我的服务</a>"}' "$A/api/portal-cards"
|
||||
curl -s -X POST -H "Authorization: Bearer $TOK" "$A/api/gateway/apply"
|
||||
```
|
||||
|
||||
后台页面用同一批接口,所以"界面能改的,agent 都能改"。
|
||||
|
||||
## 部署需要的全部自定义文件
|
||||
|
||||
仓库即完整部署物料,没有额外依赖仓库外的文件:
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(`<a href='https://mine.example.org'>我的服务</a>`); 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)
|
||||
}
|
||||
}
|
||||
|
||||
151
admin-server/schema.go
Normal file
151
admin-server/schema.go
Normal file
@ -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": "<a class=\"card\" href=\"https://example.com\">示例</a>"},
|
||||
"替换门户页 <!--NAVI_CARDS_START/END--> 之间的内容;空串=恢复内置卡片。整页替换可写数据卷 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 <NAVI_ADMIN_TOKEN>(后台页面已登录则可直接用界面)。结构化资源的写入口见 resources。",
|
||||
"items": items,
|
||||
"resources": resources,
|
||||
})
|
||||
}
|
||||
@ -182,6 +182,17 @@ html[data-theme="dark"] .toast{ box-shadow:0 10px 30px rgba(0,0,0,.5) }
|
||||
</div>
|
||||
<p class="hint" style="margin:6px 0 0">整页完全自定义:把 <code>index.html</code> 放进数据卷的 <code>portal/</code> 目录即生效(优先于以上字段)。</p>
|
||||
</details>
|
||||
<details style="margin:12px 0">
|
||||
<summary style="cursor:pointer;font-size:13px;color:#475569">门户页服务卡片(可配置,留空=内置卡片)</summary>
|
||||
<p class="hint" style="margin:8px 0 4px">这里的内容会替换门户页卡片区,改链接无需重新构建镜像;清空并保存即恢复内置卡片。</p>
|
||||
<textarea id="p-cards" rows="6" spellcheck="false"
|
||||
style="width:100%;box-sizing:border-box;font:12px/1.5 ui-monospace,Menlo,monospace;padding:8px;border:1px solid var(--line,#d7dee9);border-radius:8px;background:var(--input-bg,#fff);color:inherit"
|
||||
placeholder='<a class="card" href="https://example.com">示例服务</a>'></textarea>
|
||||
<div class="row" style="margin-top:8px;align-items:center">
|
||||
<button class="btn" id="p-cards-save" type="button">保存卡片</button>
|
||||
<span id="p-cards-msg" class="hint" style="margin-left:8px"></span>
|
||||
</div>
|
||||
</details>
|
||||
<details style="margin:12px 0">
|
||||
<summary style="cursor:pointer;font-size:13px;color:#475569">高级:自定义页脚 HTML</summary>
|
||||
<div class="fg" style="margin-top:8px">
|
||||
@ -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);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user