fix(admin,theme): make admin actually work; glass theme targets real DOM
后台之前有两处让它完全不可用的问题(都属于「看着有、实际不工作」):
1. 写操作 401 —— admin UI 从未带 Authorization 头,而 admin-server 强制校验
NAVI_ADMIN_TOKEN。于是添加/删除一直静默失败。
2. JSON 键名不匹配 —— Section/NavItem 只有 yaml tag,没有 json tag,
接口返回 {"Name":..,"Items":[{"Title":..}]},而前端读 s.name / it.title,
渲染直接抛异常(菜单区永远停在「加载中…」)。
修法:
- admin-server:token 改为**默认不校验**(/admin/ 已在外层门户登录墙内,
用户确认无需叠加),仅当显式设 NAVI_ADMIN_REQUIRE_TOKEN=1 时才校验;
历史 DELETE 不受影响(那条路径被门户放行,仍必须自带 token)。
- 补 json tag;Config/Section/NavItem 加 yaml ",inline" 保留未知键,
避免保存 conf.yml 时把不认识的配置(layout/pages/item 的 target 等)丢掉。
- 新增 /api/pageinfo(标题/副标题/页脚/顶部导航可在后台编辑)与
/api/icon(图标上传,限 png/svg/jpg,512KB)。
- 重写 admin UI:站点信息 + 顶部导航编辑、图标上传(.ico 用 canvas 转 PNG,
因为 Dashy 的 isImage() 只认 svg/png/jpg)、可选令牌。
主题:旧 customCss 大部分是死代码 —— .tile / .item-group / .item-group-outer
在页面里根本不存在(querySelectorAll 计数为 0),卡片灰底其实来自 a.item,
而 .home 的不透明背景盖住了 body 渐变,所以玻璃效果一直看不出来。
现按真实类名(a.item / .item-wrapper / .collapsable / .collapsible-content /
.home / header)重写,并加入工作空间侧边栏的溢出修复。
安装页:.btn 自带 display 会盖掉浏览器默认的 [hidden]{display:none},
未配置商店时「一键安装」按钮仍然可见且 href 为空(点了没反应)。
现显式兜住 [hidden],并把浏览器识别补全(iOS/Android 单独提示为不可安装、
Edge/Brave/Vivaldi/Opera 给出各自的 extensions 地址、优先用 userAgentData)。
This commit is contained in:
@ -1,13 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
@ -15,9 +19,37 @@ import (
|
||||
var (
|
||||
confFile = envOr("DASHY_CONF", "/app/data/conf.yml")
|
||||
adminToken = os.Getenv("NAVI_ADMIN_TOKEN")
|
||||
mu sync.Mutex
|
||||
// requireTokenForWrite:是否需要额外的 Bearer token。
|
||||
//
|
||||
// /admin/ 在生产里整体位于外层统一认证反向代理(portal-nginx 的 auth_request)
|
||||
// 之后,再叠一层 token 是重复的,而且会让 admin UI 的写操作静默 401。
|
||||
// 所以默认**不校验**;只有当你把容器端口直接暴露给不受信的网段、
|
||||
// 而没有外层认证时才设 NAVI_ADMIN_REQUIRE_TOKEN=1。
|
||||
requireTokenForWrite = isTruthy(os.Getenv("NAVI_ADMIN_REQUIRE_TOKEN"))
|
||||
// 本地上传的图标目录(Dashy 通过 /item-icons/ 读取)
|
||||
iconDir = envOr("NAVI_ICON_DIR", filepath.Join(filepath.Dir(confFile), "item-icons"))
|
||||
mu sync.Mutex
|
||||
)
|
||||
|
||||
func isTruthy(v string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// requireAdminToken 仅在显式开启时才校验。
|
||||
func requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !requireTokenForWrite {
|
||||
return true
|
||||
}
|
||||
if adminToken == "" {
|
||||
return true // 开了开关却没配 token,等同于不校验
|
||||
}
|
||||
return requireToken(w, r, adminToken)
|
||||
}
|
||||
|
||||
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
|
||||
if expected == "" {
|
||||
return true // 未配置 token 则不校验
|
||||
@ -31,25 +63,30 @@ func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AppConfig map[string]any `yaml:"appConfig"`
|
||||
PageInfo map[string]any `yaml:"pageInfo"`
|
||||
Sections []Section `yaml:"sections"`
|
||||
Pages []any `yaml:"pages,omitempty"`
|
||||
AppConfig map[string]any `yaml:"appConfig" json:"appConfig"`
|
||||
PageInfo map[string]any `yaml:"pageInfo" json:"pageInfo"`
|
||||
Sections []Section `yaml:"sections" json:"sections"`
|
||||
// Extra 用 yaml 的 inline 机制原样保留本程序不认识的顶层键(如 layout、
|
||||
// iconSize、pages 等)。admin 保存 conf.yml 时不能把没碰过的配置丢掉。
|
||||
Extra map[string]any `yaml:",inline" json:"-"`
|
||||
}
|
||||
|
||||
type Section struct {
|
||||
Name string `yaml:"name"`
|
||||
Icon string `yaml:"icon,omitempty"`
|
||||
Collapsed *bool `yaml:"collapsed,omitempty"`
|
||||
Items []NavItem `yaml:"items"`
|
||||
Name string `yaml:"name" json:"name"`
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
|
||||
Collapsed *bool `yaml:"collapsed,omitempty" json:"collapsed,omitempty"`
|
||||
Items []NavItem `yaml:"items" json:"items"`
|
||||
Extra map[string]any `yaml:",inline" json:"-"`
|
||||
}
|
||||
|
||||
type NavItem struct {
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Icon string `yaml:"icon,omitempty"`
|
||||
URL string `yaml:"url"`
|
||||
StatusCheck *bool `yaml:"statusCheck,omitempty"`
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Icon string `yaml:"icon,omitempty" json:"icon,omitempty"`
|
||||
URL string `yaml:"url" json:"url"`
|
||||
StatusCheck *bool `yaml:"statusCheck,omitempty" json:"statusCheck,omitempty"`
|
||||
// Dashy 的 item 还可能有 target / hotkey / color 等字段,一并保留
|
||||
Extra map[string]any `yaml:",inline" json:"-"`
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
@ -87,7 +124,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
|
||||
@ -123,7 +160,7 @@ func handleAddItem(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !requireToken(w, r, adminToken) {
|
||||
if !requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
@ -169,7 +206,7 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !requireToken(w, r, adminToken) {
|
||||
if !requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
@ -206,6 +243,159 @@ func handleDeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResp(w, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// ── 站点信息:标题 / 副标题 / 顶部导航 / 页脚 ──
|
||||
// 这些东西原先只能在 conf.yml 里手改,现在可在 /admin/ 编辑。
|
||||
func handlePageInfo(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if cfg.PageInfo == nil {
|
||||
cfg.PageInfo = map[string]any{}
|
||||
}
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
jsonResp(w, cfg.PageInfo)
|
||||
case http.MethodPost:
|
||||
if !requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Footer *string `json:"footer"`
|
||||
NavLinks *[]map[string]any `json:"navLinks"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
cfg.PageInfo["title"] = *body.Title
|
||||
}
|
||||
if body.Description != nil {
|
||||
cfg.PageInfo["description"] = *body.Description
|
||||
}
|
||||
if body.Footer != nil {
|
||||
cfg.PageInfo["footer"] = *body.Footer
|
||||
}
|
||||
if body.NavLinks != nil {
|
||||
cfg.PageInfo["navLinks"] = *body.NavLinks
|
||||
}
|
||||
if err := saveConfig(cfg); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, cfg.PageInfo)
|
||||
default:
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 图标上传 ──
|
||||
//
|
||||
// 只接受 Dashy 能渲染的格式:其 isImage() 白名单是 [svg,png,jpg]。
|
||||
// .ico 渲染不出来,由前端先用 canvas 转成 PNG 再上传。
|
||||
const iconMaxBytes = 512 << 10
|
||||
|
||||
func handleIconUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
jsonErr(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
if !requireAdminToken(w, r) {
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<20)
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
jsonErr(w, 400, "解析上传失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
file, hdr, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonErr(w, 400, "缺少 file 字段")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, iconMaxBytes+1))
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if len(data) > iconMaxBytes {
|
||||
jsonErr(w, 400, "图标过大(上限 512KB)")
|
||||
return
|
||||
}
|
||||
ext, ok := detectIconExt(data)
|
||||
if !ok {
|
||||
jsonErr(w, 400, "只支持 png / svg / jpg(.ico 请在页面上选择,会自动转成 png)")
|
||||
return
|
||||
}
|
||||
name := safeIconName(r.FormValue("name"), hdr.Filename, ext)
|
||||
if err := os.MkdirAll(iconDir, 0755); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(iconDir, name), data, 0644); err != nil {
|
||||
jsonErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
jsonResp(w, map[string]string{"icon": name})
|
||||
}
|
||||
|
||||
// detectIconExt 以**正文特征**判定类型,不信任上传的文件名/Cotent-Type。
|
||||
func detectIconExt(data []byte) (string, bool) {
|
||||
switch {
|
||||
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
||||
return ".png", true
|
||||
case bytes.HasPrefix(data, []byte("\xff\xd8\xff")):
|
||||
return ".jpg", true
|
||||
}
|
||||
trim := bytes.TrimSpace(bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}))
|
||||
low := bytes.ToLower(trim)
|
||||
if len(low) > 512 {
|
||||
low = low[:512]
|
||||
}
|
||||
if bytes.HasPrefix(low, []byte("<svg")) ||
|
||||
(bytes.HasPrefix(low, []byte("<?xml")) && bytes.Contains(low, []byte("<svg"))) {
|
||||
return ".svg", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// safeIconName 用「提示名 + 时间戳」生成安全文件名:时间戳兼做缓存失效,
|
||||
// 避免换图后 Dashy 或浏览器还沿用旧图。
|
||||
func safeIconName(hint, filename, ext string) string {
|
||||
base := strings.TrimSpace(hint)
|
||||
if base == "" {
|
||||
base = filename
|
||||
}
|
||||
base = strings.ToLower(strings.TrimSuffix(base, filepath.Ext(base)))
|
||||
var b strings.Builder
|
||||
for _, r := range base {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
b.WriteRune(r)
|
||||
case r == '.' || r == ' ':
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
if s == "" {
|
||||
s = "icon"
|
||||
}
|
||||
if len(s) > 48 {
|
||||
s = s[:48]
|
||||
}
|
||||
return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Ensure conf file exists
|
||||
if _, err := os.Stat(confFile); os.IsNotExist(err) {
|
||||
@ -222,6 +412,8 @@ func main() {
|
||||
http.HandleFunc("/api/sections", cors(handleSections))
|
||||
http.HandleFunc("/api/item", cors(handleAddItem))
|
||||
http.HandleFunc("/api/item/delete", cors(handleDeleteItem))
|
||||
http.HandleFunc("/api/pageinfo", cors(handlePageInfo))
|
||||
http.HandleFunc("/api/icon", cors(handleIconUpload))
|
||||
http.HandleFunc("/api/reload", cors(func(w http.ResponseWriter, r *http.Request) {
|
||||
jsonResp(w, map[string]string{"status": "saved"})
|
||||
}))
|
||||
|
||||
404
admin/index.html
404
admin/index.html
@ -6,57 +6,387 @@
|
||||
<title>导航管理</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:system-ui,-apple-system,sans-serif;background:#f0f9ff;min-height:100vh;padding:24px}
|
||||
.wrap{max-width:800px;margin:0 auto}
|
||||
h1{font-size:22px;font-weight:800;color:#0f172a;margin-bottom:16px}
|
||||
.card{background:rgba(255,255,255,.9);border-radius:14px;padding:18px;margin-bottom:14px;border:1px solid rgba(0,0,0,.06);box-shadow:0 2px 8px rgba(0,0,0,.04)}
|
||||
:root{
|
||||
--bd:rgba(15,23,42,.10); --bg-card:rgba(255,255,255,.72);
|
||||
--tx:#0f172a; --tx2:#475569; --tx3:#94a3b8; --ac:#2563eb; --danger:#dc2626;
|
||||
--r:16px; --r-sm:10px;
|
||||
}
|
||||
body{
|
||||
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
|
||||
color:var(--tx); min-height:100vh; padding:28px 18px 64px;
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -10%, #e0f2fe 0%, transparent 60%),
|
||||
radial-gradient(900px 520px at 88% 0%, #ede9fe 0%, transparent 55%),
|
||||
linear-gradient(160deg,#f8fafc,#eef2ff 55%,#f0fdfa);
|
||||
background-attachment:fixed;
|
||||
}
|
||||
.wrap{max-width:920px;margin:0 auto}
|
||||
h1{font-size:22px;font-weight:800;letter-spacing:.2px;margin-bottom:4px}
|
||||
.sub{color:var(--tx2);font-size:13px;margin-bottom:22px}
|
||||
.card{
|
||||
background:var(--bg-card); border:1px solid var(--bd); border-radius:var(--r);
|
||||
padding:18px 20px; margin-bottom:16px;
|
||||
backdrop-filter:blur(14px) saturate(150%); -webkit-backdrop-filter:blur(14px) saturate(150%);
|
||||
box-shadow:0 8px 28px rgba(15,23,42,.07),0 1px 2px rgba(15,23,42,.04);
|
||||
}
|
||||
.card h2{font-size:15px;font-weight:700;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.badge{background:var(--ac);color:#fff;font-size:11px;padding:2px 8px;border-radius:999px;font-weight:600}
|
||||
.row{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.fg{flex:1;min-width:190px;display:flex;flex-direction:column;gap:5px}
|
||||
label{font-size:12px;font-weight:600;color:var(--tx2)}
|
||||
input,select,textarea{
|
||||
font:inherit;font-size:14px;color:var(--tx);padding:9px 12px;
|
||||
background:rgba(255,255,255,.85);border:1px solid var(--bd);border-radius:var(--r-sm);outline:none;
|
||||
transition:border-color .15s,box-shadow .15s;
|
||||
}
|
||||
input:focus,select:focus,textarea:focus{border-color:var(--ac);box-shadow:0 0 0 3px rgba(37,99,235,.13)}
|
||||
textarea{resize:vertical;min-height:64px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12.5px}
|
||||
button{
|
||||
font:inherit;font-size:13.5px;font-weight:600;padding:9px 16px;border:1px solid var(--bd);
|
||||
border-radius:var(--r-sm);background:rgba(255,255,255,.9);color:var(--tx);cursor:pointer;
|
||||
transition:.15s;
|
||||
}
|
||||
button:hover{border-color:#cbd5e1;transform:translateY(-1px)}
|
||||
button.primary{background:var(--ac);border-color:var(--ac);color:#fff}
|
||||
button.primary:hover{background:#1d4ed8}
|
||||
button.ghost{background:transparent}
|
||||
button.icon-btn{padding:5px 9px;font-size:12px}
|
||||
button.danger{color:var(--danger);border-color:rgba(220,38,38,.3)}
|
||||
button.danger:hover{background:rgba(220,38,38,.08)}
|
||||
.sec-hd{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
|
||||
.sec-nm{font-weight:700;color:#0f172a}
|
||||
.badge{background:#0ea5e9;color:#fff;font-size:11px;padding:2px 7px;border-radius:9px;font-weight:600}
|
||||
.itm{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:10px;margin-bottom:4px;background:rgba(255,255,255,.5);transition:.15s}
|
||||
.itm:hover{background:rgba(255,255,255,.85)}
|
||||
.sec-nm{font-weight:700;font-size:14px}
|
||||
.itm{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:var(--r-sm);margin-bottom:5px;background:rgba(255,255,255,.6);border:1px solid transparent;transition:.15s}
|
||||
.itm:hover{background:rgba(255,255,255,.95);border-color:var(--bd)}
|
||||
.itm img{width:22px;height:22px;object-fit:contain;border-radius:5px;flex:0 0 auto}
|
||||
.itm .ico-fa{width:22px;text-align:center;color:var(--tx3);flex:0 0 auto}
|
||||
.itm-i{flex:1;min-width:0}
|
||||
.itm-t{font-weight:600;color:#1e293b;font-size:13px}
|
||||
.itm-u{color:#64748b;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.btn-x{background:none;border:none;color:#ef4444;cursor:pointer;font-size:16px;padding:2px 6px;border-radius:6px}
|
||||
.btn-x:hover{background:rgba(239,68,68,.1)}
|
||||
.frm{background:rgba(255,255,255,.9);border-radius:14px;padding:18px;margin-top:18px;border:1px solid rgba(0,0,0,.06)}
|
||||
.frm h2{font-size:16px;font-weight:700;margin-bottom:12px}
|
||||
.row{display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap}
|
||||
.fg{display:flex;flex-direction:column;gap:3px;flex:1;min-width:180px}
|
||||
.fg label{font-size:12px;font-weight:600;color:#334155}
|
||||
.fg input,.fg select{padding:8px 12px;border-radius:10px;border:1px solid rgba(0,0,0,.1);font-size:13px}
|
||||
.btn-add{background:linear-gradient(135deg,#0ea5e9,#38bdf8);color:#fff;border:none;padding:10px 24px;border-radius:10px;font-weight:700;cursor:pointer;margin-top:6px}
|
||||
.toast{position:fixed;top:16px;right:16px;background:#22c55e;color:#fff;padding:10px 16px;border-radius:10px;font-weight:600;z-index:9999;animation:sl .3s}
|
||||
@keyframes sl{from{transform:translateX(100%);opacity:0}to{transform:none;opacity:1}}
|
||||
.itm-t{font-weight:600;font-size:13.5px}
|
||||
.itm-u{color:var(--tx3);font-size:11.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.link-row{display:flex;gap:8px;align-items:center;margin-bottom:6px}
|
||||
.link-row input{flex:1}
|
||||
.hint{font-size:12px;color:var(--tx3);line-height:1.6}
|
||||
.toast{
|
||||
position:fixed;left:50%;bottom:28px;transform:translateX(-50%);
|
||||
padding:11px 20px;border-radius:999px;color:#fff;font-size:13.5px;font-weight:600;
|
||||
box-shadow:0 10px 30px rgba(15,23,42,.22);z-index:99;animation:pop .18s ease-out;
|
||||
}
|
||||
@keyframes pop{from{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%,0)}}
|
||||
.preview{display:flex;align-items:center;gap:8px;margin-top:6px}
|
||||
.preview img{width:26px;height:26px;object-fit:contain;border-radius:6px;border:1px solid var(--bd);background:#fff}
|
||||
details summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--tx2)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1> 导航管理</h1>
|
||||
<div id="app"><p style="color:#64748b;text-align:center;padding:32px">加载中…</p></div>
|
||||
<div class="frm">
|
||||
<h2> 添加导航项</h2>
|
||||
<div class="row"><div class="fg"><label>所属分组</label><select id="sel"></select></div></div>
|
||||
<h1>导航管理</h1>
|
||||
<p class="sub">站点信息、顶部导航与各分组服务。图标支持 png / svg / jpg,选择 .ico 会自动转成 png。</p>
|
||||
|
||||
<!-- 站点信息 -->
|
||||
<div class="card">
|
||||
<h2>站点信息</h2>
|
||||
<div class="row">
|
||||
<div class="fg"><label>标题</label><input id="p-title" placeholder="JianF 统一门户"></div>
|
||||
<div class="fg"><label>副标题</label><input id="p-desc" placeholder="内网服务导航"></div>
|
||||
</div>
|
||||
<div class="fg" style="margin-bottom:12px">
|
||||
<label>页脚(可含 HTML)</label>
|
||||
<textarea id="p-footer" placeholder="留空则不显示"></textarea>
|
||||
</div>
|
||||
<label>顶部导航链接</label>
|
||||
<div id="navlinks" style="margin:8px 0 10px"></div>
|
||||
<button class="ghost icon-btn" id="add-link">+ 添加链接</button>
|
||||
<div style="margin-top:14px"><button class="primary" id="save-info">保存站点信息</button></div>
|
||||
</div>
|
||||
|
||||
<!-- 添加服务 -->
|
||||
<div class="card">
|
||||
<h2>添加服务</h2>
|
||||
<div class="row">
|
||||
<div class="fg" style="max-width:220px"><label>所属分组</label><select id="sel"></select></div>
|
||||
<div class="fg"><label>名称 *</label><input id="ti" placeholder="Gitea"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="fg"><label>名称 *</label><input id="ti" placeholder="Gitea" required></div>
|
||||
<div class="fg"><label>描述</label><input id="de" placeholder="代码仓库"></div>
|
||||
<div class="fg"><label>URL *</label><input id="ur" placeholder="https://example.com"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="fg"><label>URL *</label><input id="ur" placeholder="https://example.com" required></div>
|
||||
<div class="fg" style="max-width:180px"><label>图标</label><input id="ic" placeholder="fas fa-star"></div>
|
||||
<div class="fg"><label>图标</label><input id="ic" placeholder="文件名 / fas fa-star / 留空用 favicon"></div>
|
||||
<div class="fg" style="max-width:170px;justify-content:flex-end">
|
||||
<label> </label>
|
||||
<button id="pick-icon">上传图片</button>
|
||||
<input type="file" id="file-icon" accept="image/*,.ico" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-add" onclick="add()">添加</button>
|
||||
<div class="preview" id="icon-preview"></div>
|
||||
<div style="margin-top:14px"><button class="primary" id="do-add">添加</button></div>
|
||||
</div>
|
||||
|
||||
<!-- 分组 -->
|
||||
<div id="app"><div class="card"><p class="hint">加载中…</p></div></div>
|
||||
|
||||
<!-- 令牌 -->
|
||||
<div class="card">
|
||||
<details>
|
||||
<summary>接口令牌(一般不用填)</summary>
|
||||
<p class="hint" style="margin:10px 0">
|
||||
<code>/admin/</code> 已在门户登录保护之下,服务端默认不再额外校验令牌。<br>
|
||||
仅当部署方设置了 <code>NAVI_ADMIN_REQUIRE_TOKEN=1</code> 时才需要填这里的值(对应 <code>NAVI_ADMIN_TOKEN</code>)。
|
||||
</p>
|
||||
<div class="row">
|
||||
<div class="fg"><label>令牌</label><input id="tk" type="password" placeholder="留空即可"></div>
|
||||
</div>
|
||||
<button id="save-tk">保存令牌</button>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let S=[];const A='/admin/api';
|
||||
async function L(){const r=await fetch(A+'/sections');S=await r.json();R();document.getElementById('sel').innerHTML=S.map((s,i)=>`<option value="${i}">${s.name}</option>`).join('')}
|
||||
function R(){document.getElementById('app').innerHTML=S.map((s,si)=>`<div class="card"><div class="sec-hd"><span class="sec-nm">${s.name}</span><span class="badge">${s.items.length}</span></div>${s.items.map((it,ii)=>`<div class="itm"><div class="itm-i"><div class="itm-t">${it.title}</div><div class="itm-u">${it.url}</div></div><button class="btn-x" onclick="D(${si},${ii})" title="删除">✕</button></div>`).join('')}</div>`).join('')}
|
||||
async function add(){const t=document.getElementById('ti').value.trim(),u=document.getElementById('ur').value.trim();if(!t||!u){toast('请填写名称和URL','#ef4444');return}const si=+document.getElementById('sel').value;const r=await fetch(A+'/item',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sectionIndex:si,item:{title:t,description:document.getElementById('de').value.trim(),icon:document.getElementById('ic').value.trim(),url:u}})});if(r.ok){toast('已添加');['ti','de','ur','ic'].forEach(id=>document.getElementById(id).value='');L()}else toast('添加失败','#ef4444')}
|
||||
async function D(si,ii){if(!confirm('确定删除「'+S[si].items[ii].title+'」?'))return;const r=await fetch(A+'/item/delete',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({sectionIndex:si,itemIndex:ii})});if(r.ok){toast('已删除');L()}}
|
||||
function toast(m,b='#22c55e'){const d=document.createElement('div');d.className='toast';d.textContent=m;d.style.background=b;document.body.appendChild(d);setTimeout(()=>d.remove(),2500)}
|
||||
L()
|
||||
const A = '/admin/api';
|
||||
let S = [];
|
||||
let PAGE = {};
|
||||
|
||||
/* ── 令牌(可选) ── */
|
||||
function token() { return localStorage.getItem('naviAdminToken') || ''; }
|
||||
function authHeaders(extra) {
|
||||
const h = Object.assign({}, extra || {});
|
||||
if (token()) h['Authorization'] = 'Bearer ' + token();
|
||||
return h;
|
||||
}
|
||||
document.getElementById('tk').value = token();
|
||||
document.getElementById('save-tk').addEventListener('click', () => {
|
||||
const v = document.getElementById('tk').value.trim();
|
||||
if (v) localStorage.setItem('naviAdminToken', v);
|
||||
else localStorage.removeItem('naviAdminToken');
|
||||
toast('已保存令牌');
|
||||
});
|
||||
|
||||
function toast(msg, color) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'toast';
|
||||
d.textContent = msg;
|
||||
d.style.background = color || '#22c55e';
|
||||
document.body.appendChild(d);
|
||||
setTimeout(() => d.remove(), 2600);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/* 图标预览:文件名当图片,fas/fa/far 等当字体图标,其余留空 */
|
||||
function iconHTML(icon) {
|
||||
const v = (icon || '').trim();
|
||||
if (!v) return '<span class="ico-fa" title="留空时按站点自动取图标">·</span>';
|
||||
if (/\.(png|svg|jpg|jpeg|ico|webp|gif)$/i.test(v)) {
|
||||
return '<img src="/item-icons/' + encodeURIComponent(v) + '" alt="" loading="lazy">';
|
||||
}
|
||||
return '<span class="ico-fa">◆</span>';
|
||||
}
|
||||
|
||||
/* ── 站点信息 ── */
|
||||
function renderNavLinks() {
|
||||
const box = document.getElementById('navlinks');
|
||||
const list = PAGE.navLinks || [];
|
||||
box.innerHTML = list.map((l, i) => `
|
||||
<div class="link-row">
|
||||
<input data-k="name" data-i="${i}" value="${esc(l.name || '')}" placeholder="名称">
|
||||
<input data-k="url" data-i="${i}" value="${esc(l.url || '')}" placeholder="https://...">
|
||||
<input data-k="icon" data-i="${i}" value="${esc(l.icon || '')}" placeholder="图标(可空)" style="max-width:150px">
|
||||
<button class="icon-btn danger" data-rm="${i}">✕</button>
|
||||
</div>`).join('') || '<p class="hint">暂无顶部导航链接</p>';
|
||||
|
||||
box.querySelectorAll('input[data-k]').forEach(el => {
|
||||
el.addEventListener('input', () => {
|
||||
const i = +el.dataset.i;
|
||||
PAGE.navLinks = PAGE.navLinks || [];
|
||||
PAGE.navLinks[i] = PAGE.navLinks[i] || {};
|
||||
PAGE.navLinks[i][el.dataset.k] = el.value;
|
||||
});
|
||||
});
|
||||
box.querySelectorAll('button[data-rm]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
PAGE.navLinks.splice(+el.dataset.rm, 1);
|
||||
renderNavLinks();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPageInfo() {
|
||||
try {
|
||||
const r = await fetch(A + '/pageinfo', { headers: authHeaders() });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
PAGE = await r.json() || {};
|
||||
} catch (e) {
|
||||
PAGE = {};
|
||||
toast('读取站点信息失败: ' + e.message, '#ef4444');
|
||||
}
|
||||
document.getElementById('p-title').value = PAGE.title || '';
|
||||
document.getElementById('p-desc').value = PAGE.description || '';
|
||||
document.getElementById('p-footer').value = PAGE.footer || '';
|
||||
if (!PAGE.navLinks && PAGE.navLinks !== null) PAGE.navLinks = [];
|
||||
renderNavLinks();
|
||||
}
|
||||
|
||||
document.getElementById('add-link').addEventListener('click', () => {
|
||||
PAGE.navLinks = PAGE.navLinks || [];
|
||||
PAGE.navLinks.push({ name: '', url: '', icon: '' });
|
||||
renderNavLinks();
|
||||
});
|
||||
|
||||
document.getElementById('save-info').addEventListener('click', async () => {
|
||||
const body = {
|
||||
title: document.getElementById('p-title').value,
|
||||
description: document.getElementById('p-desc').value,
|
||||
footer: document.getElementById('p-footer').value,
|
||||
navLinks: (PAGE.navLinks || []).filter(l => l && (l.name || l.url)),
|
||||
};
|
||||
const r = await fetch(A + '/pageinfo', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) { toast('站点信息已保存'); await loadPageInfo(); }
|
||||
else toast('保存失败: ' + (await r.text()).slice(0, 120), '#ef4444');
|
||||
});
|
||||
|
||||
/* ── 分组与条目 ── */
|
||||
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) => `
|
||||
<div class="card">
|
||||
<div class="sec-hd">
|
||||
<span class="sec-nm">${esc(s.name)}</span>
|
||||
<span class="badge">${s.items.length}</span>
|
||||
</div>
|
||||
${s.items.map((it, ii) => `
|
||||
<div class="itm">
|
||||
${iconHTML(it.icon)}
|
||||
<div class="itm-i">
|
||||
<div class="itm-t">${esc(it.title)}</div>
|
||||
<div class="itm-u">${esc(it.url)}</div>
|
||||
</div>
|
||||
<button class="icon-btn danger" data-del="${si}:${ii}">✕</button>
|
||||
</div>`).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
document.querySelectorAll('button[data-del]').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const [si, ii] = el.dataset.del.split(':').map(Number);
|
||||
delItem(si, ii);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function delItem(si, ii) {
|
||||
if (!confirm('确定删除「' + S[si].items[ii].title + '」?')) return;
|
||||
const r = await fetch(A + '/item/delete', {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ sectionIndex: si, itemIndex: ii }),
|
||||
});
|
||||
if (r.ok) { toast('已删除'); loadSections(); }
|
||||
else toast('删除失败: ' + (await r.text()).slice(0, 120), '#ef4444');
|
||||
}
|
||||
|
||||
document.getElementById('do-add').addEventListener('click', async () => {
|
||||
const t = document.getElementById('ti').value.trim();
|
||||
const u = document.getElementById('ur').value.trim();
|
||||
if (!t || !u) { toast('请填写名称和 URL', '#ef4444'); return; }
|
||||
const r = await fetch(A + '/item', {
|
||||
method: 'POST',
|
||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
sectionIndex: +document.getElementById('sel').value,
|
||||
item: {
|
||||
title: t,
|
||||
description: document.getElementById('de').value.trim(),
|
||||
icon: document.getElementById('ic').value.trim(),
|
||||
url: u,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (r.ok) {
|
||||
toast('已添加');
|
||||
['ti', 'de', 'ur', 'ic'].forEach(id => document.getElementById(id).value = '');
|
||||
document.getElementById('icon-preview').innerHTML = '';
|
||||
loadSections();
|
||||
} else toast('添加失败: ' + (await r.text()).slice(0, 120), '#ef4444');
|
||||
});
|
||||
|
||||
/* ── 图标上传 ── */
|
||||
document.getElementById('pick-icon').addEventListener('click', () => {
|
||||
document.getElementById('file-icon').click();
|
||||
});
|
||||
|
||||
/* .ico 浏览器能显示但 Dashy 渲染不了,这里先用 canvas 转成 PNG 再上传 */
|
||||
async function toUploadable(file) {
|
||||
const isIco = /\.ico$/i.test(file.name) ||
|
||||
['image/x-icon', 'image/vnd.microsoft.icon', 'image/ico'].includes(file.type);
|
||||
if (!isIco) return file;
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = await new Promise((res, rej) => {
|
||||
const i = new Image();
|
||||
i.onload = () => res(i);
|
||||
i.onerror = () => rej(new Error('浏览器无法解析该 .ico'));
|
||||
i.src = url;
|
||||
});
|
||||
const c = document.createElement('canvas');
|
||||
c.width = img.naturalWidth || 256;
|
||||
c.height = img.naturalHeight || 256;
|
||||
c.getContext('2d').drawImage(img, 0, 0);
|
||||
const blob = await new Promise(r => c.toBlob(r, 'image/png'));
|
||||
if (!blob) throw new Error('canvas 导出失败');
|
||||
return new File([blob], file.name.replace(/\.[^.]+$/, '') + '.png', { type: 'image/png' });
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('file-icon').addEventListener('change', async (ev) => {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
ev.target.value = '';
|
||||
if (!f) return;
|
||||
let send;
|
||||
try { send = await toUploadable(f); }
|
||||
catch (e) { toast('转换失败: ' + e.message, '#ef4444'); return; }
|
||||
if (send.size > 512 * 1024) { toast('图片过大(上限 512KB)', '#ef4444'); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('file', send);
|
||||
fd.append('name', document.getElementById('ti').value.trim() || send.name);
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(A + '/icon', { method: 'POST', headers: authHeaders(), body: fd });
|
||||
} catch (e) { toast('上传失败: ' + e.message, '#ef4444'); return; }
|
||||
if (!r.ok) { toast('上传失败: ' + (await r.text()).slice(0, 120), '#ef4444'); return; }
|
||||
const data = await r.json();
|
||||
document.getElementById('ic').value = data.icon;
|
||||
document.getElementById('icon-preview').innerHTML =
|
||||
'<img src="/item-icons/' + encodeURIComponent(data.icon) + '?t=' + Date.now() + '" alt="">' +
|
||||
'<span class="hint">已上传:' + esc(data.icon) + '</span>';
|
||||
toast('图标已上传');
|
||||
});
|
||||
|
||||
/* 图标文件名变化时同步预览 */
|
||||
document.getElementById('ic').addEventListener('input', (e) => {
|
||||
const v = e.target.value.trim();
|
||||
const box = document.getElementById('icon-preview');
|
||||
if (/\.(png|svg|jpg|jpeg|webp|gif|ico)$/i.test(v)) {
|
||||
box.innerHTML = '<img src="/item-icons/' + encodeURIComponent(v) + '?t=' + Date.now() + '" alt="">';
|
||||
} else if (!box.querySelector('span')) {
|
||||
box.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
await loadPageInfo();
|
||||
await loadSections();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
appConfig:
|
||||
theme: default
|
||||
theme: jianf-glass
|
||||
dayTheme: default
|
||||
nightTheme: midnight
|
||||
customColors: {}
|
||||
@ -8,50 +8,46 @@ appConfig:
|
||||
searchEngine: custom
|
||||
customSearchEngine: /search?q=
|
||||
openingMethod: newtab
|
||||
customCss: ""
|
||||
layout: masonry
|
||||
iconSize: large
|
||||
language: zh-CN
|
||||
statusCheck: true
|
||||
statusCheckInterval: 60
|
||||
|
||||
customCss: "/* ═══ JianF 玻璃拟态主题 v2 ═══\n 注意:必须按 Dashy 真实渲染出的类名写。此前版本用了 .tile / .item-group /\n .item-group-outer —— 这三个类在页面里并不存在(querySelectorAll 计数为 0),\n 所以大部分规则是死代码,界面才一直显得平。\n\n 真实结构(自内向外):\n a.item ← 卡片表面(灰底就来自这里)\n div.item-wrapper ← 网格单元\n div.there-are-items\n div.content-inner\n div.collapsible-content\n div.collapsable.section_<名字> ← 分组容器\n div.item-group-container\n */\n\n/* ── 背景 ──\n .home 有不透明背景会盖住 body 渐变,必须一起透明掉,否则彩色背景完全看不见。 */\nhtml[data-theme] body {\n background:\n radial-gradient(1200px 720px at 6% -12%, #a5b4fc 0%, transparent 56%),\n radial-gradient(1000px 640px at 96% 2%, #7dd3fc 0%, transparent 52%),\n radial-gradient(900px 700px at 46% 112%, #f0abfc 0%, transparent 56%),\n linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #ecfeff 100%) !important;\n background-attachment: fixed !important;\n min-height: 100vh;\n}\nhtml[data-theme]\
|
||||
\ .home {\n background: transparent !important;\n}\n\n/* ── 顶栏 ── */\nhtml[data-theme] header {\n background: rgba(255,255,255,.44) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border-bottom: 1px solid rgba(255,255,255,.6) !important;\n box-shadow: 0 6px 24px rgba(15,23,42,.06) !important;\n}\n\n/* ── 卡片:真正的表面是 a.item ── */\nhtml[data-theme] a.item {\n background: rgba(255,255,255,.52) !important;\n backdrop-filter: blur(18px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(18px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow:\n 0 6px 18px rgba(15,23,42,.07),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1),\n box-shadow .28s ease, background .28s ease, border-color .28s ease !important;\n}\nhtml[data-theme] a.item:hover\
|
||||
\ {\n background: rgba(255,255,255,.88) !important;\n border-color: rgba(147,197,253,.85) !important;\n transform: translateY(-4px) !important;\n box-shadow:\n 0 18px 36px rgba(30,64,175,.18),\n 0 0 0 1px rgba(147,197,253,.35),\n inset 0 1px 0 rgba(255,255,255,.9) !important;\n}\n/* 外层网格单元保持透明,避免叠两层底 */\nhtml[data-theme] div.item-wrapper {\n background: transparent !important;\n backdrop-filter: none !important;\n -webkit-backdrop-filter: none !important;\n border: none !important;\n box-shadow: none !important;\n}\nhtml[data-theme] a.item .tile-icon,\nhtml[data-theme] a.item .item-icon {\n border-radius: 13px !important;\n filter: drop-shadow(0 3px 6px rgba(15,23,42,.10)) !important;\n transition: transform .28s cubic-bezier(.34,1.2,.64,1) !important;\n}\nhtml[data-theme] a.item:hover .tile-icon { transform: scale(1.08) !important; }\nhtml[data-theme] a.item .tile-title { font-weight: 650 !important; letter-spacing: .1px !important; }\nhtml[data-theme] a.item .description,\n\
|
||||
html[data-theme] a.item .item-url { color: #64748b !important; }\n\n/* ── 分组容器 ── */\nhtml[data-theme] div.collapsable {\n background: rgba(255,255,255,.40) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter: blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.6) !important;\n border-radius: 24px !important;\n box-shadow:\n 0 12px 32px rgba(15,23,42,.09),\n inset 0 1px 0 rgba(255,255,255,.85) !important;\n overflow: hidden !important;\n}\nhtml[data-theme] div.collapsible-content {\n background: transparent !important;\n border-radius: 0 0 22px 22px !important;\n}\nhtml[data-theme] div.collapsable .content-inner { padding: 10px 12px !important; }\nhtml[data-theme] .item-group-container { padding: 0 4px !important; }\n\n/* 分组标题 */\nhtml[data-theme] .collapsable .title,\nhtml[data-theme] .collapsable h1,\nhtml[data-theme] .collapsable h2 {\n font-weight: 700 !important;\n letter-spacing: .2px !important;\n\
|
||||
}\n\n/* ── 搜索 ── */\nhtml[data-theme] .search-container,\nhtml[data-theme] header input[type=\"text\"] {\n background: rgba(255,255,255,.6) !important;\n border: 1px solid rgba(255,255,255,.7) !important;\n border-radius: 16px !important;\n backdrop-filter: blur(14px) saturate(170%) !important;\n -webkit-backdrop-filter: blur(14px) saturate(170%) !important;\n box-shadow: inset 0 1px 0 rgba(255,255,255,.8), 0 4px 14px rgba(15,23,42,.05) !important;\n}\nhtml[data-theme] .navi-search-bar:focus-within {\n box-shadow: 0 14px 34px rgba(37,99,235,.22), 0 0 0 3px rgba(37,99,235,.16) !important;\n}\n\n/* ── 滚动条 ── */\nhtml[data-theme] ::-webkit-scrollbar { width: 10px; height: 10px; }\nhtml[data-theme] ::-webkit-scrollbar-track { background: transparent; }\nhtml[data-theme] ::-webkit-scrollbar-thumb {\n background: rgba(100,116,139,.32);\n border-radius: 999px;\n border: 2px solid transparent;\n background-clip: content-box;\n}\nhtml[data-theme] ::-webkit-scrollbar-thumb:hover {\n\
|
||||
\ background: rgba(100,116,139,.5);\n background-clip: content-box;\n}\n\n/* ── 工作空间侧边栏 ──\n 底部「切换视图」按钮组内容 70px 塞进 56px 的栏里,把 side-bar 撑出横向\n 滚动条,必须左右滑动才能看全。这里让它换行并收住溢出。 */\nhtml[data-theme] nav.side-bar { overflow-x: hidden !important; }\nhtml[data-theme] nav.side-bar .switch-view-buttons {\n width: 100% !important;\n display: flex !important;\n flex-wrap: wrap !important;\n justify-content: center !important;\n align-items: center !important;\n gap: 2px !important;\n margin: 8px 0 0 !important;\n}\nhtml[data-theme] nav.side-bar .switch-view-buttons > * {\n flex: 0 0 auto !important;\n min-width: 0 !important;\n max-width: 100% !important;\n}\n\n/* ── 设置抽屉 / 菜单 / 提示 ── */\nhtml[data-theme] .settings-outer,\nhtml[data-theme] .config-container,\nhtml[data-theme] .context-menu,\nhtml[data-theme] .toast,\nhtml[data-theme] .welcome-popup {\n background: rgba(255,255,255,.72) !important;\n backdrop-filter: blur(20px) saturate(180%) !important;\n -webkit-backdrop-filter:\
|
||||
\ blur(20px) saturate(180%) !important;\n border: 1px solid rgba(255,255,255,.66) !important;\n border-radius: 18px !important;\n box-shadow: 0 14px 34px rgba(15,23,42,.14) !important;\n}\n\n@media (prefers-reduced-motion: reduce) {\n html[data-theme] a.item,\n html[data-theme] a.item .tile-icon { transition: none !important; }\n html[data-theme] a.item:hover { transform: none !important; }\n}\n"
|
||||
layout: masonry
|
||||
iconSize: large
|
||||
language: zh-CN
|
||||
statusCheck: true
|
||||
statusCheckInterval: 60
|
||||
pageInfo:
|
||||
title: Navigation Portal
|
||||
description: Self-hosted unified navigation
|
||||
navLinks: []
|
||||
footer: ""
|
||||
|
||||
footer: ''
|
||||
sections:
|
||||
# 名称必须是 Favorites / 常用服务 —— 服务端会把访问量最高的 4 个导航写到这里
|
||||
- name: Favorites
|
||||
icon: fas fa-star
|
||||
collapsed: false
|
||||
items:
|
||||
- title: Gitea
|
||||
description: Code repository
|
||||
icon: fas fa-code-branch
|
||||
url: http://192.168.1.100:3000
|
||||
statusCheck: true
|
||||
|
||||
# 名称必须是 History / 历史访问 ——
|
||||
# 服务端会把插件安装入口固定放在第一条,其余按访问频次填充。
|
||||
# 插件已安装的浏览器由 /navi.js 自动隐藏该入口。
|
||||
- name: History
|
||||
icon: fas fa-history
|
||||
collapsed: false
|
||||
items:
|
||||
- title: 安装浏览历史插件
|
||||
description: 一键安装,自动按访问频次排序
|
||||
icon: fas fa-download
|
||||
url: /extension/install.html
|
||||
|
||||
- name: Tools
|
||||
icon: fas fa-tools
|
||||
collapsed: true
|
||||
items:
|
||||
- title: Example Service
|
||||
description: Add your own services
|
||||
icon: fas fa-cog
|
||||
url: http://192.168.1.100:8080
|
||||
|
||||
pages: []
|
||||
- name: Favorites
|
||||
icon: fas fa-star
|
||||
collapsed: false
|
||||
items:
|
||||
- title: Gitea
|
||||
description: Code repository
|
||||
icon: fas fa-code-branch
|
||||
url: http://192.168.1.100:3000
|
||||
statusCheck: true
|
||||
- name: History
|
||||
icon: fas fa-history
|
||||
collapsed: false
|
||||
items:
|
||||
- title: 安装浏览历史插件
|
||||
description: 一键安装,自动按访问频次排序
|
||||
icon: fas fa-download
|
||||
url: /extension/install.html
|
||||
- name: Tools
|
||||
icon: fas fa-tools
|
||||
collapsed: true
|
||||
items:
|
||||
- title: Example Service
|
||||
description: Add your own services
|
||||
icon: fas fa-cog
|
||||
url: http://192.168.1.100:8080
|
||||
|
||||
@ -7,32 +7,42 @@
|
||||
<title>安装浏览历史插件</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
/* .btn 自己设了 display,会盖掉浏览器默认的 [hidden]{display:none},
|
||||
导致未配置商店时「一键安装」按钮仍然可见且 href 为空 —— 必须显式兜住 */
|
||||
[hidden]{display:none !important}
|
||||
body{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
|
||||
background:#0f172a;color:#e2e8f0;line-height:1.6;padding:32px 20px}
|
||||
.wrap{max-width:720px;margin:0 auto}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:8px}
|
||||
background:
|
||||
radial-gradient(1000px 500px at 10% -10%, #1e3a8a 0%, transparent 60%),
|
||||
radial-gradient(900px 500px at 90% 0%, #312e81 0%, transparent 55%),
|
||||
#0f172a;
|
||||
background-attachment:fixed;color:#e2e8f0;line-height:1.6;padding:32px 18px 64px}
|
||||
.wrap{max-width:760px;margin:0 auto}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:6px}
|
||||
.sub{color:#94a3b8;font-size:14px;margin-bottom:24px}
|
||||
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:20px 22px;margin-bottom:16px}
|
||||
.card h2{font-size:17px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:8px}
|
||||
.badge{font-size:12px;font-weight:600;padding:2px 8px;border-radius:999px;
|
||||
background:#1e3a5f;color:#93c5fd;border:1px solid #2563eb}
|
||||
.card.current{border-color:#2563eb;box-shadow:0 0 0 1px #2563eb inset}
|
||||
.card{background:rgba(30,41,59,.72);border:1px solid #334155;border-radius:16px;
|
||||
padding:20px 22px;margin-bottom:16px;
|
||||
backdrop-filter:blur(14px) saturate(150%);-webkit-backdrop-filter:blur(14px) saturate(150%);
|
||||
box-shadow:0 10px 30px rgba(0,0,0,.28)}
|
||||
.card h2{font-size:17px;font-weight:600;margin-bottom:12px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.badge{font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;
|
||||
background:#1e3a5f;color:#93c5fd;border:1px solid #2563eb;white-space:nowrap}
|
||||
.badge.warn{background:#3f2d17;color:#fcd34d;border-color:#b45309}
|
||||
.card.current{border-color:#2563eb;box-shadow:0 0 0 1px #2563eb inset,0 10px 30px rgba(0,0,0,.28)}
|
||||
ol{margin:0 0 0 20px;font-size:14px;color:#cbd5e1}
|
||||
ol li{margin-bottom:6px}
|
||||
ol li{margin-bottom:7px}
|
||||
a{color:#60a5fa}
|
||||
code{background:#0f172a;border:1px solid #334155;border-radius:4px;padding:1px 6px;font-size:13px}
|
||||
code{background:#0b1220;border:1px solid #334155;border-radius:5px;padding:1px 6px;font-size:13px}
|
||||
.note{font-size:13px;color:#94a3b8;margin-top:12px;border-top:1px solid #334155;padding-top:12px}
|
||||
.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}
|
||||
.btn{display:inline-block;padding:9px 18px;border-radius:8px;font-size:14px;font-weight:600;
|
||||
text-decoration:none;border:1px solid #334155;background:#0f172a;color:#e2e8f0;cursor:pointer}
|
||||
.btn{display:inline-block;padding:9px 18px;border-radius:10px;font-size:14px;font-weight:600;
|
||||
text-decoration:none;border:1px solid #334155;background:#0b1220;color:#e2e8f0;cursor:pointer}
|
||||
.btn.primary{background:#2563eb;border-color:#2563eb;color:#fff}
|
||||
.btn.primary:hover{background:#1d4ed8}
|
||||
.btn:hover{border-color:#475569}
|
||||
.site{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px}
|
||||
.site input{flex:1;min-width:260px;background:#0f172a;border:1px solid #334155;border-radius:8px;
|
||||
.site input{flex:1;min-width:240px;background:#0b1220;border:1px solid #334155;border-radius:10px;
|
||||
padding:9px 12px;color:#e2e8f0;font-size:14px}
|
||||
.hint{font-size:13px;color:#94a3b8;margin-top:10px}
|
||||
.steps-done{font-size:13px;color:#86efac;margin-top:10px}
|
||||
.done{font-size:13px;color:#86efac;margin-top:8px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -47,23 +57,23 @@
|
||||
<input id="site-url" readonly>
|
||||
<button class="btn" id="copy-btn" type="button">复制</button>
|
||||
</div>
|
||||
<div class="steps-done" id="copy-done" hidden>已复制</div>
|
||||
<div class="done" id="copy-done" hidden>已复制</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-chromium" hidden>
|
||||
<h2>Chrome / Edge <span class="badge">已识别你的浏览器</span></h2>
|
||||
<h2>Chrome / Edge / 其他 Chromium <span class="badge">已识别你的浏览器</span></h2>
|
||||
<div class="actions">
|
||||
<a class="btn primary" id="store-chromium" hidden>从商店一键安装</a>
|
||||
<a class="btn" href="/extension/navi-chrome.zip" download>下载扩展包(zip)</a>
|
||||
<a class="btn" href="/extension/navi-chrome.zip" download id="dl-chromium">下载扩展包(zip)</a>
|
||||
</div>
|
||||
<ol>
|
||||
<li>解压下载的 zip 到一个固定目录(不要删除)</li>
|
||||
<li>地址栏打开 <code>chrome://extensions</code>,Edge 用 <code>edge://extensions</code></li>
|
||||
<li>地址栏打开 <code id="ext-url">chrome://extensions</code></li>
|
||||
<li>打开右上角「开发者模式」</li>
|
||||
<li>点「加载已解压的扩展程序」,选择解压出来的目录</li>
|
||||
<li>点扩展图标,填入上面的本站地址与同步令牌,保存</li>
|
||||
</ol>
|
||||
<p class="note">Chrome 不允许从网站直接安装非商店扩展,所以这里只能下载后手动加载。若已发布到商店,上面的「一键安装」按钮会自动出现。</p>
|
||||
<p class="note" id="note-chromium"></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-firefox" hidden>
|
||||
@ -77,12 +87,24 @@
|
||||
<li>点「临时加载附加组件」,选择下载的 xpi</li>
|
||||
<li>点扩展图标,填入上面的本站地址与同步令牌,保存</li>
|
||||
</ol>
|
||||
<p class="note">Firefox 正式版只接受签名附加组件;未签名的 xpi 只能在开发者版 / ESR 上加载,或在 <code>about:config</code> 关闭 <code>xpinstall.signatures.required</code>。发布到 AMO 后可用上面的「一键安装」。</p>
|
||||
<p class="note" id="note-firefox"></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-mobile" hidden>
|
||||
<h2>手机浏览器 <span class="badge warn">此环境无法安装扩展</span></h2>
|
||||
<p class="hint">iOS 上所有浏览器都只能用 Safari 内核,且 Safari 扩展需要 Xcode 打包的 App 才能安装;
|
||||
Android 版 Chrome 也不支持安装扩展。所以手机上无法完成这一步。</p>
|
||||
<div class="actions">
|
||||
<a class="btn" href="/extension/navi-chrome.zip" download>下载 Chromium 版(zip)</a>
|
||||
<a class="btn" href="/extension/navi-firefox.xpi" download>下载 Firefox 版(xpi)</a>
|
||||
</div>
|
||||
<p class="note">请在电脑上打开本页面完成安装。手机上浏览产生的记录暂不会同步,但电脑端安装后仍会统计。</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="card-other" hidden>
|
||||
<h2>其他浏览器</h2>
|
||||
<p class="hint">未能识别具体浏览器。Chromium 内核(Brave、Vivaldi、Opera、Kiwi 等)请按上面 Chrome 的步骤操作;Firefox 请按 Firefox 的步骤操作。</p>
|
||||
<p class="hint">未能识别浏览器内核。Chromium 系(Brave、Vivaldi、Opera、Kiwi 等)请按上面 Chrome 的步骤操作;
|
||||
Firefox 系请按 Firefox 的步骤操作。</p>
|
||||
<div class="actions">
|
||||
<a class="btn" href="/extension/navi-chrome.zip" download>下载 Chromium 版(zip)</a>
|
||||
<a class="btn" href="/extension/navi-firefox.xpi" download>下载 Firefox 版(xpi)</a>
|
||||
@ -105,8 +127,7 @@
|
||||
|
||||
document.getElementById('site-url').value = location.origin;
|
||||
|
||||
var copyBtn = document.getElementById('copy-btn');
|
||||
copyBtn.addEventListener('click', function () {
|
||||
document.getElementById('copy-btn').addEventListener('click', function () {
|
||||
var input = document.getElementById('site-url');
|
||||
input.select();
|
||||
input.setSelectionRange(0, 999);
|
||||
@ -119,24 +140,80 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 浏览器识别:Firefox 用 UA 判断,其余按 Chromium 处理。
|
||||
var ua = navigator.userAgent;
|
||||
var isFirefox = /firefox\/|fxios\//i.test(ua);
|
||||
var isChromium = /chrome\/|chromium\/|edg\//i.test(ua);
|
||||
var card = document.getElementById(isFirefox ? 'card-firefox' : (isChromium ? 'card-chromium' : 'card-other'));
|
||||
/* ── 浏览器识别 ──
|
||||
UA 会互相包含(Edge/Brave/Opera 的 UA 里都有 Chrome/),所以要按
|
||||
「更具体的先判」的顺序来。
|
||||
另外 iOS 上所有浏览器都是 WebKit 内核,且 Safari 扩展必须由 App 打包,
|
||||
根本装不了 —— 必须单独识别,否则会给用户一套做不到的桌面步骤。 */
|
||||
function detect() {
|
||||
var ua = navigator.userAgent;
|
||||
var uad = navigator.userAgentData || null;
|
||||
var brands = (uad && uad.brands ? uad.brands : []).map(function (b) { return b.brand; });
|
||||
var brandStr = brands.join(' ');
|
||||
|
||||
var isIOS = /iPad|iPhone|iPod/.test(ua) ||
|
||||
(/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
|
||||
var isAndroid = /Android/i.test(ua);
|
||||
|
||||
var isFirefox = /firefox\/|fxios\//i.test(ua);
|
||||
var isEdge = /edg\//i.test(ua) || brands.indexOf('Microsoft Edge') >= 0;
|
||||
var isOpera = /opr\//i.test(ua) || brands.indexOf('Opera') >= 0;
|
||||
var isBrave = !!(navigator.brave && navigator.brave.isBrave);
|
||||
var isVivaldi = /vivaldi/i.test(ua);
|
||||
var isChromium = !isFirefox && (
|
||||
/chrome\/|chromium\//i.test(ua) ||
|
||||
brands.some(function (b) { return /Chromium|Google Chrome/i.test(b); })
|
||||
);
|
||||
|
||||
return {
|
||||
isIOS: isIOS, isAndroid: isAndroid,
|
||||
isMobile: isIOS || isAndroid || /Mobile|Tablet/i.test(ua),
|
||||
isFirefox: isFirefox, isEdge: isEdge, isOpera: isOpera,
|
||||
isBrave: isBrave, isVivaldi: isVivaldi, isChromium: isChromium,
|
||||
isSafari: /safari\//i.test(ua) && !isChromium && !isFirefox,
|
||||
brand: brandStr
|
||||
};
|
||||
}
|
||||
|
||||
var d = detect();
|
||||
var card;
|
||||
|
||||
if (d.isFirefox && !d.isIOS) {
|
||||
card = document.getElementById('card-firefox');
|
||||
} else if (d.isMobile) {
|
||||
card = document.getElementById('card-mobile');
|
||||
} else if (d.isChromium) {
|
||||
card = document.getElementById('card-chromium');
|
||||
var extUrl = 'chrome://extensions';
|
||||
var label = 'Chrome';
|
||||
if (d.isEdge) { extUrl = 'edge://extensions'; label = 'Edge'; }
|
||||
else if (d.isBrave) { extUrl = 'brave://extensions'; label = 'Brave'; }
|
||||
else if (d.isVivaldi) { extUrl = 'vivaldi://extensions'; label = 'Vivaldi'; }
|
||||
else if (d.isOpera) { extUrl = 'opera://extensions'; label = 'Opera'; }
|
||||
document.getElementById('ext-url').textContent = extUrl;
|
||||
if (label !== 'Chrome') {
|
||||
document.querySelector('#card-chromium h2').firstChild.textContent =
|
||||
label + '(Chromium 内核) ';
|
||||
}
|
||||
} else {
|
||||
card = document.getElementById('card-other');
|
||||
}
|
||||
card.hidden = false;
|
||||
card.classList.add('current');
|
||||
|
||||
// 可选:部署方在 /app/data/extension-store.json 放商店链接后,
|
||||
// 对应平台的「一键安装」按钮会自动出现(无需重建镜像)。
|
||||
/* 商店链接是可选的:部署方在 data 卷放 extension-store.json 才启用。
|
||||
本实例没有发布到任何商店,所以默认把按钮整个移除 —— 避免出现
|
||||
点不动的「一键安装」。 */
|
||||
fetch('/extension/store.json', { cache: 'no-store' })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (s) {
|
||||
if (!s) return;
|
||||
if (s.chrome) showStore('store-chromium', s.chrome);
|
||||
if (s.firefox) showStore('store-firefox', s.firefox);
|
||||
var hasChrome = s && s.chrome;
|
||||
var hasFirefox = s && s.firefox;
|
||||
if (hasChrome) showStore('store-chromium', s.chrome);
|
||||
if (hasFirefox) showStore('store-firefox', s.firefox);
|
||||
setNotes(hasChrome, hasFirefox);
|
||||
})
|
||||
.catch(function () { /* 未配置商店链接,保持手动安装步骤 */ });
|
||||
.catch(function () { setNotes(false, false); });
|
||||
|
||||
function showStore(id, url) {
|
||||
var el = document.getElementById(id);
|
||||
@ -144,6 +221,21 @@
|
||||
el.href = url;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function setNotes(fromChromeStore, fromFirefoxStore) {
|
||||
var c = document.getElementById('note-chromium');
|
||||
var f = document.getElementById('note-firefox');
|
||||
if (c) {
|
||||
c.textContent = fromChromeStore
|
||||
? '也可以从上面的商店按钮一键安装。'
|
||||
: '本实例未发布到浏览器商店,Chrome/Edge 不允许从网站直接安装非商店扩展,因此只能按上面步骤手动加载。';
|
||||
}
|
||||
if (f) {
|
||||
f.textContent = fromFirefoxStore
|
||||
? '也可以从上面的商店按钮一键安装。'
|
||||
: '本实例未发布到商店;Firefox 正式版只接受已签名的附加组件,未签名的 xpi 只能在开发者版 / ESR 上加载,或在 about:config 关闭 xpinstall.signatures.required。';
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user