feat(plugin): 新增背景音乐插件,插件系统支持音频上传与片段文件
10 个文件变更
+602
-9
fangqihang1717@163.com
| •README.md | +19 -1 |
| •docs/PLUGIN.md | +41 -2 |
| •internal/handlers/admin.go | +103 -3 |
| •internal/handlers/plugin_asset_test.go | +74 -0 |
| •internal/plugin/plugin.go | +34 -2 |
| •internal/plugin/plugin_test.go | +115 -0 |
| •plugins/bgm/player.html | +147 -0 |
| •plugins/bgm/plugin.json | +48 -0 |
| •web/static/app.css | +6 -0 |
| •web/templates/admin.html | +15 -1 |
变更内容
diff --git a/README.md b/README.md
index c594d5d..30d73b8 100644
--- a/README.md
+++ b/README.md
@@ -415,6 +415,21 @@ docker run -d --name clearlove --restart=always \
安装流程:拉取市场列表 → 下载 zip → **SHA256 完整性校验** → 落盘 → 回到插件管理页「**启用**」。
+### 内置插件
+
+| 插件 | 说明 | 安装方式 |
+|---|---|---|
+| [背景音乐](plugins/bgm) | 管理员上传音乐后,访客打开站点即可听到背景音乐 | 后台「插件管理」上传 `dist/plugin-bgm.zip` |
+
+**背景音乐**用法:上传 zip → 启用 → 在「插件设置」中上传音乐并保存。
+
+> zip 内 `plugin.json` 与 `player.html` 需位于**压缩包根目录**(不要再套一层文件夹)。
+> 仓库 `dist/` 目录不纳入版本库,可自行打包:`cd plugins/bgm && zip -r ../../plugin-bgm.zip .`
+
+> 浏览器会拦截带声音的自动播放,插件已做兜底:被拦截时播放按钮旁会出现「点击开启背景音乐」提示,
+> 访客点击一次即可播放;站内翻页会接着播(进度记在会话里),关闭状态也会被记住。
+> 支持自动播放 / 循环 / 默认音量(0-100)/ 悬浮按钮位置(右下 / 左下)设置,按钮会自动避开右下角的发帖按钮。
+
### 开发并发布插件
插件就是一个包含 `plugin.json` 的 zip 包:
@@ -442,7 +457,10 @@ docker run -d --name clearlove --restart=always \
| `badge` | `post_badge` | 给指定昵称的帖子打上自定义标识 |
插件还可在 `config` 中**声明配置项**,后台「插件管理 → 插件设置」会自动渲染表单,
-钩子字段用 `{config:key}` 引用配置值(示例见 [plugins/official-guard](plugins/official-guard))。
+钩子字段用 `{config:key}` 引用配置值(`guard` / `badge` / `html` 钩子均支持)。
+
+配置项类型:`text` / `textarea` / `switch` / `select` / `number` / **`audio`(音频上传,含试听与旧文件自动清理)**。
+较长的 HTML / JS 片段可放在插件目录的独立文件中,`html` 字段写成 `"@player.html"` 即可,无需在 JSON 里转义。
发布流程:在云端 <https://clearlove.kazx.top/dev/register> 注册开发者(邮箱验证)→ 开发者后台上传 zip → 管理员审核上架 → 所有站点即可一键安装。详见 [docs/PLUGIN.md](docs/PLUGIN.md)。
diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md
index df94029..0141d8c 100644
--- a/docs/PLUGIN.md
+++ b/docs/PLUGIN.md
@@ -77,14 +77,29 @@ your-plugin.zip
|---|---|
| `key` | 字段名,供 `{config:key}` 引用 |
| `label` | 后台表单里显示的名称 |
-| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` |
+| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` / `audio`(音频上传) |
| `default` | 未配置时的默认值 |
| `help` | 表单下方的说明文字 |
| `options` | 仅 `select` 使用,候选项数组 |
+### 上传型字段(audio)
+
+`type: "audio"` 会渲染成「试听 + 选择文件」的上传控件,管理员保存后配置值即为文件的可访问地址
+(形如 `/uploads/plugins/<插件名>/audio-xxxxxxxx.mp3`),可直接用于 `{config:key}`:
+
+```json
+{ "key": "music", "label": "背景音乐文件", "type": "audio",
+ "default": "", "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB" }
+```
+
+- 文件名由服务端生成,不使用上传时的原始文件名
+- 限制 20MB 与扩展名白名单;重新上传或勾选「删除当前音乐」会自动清理旧文件
+- 配置值会被清除引号与尖括号等危险字符 —— 因为它会被原样注入页面
+
### 在钩子里引用配置
-任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值:
+任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值。
+`guard` / `badge` 与 `html` 类钩子都支持:
```json
{ "hook": "compose_guard", "type": "guard",
@@ -92,6 +107,11 @@ your-plugin.zip
"label": "{config:badge}" }
```
+```json
+{ "hook": "footer_html", "type": "html",
+ "html": "<audio src=\"{config:music}\" autoplay loop></audio>" }
+```
+
配置值保存在数据库 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
---
@@ -124,6 +144,25 @@ your-plugin.zip
注入的 HTML 会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
+### 片段较长时用 `@文件`
+
+在 JSON 里转义大段 HTML / JS 很痛苦。把片段放在插件目录下的独立文件里,
+`html` 字段写成 `@文件名` 即可,安装(zip)时会随插件一起落盘:
+
+```json
+{ "hook": "footer_html", "type": "html", "html": "@player.html" }
+```
+
+```
+插件 zip 根目录/
+├── plugin.json
+└── player.html ← 与 plugin.json 同级,整段 HTML/CSS/JS 原样放在这里
+```
+
+- 只读取插件目录下的**直接文件名**,`@../xxx` 这类路径会被无视
+- 片段文件缺失时该钩子被跳过(不会把 `@player.html` 原样输出到页面)
+- 官方「背景音乐」插件就是这么写的,可直接参考 `plugins/bgm/`
+
### filter 钩子示例
```json
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index 10e1858..b75ab09 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -6,10 +6,12 @@ import (
"archive/zip"
"bytes"
"encoding/json"
+ "errors"
"fmt"
"html/template"
"io"
"io/fs"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -746,25 +748,56 @@ func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
return
}
+ // 音频上传会把请求变成 multipart,这里限制总大小并显式解析
+ r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
+ if err := r.ParseMultipartForm(8 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
+ http.Error(w, "表单提交失败:文件超过 20MB 或格式错误", http.StatusBadRequest)
+ return
+ }
name := filepath.Base(r.FormValue("plugin"))
p, ok := plugin.Load()[name]
if !ok {
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
return
}
+ old := plugin.ConfigOf(name)
values := map[string]string{}
for _, f := range p.Config {
if f.Key == "" {
continue
}
- if f.Type == "switch" {
+ switch f.Type {
+ case "switch":
values[f.Key] = "0"
if r.FormValue("cfg_"+f.Key) == "1" {
values[f.Key] = "1"
}
- continue
+ case "audio", "file":
+ // 优先用新上传的文件,其次是"删除",都没有则保留原值
+ var fh *multipart.FileHeader
+ if r.MultipartForm != nil {
+ if list := r.MultipartForm.File["cfg_"+f.Key+"__file"]; len(list) > 0 {
+ fh = list[0]
+ }
+ }
+ switch {
+ case fh != nil && fh.Size > 0:
+ url, err := savePluginAsset(name, fh)
+ if err != nil {
+ http.Error(w, "上传失败:"+err.Error(), http.StatusBadRequest)
+ return
+ }
+ removePluginAsset(old[f.Key])
+ values[f.Key] = url
+ case r.FormValue("cfg_"+f.Key+"__clear") == "1":
+ removePluginAsset(old[f.Key])
+ values[f.Key] = ""
+ default:
+ values[f.Key] = sanitizePluginAsset(r.FormValue("cfg_" + f.Key))
+ }
+ default:
+ values[f.Key] = r.FormValue("cfg_" + f.Key)
}
- values[f.Key] = r.FormValue("cfg_" + f.Key)
}
if err := plugin.SaveConfig(p, values); err != nil {
http.Error(w, "保存失败: "+err.Error(), http.StatusInternalServerError)
@@ -774,6 +807,73 @@ func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
+// pluginAssetExts 插件素材允许的音频扩展名
+var pluginAssetExts = map[string]bool{
+ ".mp3": true, ".m4a": true, ".aac": true, ".ogg": true, ".oga": true,
+ ".opus": true, ".wav": true, ".flac": true, ".webm": true,
+}
+
+// savePluginAsset 保存插件上传的素材文件,返回可直接访问的 URL。
+// 文件名由服务端生成,不使用用户提交的文件名,避免路径与注入问题。
+func savePluginAsset(pluginName string, fh *multipart.FileHeader) (string, error) {
+ if fh.Size > 20<<20 {
+ return "", fmt.Errorf("文件不能超过 20MB")
+ }
+ ext := strings.ToLower(filepath.Ext(fh.Filename))
+ if !pluginAssetExts[ext] {
+ return "", fmt.Errorf("不支持的音频格式(支持 mp3 / m4a / aac / ogg / opus / wav / flac)")
+ }
+ sub := filepath.Base(pluginName)
+ dir := filepath.Join(config.Cfg.UploadDir, "plugins", sub)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ src, err := fh.Open()
+ if err != nil {
+ return "", err
+ }
+ defer src.Close()
+ name := "audio-" + util.RandomHex(8) + ext
+ dst, err := os.Create(filepath.Join(dir, name))
+ if err != nil {
+ return "", err
+ }
+ defer dst.Close()
+ if _, err := io.Copy(dst, io.LimitReader(src, 20<<20)); err != nil {
+ return "", err
+ }
+ return "/uploads/plugins/" + sub + "/" + name, nil
+}
+
+// removePluginAsset 删除本插件此前上传的素材(重新上传或清除时调用)。
+// 只处理 /uploads/plugins/ 下的路径,杜绝借配置值删除其他文件。
+func removePluginAsset(url string) {
+ const prefix = "/uploads/plugins/"
+ url = strings.TrimSpace(url)
+ if !strings.HasPrefix(url, prefix) || strings.Contains(url, "..") {
+ return
+ }
+ // /uploads/plugins/bgm/x.mp3 -> <UploadDir>/plugins/bgm/x.mp3
+ path := filepath.Join(config.Cfg.UploadDir, "plugins",
+ filepath.FromSlash(strings.TrimPrefix(url, prefix)))
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ util.Log("warn", "清理旧素材失败 %s: %v", path, err)
+ }
+}
+
+// sanitizePluginAsset 清理素材地址中的危险字符。
+// 该值会被插件以 {config:key} 原样注入页面,必须保证不含引号与尖括号。
+func sanitizePluginAsset(v string) string {
+ v = strings.TrimSpace(util.StripHTML(v))
+ return strings.Map(func(r rune) rune {
+ switch r {
+ case '"', '\'', '<', '>', '\\', '`':
+ return -1
+ }
+ return r
+ }, v)
+}
+
// AdminPluginUpload 上传安装插件 zip
func AdminPluginUpload(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
diff --git a/internal/handlers/plugin_asset_test.go b/internal/handlers/plugin_asset_test.go
new file mode 100644
index 0000000..7f7f58c
--- /dev/null
+++ b/internal/handlers/plugin_asset_test.go
@@ -0,0 +1,74 @@
+package handlers
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "clearlove/internal/config"
+)
+
+// TestRemovePluginAsset 素材清理必须定位到 <UploadDir>/plugins/ 下的真实文件。
+// 曾经漏掉 plugins 一段,导致重新上传后旧文件一直留在磁盘上。
+func TestRemovePluginAsset(t *testing.T) {
+ root := t.TempDir()
+ config.Cfg.UploadDir = filepath.Join(root, "uploads")
+ dir := filepath.Join(config.Cfg.UploadDir, "plugins", "bgm")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ target := filepath.Join(dir, "audio-abc.mp3")
+ if err := os.WriteFile(target, []byte("music"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ removePluginAsset("/uploads/plugins/bgm/audio-abc.mp3")
+ if _, err := os.Stat(target); !os.IsNotExist(err) {
+ t.Fatalf("旧素材未被删除: %v", err)
+ }
+
+ // 重复删除不应报错,也不应影响其他文件
+ removePluginAsset("/uploads/plugins/bgm/audio-abc.mp3")
+}
+
+// TestRemovePluginAssetGuards 只清理插件素材目录内的文件,拒绝越权路径
+func TestRemovePluginAssetGuards(t *testing.T) {
+ root := t.TempDir()
+ config.Cfg.UploadDir = filepath.Join(root, "uploads")
+ if err := os.MkdirAll(config.Cfg.UploadDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ keep := filepath.Join(config.Cfg.UploadDir, "keep.webp")
+ if err := os.WriteFile(keep, []byte("keep"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ for _, u := range []string{
+ "",
+ "/uploads/keep.webp", // 不在插件目录内
+ "/uploads/plugins/../../keep.webp", // 路径穿越
+ "/uploads/plugins/", // 目录本身
+ "https://example.com/x.mp3", // 外部地址
+ "/uploads/plugins/../../../etc/passwd", // 绝对越权
+ } {
+ removePluginAsset(u)
+ }
+ if _, err := os.Stat(keep); err != nil {
+ t.Fatalf("不应删除插件素材目录之外的文件: %v", err)
+ }
+}
+
+// TestSanitizePluginAsset 素材地址要能安全地注入页面(不含引号与尖括号)
+func TestSanitizePluginAsset(t *testing.T) {
+ cases := map[string]string{
+ `/uploads/plugins/bgm/a.mp3`: `/uploads/plugins/bgm/a.mp3`,
+ `/uploads/plugins/bgm/a'.mp3`: `/uploads/plugins/bgm/a.mp3`,
+ `"/uploads/plugins/bgm/a.mp3"`: `/uploads/plugins/bgm/a.mp3`,
+ `x"><script>alert(1)</script>`: `xalert(1)`, // StripHTML 去掉标签,尖括号与引号也会被清除
+ }
+ for in, want := range cases {
+ if got := sanitizePluginAsset(in); got != want {
+ t.Errorf("sanitizePluginAsset(%q) = %q, 期望 %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index e0c24d7..2657ebe 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -137,12 +137,41 @@ func Load() map[string]*Plugin {
}
var p Plugin
if json.Unmarshal(b, &p) == nil && p.Name != "" {
+ loadHookFiles(&p, filepath.Join(dir(), e.Name()))
out[p.Name] = &p
}
}
return out
}
+// loadHookFiles 支持把 html 片段放在插件目录的文件中:
+//
+// {"hook":"footer_html","type":"html","html":"@player.html"}
+//
+// 表示读取同目录下的 player.html。带脚本的插件用它可以避免在 JSON 里
+// 转义大段 HTML,插件安装(zip)时会连同该文件一起落盘。
+func loadHookFiles(p *Plugin, base string) {
+ for i := range p.Hooks {
+ h := &p.Hooks[i]
+ if h.Type != "html" || !strings.HasPrefix(h.HTML, "@") {
+ continue
+ }
+ // 只允许插件目录下的直接文件名,避免路径穿越
+ name := filepath.Base(strings.TrimSpace(h.HTML[1:]))
+ if name == "" || name == "." || name == string(filepath.Separator) {
+ h.HTML = ""
+ continue
+ }
+ b, err := os.ReadFile(filepath.Join(base, name))
+ if err != nil {
+ // 文件缺失时清空,避免把 "@xxx" 原样注入页面
+ h.HTML = ""
+ continue
+ }
+ h.HTML = string(b)
+ }
+}
+
// enabledSet 已启用插件名集合
func enabledSet() map[string]bool {
set := map[string]bool{}
@@ -474,7 +503,9 @@ func CallFilter(content string) string {
return content
}
-// CallHTML 收集指定页面钩子点的全部启用插件 HTML 片段
+// CallHTML 收集指定页面钩子点的全部启用插件 HTML 片段,
+// 并展开片段中的 {config:key} 占位符(与 guard / badge 钩子保持一致),
+// 插件据此把后台配置好的值(如上传的音乐地址)注入页面。
func CallHTML(hook string) string {
var b strings.Builder
enabled := enabledSet()
@@ -482,9 +513,10 @@ func CallHTML(hook string) string {
if !enabled[name] {
continue
}
+ cfg := GetConfig(p)
for _, h := range p.Hooks {
if h.Hook == hook && h.Type == "html" {
- b.WriteString(h.HTML)
+ b.WriteString(expand(h.HTML, cfg))
}
}
}
diff --git a/internal/plugin/plugin_test.go b/internal/plugin/plugin_test.go
new file mode 100644
index 0000000..78e108b
--- /dev/null
+++ b/internal/plugin/plugin_test.go
@@ -0,0 +1,115 @@
+package plugin
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "clearlove/internal/config"
+ "clearlove/internal/database"
+)
+
+// setup 准备带数据库的插件测试环境(插件配置保存在 settings 表)
+func setup(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ dataDir := filepath.Join(root, "data")
+ if err := os.MkdirAll(dataDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ config.Cfg = config.Config{
+ DataDir: dataDir,
+ UploadDir: filepath.Join(root, "uploads"),
+ DBType: "sqlite",
+ SQLitePath: filepath.Join(dataDir, "clearlove.db"),
+ Installed: true,
+ Secret: "test-secret",
+ }
+ if err := database.Connect(); err != nil {
+ t.Fatalf("数据库连接失败: %v", err)
+ }
+ t.Cleanup(func() {
+ if database.DB != nil {
+ _ = database.DB.Close()
+ database.DB = nil
+ }
+ })
+ if err := database.Migrate(); err != nil {
+ t.Fatalf("数据库建表失败: %v", err)
+ }
+ return dataDir
+}
+
+// writePlugin 写入一个插件目录
+func writePlugin(t *testing.T, dataDir, name, manifest, fragment string) string {
+ t.Helper()
+ d := filepath.Join(dataDir, "plugins", name)
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, "plugin.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if fragment != "" {
+ if err := os.WriteFile(filepath.Join(d, "player.html"), []byte(fragment), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return d
+}
+
+// TestCallHTMLFileAndConfig 验证 html 钩子:@片段文件加载、{config:} 展开与启用开关
+func TestCallHTMLFileAndConfig(t *testing.T) {
+ dataDir := setup(t)
+ writePlugin(t, dataDir, "demo", `{
+ "name":"demo","version":"1.0.0",
+ "config":[{"key":"music","default":"/uploads/default.mp3"}],
+ "hooks":[{"hook":"footer_html","type":"html","html":"@player.html"}]
+ }`, `<audio src="{config:music}"></audio>`)
+
+ // 未启用:不注入任何内容
+ if got := CallHTML("footer_html"); got != "" {
+ t.Fatalf("未启用的插件不应注入内容, got %q", got)
+ }
+
+ SetEnabled("demo", true)
+ if got := CallHTML("footer_html"); got != `<audio src="/uploads/default.mp3"></audio>` {
+ t.Fatalf("默认配置未展开: %q", got)
+ }
+
+ p := Load()["demo"]
+ if p == nil {
+ t.Fatal("插件未被加载")
+ }
+ if err := SaveConfig(p, map[string]string{"music": "/uploads/plugins/demo/audio-abc.mp3"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := CallHTML("footer_html"); !strings.Contains(got, "/uploads/plugins/demo/audio-abc.mp3") {
+ t.Fatalf("配置值未注入页面: %q", got)
+ }
+
+ // 片段文件缺失时应跳过该钩子,而不是把 "@player.html" 原样注入页面
+ if err := os.Remove(filepath.Join(dataDir, "plugins", "demo", "player.html")); err != nil {
+ t.Fatal(err)
+ }
+ if got := CallHTML("footer_html"); strings.Contains(got, "@player.html") {
+ t.Fatalf("片段缺失时不应注入占位文本: %q", got)
+ }
+}
+
+// TestHookFragmentTraversal @片段文件不允许路径穿越
+func TestHookFragmentTraversal(t *testing.T) {
+ dataDir := setup(t)
+ if err := os.WriteFile(filepath.Join(dataDir, "secret.txt"), []byte("TOP-SECRET"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ writePlugin(t, dataDir, "evil", `{
+ "name":"evil","version":"1.0.0",
+ "hooks":[{"hook":"footer_html","type":"html","html":"@../secret.txt"}]
+ }`, "")
+ SetEnabled("evil", true)
+ if got := CallHTML("footer_html"); strings.Contains(got, "TOP-SECRET") {
+ t.Fatalf("存在路径穿越读取: %q", got)
+ }
+}
diff --git a/plugins/bgm/player.html b/plugins/bgm/player.html
new file mode 100644
index 0000000..a1a6709
--- /dev/null
+++ b/plugins/bgm/player.html
@@ -0,0 +1,147 @@
+<!-- ClearLove 背景音乐播放器(由插件 bgm 注入到 footer_html) -->
+<div id="clv-bgm" hidden
+ data-src="{config:music}"
+ data-vol="{config:volume}"
+ data-auto="{config:autoplay}"
+ data-loop="{config:loop}"
+ data-pos="{config:pos}">
+ <audio id="clv-bgm-audio" preload="auto" playsinline></audio>
+ <button id="clv-bgm-btn" type="button" aria-label="播放背景音乐" aria-pressed="false">
+ <svg class="bgm-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 17.5V5.8l10-2v11.7"/><circle cx="6.6" cy="17.8" r="2.6"/><circle cx="16.6" cy="15.5" r="2.6"/></svg>
+ <svg class="bgm-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M9 17.5V5.8l10-2v11.7"/><circle cx="6.6" cy="17.8" r="2.6"/><circle cx="16.6" cy="15.5" r="2.6"/><path d="M3.5 3.5l17 17"/></svg>
+ </button>
+ <span id="clv-bgm-tip" hidden>点击开启背景音乐</span>
+</div>
+<style>
+#clv-bgm{position:fixed;z-index:900;bottom:22px;display:flex;align-items:center;gap:8px;animation:clv-bgm-in .45s ease both}
+#clv-bgm[hidden]{display:none!important}
+#clv-bgm.pos-right{right:18px;flex-direction:row-reverse}
+#clv-bgm.pos-left{left:18px}
+#clv-bgm-btn{width:46px;height:46px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;padding:0;border:0;border-radius:50%;cursor:pointer;color:#fff;background:linear-gradient(135deg,var(--clv-accent,#ff7a9c),var(--clv-accent-2,#8a6cff));box-shadow:0 8px 22px rgba(0,0,0,.22)}
+#clv-bgm-btn:focus-visible{outline:2px solid var(--clv-accent);outline-offset:2px}
+#clv-bgm-btn svg{width:22px;height:22px}
+#clv-bgm-btn .bgm-off{display:none}
+#clv-bgm.paused #clv-bgm-btn .bgm-on{display:none}
+#clv-bgm.paused #clv-bgm-btn .bgm-off{display:block}
+#clv-bgm.playing #clv-bgm-btn{animation:clv-bgm-pulse 2.4s ease-in-out infinite}
+#clv-bgm-tip{font-size:12px;padding:5px 10px;border-radius:999px;white-space:nowrap;background:var(--clv-card,#fff);color:var(--clv-text,#23262f);border:1px solid var(--clv-border,rgba(0,0,0,.1));box-shadow:0 6px 16px rgba(0,0,0,.12)}
+@keyframes clv-bgm-in{from{opacity:0}to{opacity:1}}
+@keyframes clv-bgm-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.06)}}
+@media (prefers-reduced-motion:reduce){#clv-bgm.playing #clv-bgm-btn{animation:none}}
+@media (max-width:480px){#clv-bgm-btn{width:42px;height:42px}}
+</style>
+<script>
+(function () {
+ var box = document.getElementById('clv-bgm');
+ if (!box) return;
+ var audio = document.getElementById('clv-bgm-audio');
+ var btn = document.getElementById('clv-bgm-btn');
+ var tip = document.getElementById('clv-bgm-tip');
+ var src = (box.getAttribute('data-src') || '').trim();
+ if (!src) return; // 未上传音乐:播放器不出现
+
+ audio.src = src;
+ audio.loop = box.getAttribute('data-loop') === '1';
+ var v = parseInt(box.getAttribute('data-vol'), 10);
+ audio.volume = Math.min(1, Math.max(0, (isNaN(v) ? 35 : v) / 100));
+ box.className = box.getAttribute('data-pos') === '左下' ? 'pos-left' : 'pos-right';
+
+ var ON = 'clv_bgm_on', POS = 'clv_bgm_pos';
+ function get(k, sess) { try { return (sess ? sessionStorage : localStorage).getItem(k); } catch (e) { return null; } }
+ function set(k, val, sess) { try { (sess ? sessionStorage : localStorage).setItem(k, val); } catch (e) { } }
+
+ // 访客的开关优先,其次才是后台的「自动播放」设置
+ var saved = get(ON);
+ var on = saved === null ? box.getAttribute('data-auto') === '1' : saved === '1';
+ var armed = false, tipTimer = null;
+
+ function render() {
+ var playing = !audio.paused && !audio.ended;
+ box.classList.toggle('playing', playing);
+ box.classList.toggle('paused', !playing);
+ btn.setAttribute('aria-pressed', playing ? 'true' : 'false');
+ btn.setAttribute('aria-label', playing ? '暂停背景音乐' : '播放背景音乐');
+ }
+
+ // 浏览器要求音频必须由用户手势触发:监听一次全局交互再播
+ function armGesture() {
+ if (armed) return;
+ armed = true;
+ var fire = function () {
+ document.removeEventListener('pointerdown', fire, true);
+ document.removeEventListener('keydown', fire, true);
+ armed = false;
+ if (on && audio.paused) play(true);
+ };
+ document.addEventListener('pointerdown', fire, true);
+ document.addEventListener('keydown', fire, true);
+ }
+
+ function showTip() {
+ tip.hidden = false;
+ clearTimeout(tipTimer);
+ tipTimer = setTimeout(function () { tip.hidden = true; }, 10000);
+ }
+
+ function play(byGesture) {
+ var p = audio.play();
+ if (p && p.catch) {
+ p.catch(function () {
+ // 自动播放被拦截:提示访客,并在其首次交互时自动开始
+ if (!byGesture) { showTip(); armGesture(); }
+ render();
+ });
+ }
+ }
+
+ btn.addEventListener('click', function () {
+ if (audio.paused) {
+ on = true;
+ set(ON, '1');
+ tip.hidden = true;
+ play(true);
+ } else {
+ on = false;
+ set(ON, '0');
+ audio.pause();
+ }
+ render();
+ });
+
+ audio.addEventListener('play', render);
+ audio.addEventListener('pause', render);
+ // 文件缺失或格式不支持时直接隐藏,避免留下一个点不动的按钮
+ audio.addEventListener('error', function () { box.hidden = true; });
+
+ // 记录播放进度,站内翻页后接着播
+ var lastSave = 0;
+ audio.addEventListener('timeupdate', function () {
+ if (Math.abs(audio.currentTime - lastSave) >= 1) {
+ lastSave = audio.currentTime;
+ set(POS, String(audio.currentTime), true);
+ }
+ });
+ window.addEventListener('pagehide', function () { set(POS, String(audio.currentTime), true); });
+ var resume = parseFloat(get(POS, true));
+ if (isFinite(resume) && resume > 0) {
+ audio.addEventListener('loadedmetadata', function () {
+ try { if (!audio.duration || resume < audio.duration) audio.currentTime = resume; } catch (e) { }
+ });
+ }
+
+ // 自动避开右下角的发帖悬浮按钮,避免两个按钮叠在一起
+ function place() {
+ var fab = document.querySelector('.fab');
+ var right = box.classList.contains('pos-right');
+ var r = fab ? fab.getBoundingClientRect() : null;
+ var sameSide = r && r.width > 0 && (right ? window.innerWidth - r.right < 90 : r.left < 90);
+ box.style.bottom = sameSide ? Math.round(window.innerHeight - r.top + 12) + 'px' : '';
+ }
+
+ box.hidden = false;
+ place();
+ window.addEventListener('resize', place);
+ render();
+ if (on) play(false);
+})();
+</script>
diff --git a/plugins/bgm/plugin.json b/plugins/bgm/plugin.json
new file mode 100644
index 0000000..f25e557
--- /dev/null
+++ b/plugins/bgm/plugin.json
@@ -0,0 +1,48 @@
+{
+ "name": "bgm",
+ "version": "1.0.0",
+ "author": "ClearLove",
+ "description": "背景音乐:管理员上传音乐后,访客打开站点即可听到背景音乐,支持自动播放、循环、音量与按钮位置设置",
+ "requires": "2.0.0",
+ "config": [
+ {
+ "key": "music",
+ "label": "背景音乐文件",
+ "type": "audio",
+ "default": "",
+ "help": "支持 mp3 / m4a / aac / ogg / opus / wav / flac,单个文件不超过 20MB;重新上传会自动删除旧文件"
+ },
+ {
+ "key": "autoplay",
+ "label": "自动播放",
+ "type": "switch",
+ "default": "1",
+ "help": "受浏览器自动播放策略限制:被拦截时按钮旁会出现提示,访客点击一次即可播放"
+ },
+ {
+ "key": "loop",
+ "label": "循环播放",
+ "type": "switch",
+ "default": "1",
+ "help": "关闭后音乐播放完自动停止"
+ },
+ {
+ "key": "volume",
+ "label": "默认音量(0-100)",
+ "type": "number",
+ "default": "35",
+ "help": "访客可通过悬浮按钮自行开关,其选择会被记住"
+ },
+ {
+ "key": "pos",
+ "label": "悬浮按钮位置",
+ "type": "select",
+ "default": "右下",
+ "options": ["右下", "左下"],
+ "help": "按钮会自动避开同侧右下角的发帖悬浮按钮"
+ }
+ ],
+ "hooks": [
+ { "hook": "footer_html", "type": "html", "html": "@player.html" }
+ ]
+}
diff --git a/web/static/app.css b/web/static/app.css
index 6d9eead..3c5fc4c 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -369,6 +369,12 @@ textarea { resize: vertical; }
.settings-form .card { padding: 18px; }
.check-row { display: flex; align-items: center; gap: 8px; }
.check-row input { width: auto; margin: 0; }
+/* ---------------- 插件设置:上传型字段 ---------------- */
+.plugin-audio { margin: 10px 0; display: flex; flex-direction: column; gap: 8px; }
+.plugin-audio .field-label { font-size: 14px; color: #4b5060; }
+.plugin-audio-prev { width: 100%; max-width: 420px; height: 36px; }
+.plugin-audio input[type=file] { font-size: 13px; }
+
/* ---------------- 数据迁移 ---------------- */
.tips-list { margin: 6px 0 0; padding-left: 20px; font-size: 13px; color: #4b5060; line-height: 1.9; }
.tips-list li { margin-bottom: 2px; }
diff --git a/web/templates/admin.html b/web/templates/admin.html
index 0a6430a..b6ee47f 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -470,12 +470,25 @@
{{range .plugins}}
{{if .Config}}
-<form class="card plugin-config" method="post" action="/admin/plugins/config">
+<form class="card plugin-config" method="post" action="/admin/plugins/config" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{$.csrf}}">
<input type="hidden" name="plugin" value="{{.Name}}">
<h3>{{.Name}} <small class="muted">v{{.Version}} 插件设置</small></h3>
{{$cfg := index $.configs .Name}}
{{range .Config}}
+ {{if eq .Type "audio"}}
+ {{/* 上传型字段:不套 label,避免点击试听控件时触发文件选择 */}}
+ {{$audio := index $cfg .Key}}
+ <div class="plugin-audio">
+ <span class="field-label">{{.Label}}</span>
+ {{if $audio}}
+ <audio src="{{$audio}}" controls preload="none" class="plugin-audio-prev"></audio>
+ <label class="check-row"><input type="checkbox" name="cfg_{{.Key}}__clear" value="1"> 删除当前音乐</label>
+ {{else}}<span class="muted small">尚未上传</span>{{end}}
+ <input type="file" name="cfg_{{.Key}}__file" accept="audio/*,.mp3,.m4a,.aac,.ogg,.oga,.opus,.wav,.flac">
+ <input type="hidden" name="cfg_{{.Key}}" value="{{$audio}}">
+ </div>
+ {{else}}
<label>{{.Label}}
{{if eq .Type "textarea"}}
<textarea name="cfg_{{.Key}}" rows="3" placeholder="{{.Default}}">{{index $cfg .Key}}</textarea>
@@ -488,6 +501,7 @@
<input type="text" name="cfg_{{.Key}}" value="{{index $cfg .Key}}" placeholder="{{.Default}}">
{{end}}
</label>
+ {{end}}
{{if .Help}}<p class="muted small plugin-help">{{.Help}}</p>{{end}}
{{end}}
<div class="btn-row"><button class="btn btn-primary" type="submit">保存设置</button></div>