feat(plugin): 插件系统支持配置声明、发帖守卫与帖子标识
15 个文件变更
+696
-125
fangqihang1717@163.com
| •README.md | +6 -1 |
| •docs/PLUGIN.md | +215 -64 |
| •internal/database/database.go | +3 -2 |
| •internal/handlers/admin.go | +49 -2 |
| •internal/handlers/api.go | +9 -17 |
| •internal/handlers/front.go | +65 -25 |
| •internal/models/models.go | +55 -1 |
| •internal/plugin/plugin.go | +203 -6 |
| •main.go | +1 -0 |
| •plugins/official-guard.zip | 二进制 |
| •plugins/official-guard/plugin.json | +44 -0 |
| •web/static/app.css | +6 -2 |
| •web/static/app.js | +5 -2 |
| •web/templates/admin.html | +33 -1 |
| •web/templates/front.html | +2 -2 |
变更内容
diff --git a/README.md b/README.md
index 630c255..4f0ed76 100644
--- a/README.md
+++ b/README.md
@@ -395,9 +395,14 @@ docker run -d --name clearlove --restart=always \
| 钩子类型 | 钩子名 | 用途 |
|---|---|---|
-| `html` | `header_html` / `footer_html` | 向页面头部 / 底部注入 HTML |
+| `html` | `header_html` / `footer_html` / `admin_plugins_top` | 向页面头部 / 底部 / 后台插件页注入 HTML |
| `filter` | `content_filter` | 正则改写帖子与评论内容 |
| `http` | `post_created` / `comment_created` / `report_created` | 事件 Webhook(POST JSON,带 `X-ClearLove-Event` 头) |
+| `guard` | `compose_guard` | 发帖守卫:命中昵称时强制验证后台管理员账号,通过后才允许发布 |
+| `badge` | `post_badge` | 给指定昵称的帖子打上自定义标识 |
+
+插件还可在 `config` 中**声明配置项**,后台「插件管理 → 插件设置」会自动渲染表单,
+钩子字段用 `{config:key}` 引用配置值(示例见 [plugins/official-guard](plugins/official-guard))。
发布流程:在云端 <https://clearlove.kazx.top/dev/register> 注册开发者(邮箱验证)→ 开发者后台上传 zip → 管理员审核上架 → 所有站点即可一键安装。详见 [docs/PLUGIN.md](docs/PLUGIN.md)。
diff --git a/docs/PLUGIN.md b/docs/PLUGIN.md
index 40f39b2..212158b 100644
--- a/docs/PLUGIN.md
+++ b/docs/PLUGIN.md
@@ -1,105 +1,256 @@
-# 插件开发文档
+# ClearLove 插件开发文档
-ClearLove 表白墙采用 **钩子(Hook)模式** 插件系统。插件以 zip 包上传,
-解压后安装于 `data/plugins/<插件名>/`,可在后台 **插件管理** 中启用 / 禁用 / 卸载。
+插件用于在不修改表白墙源码的前提下扩展功能。插件是**声明式**的:一个 zip 包 + 一份 `plugin.json`,
+无需编译、无需服务端代码,安装后由表白墙内核解释执行,因此可以安全地在插件市场分发。
+
+---
## 1. 插件包结构
```
-my-plugin.zip
-└── plugin.json # 插件清单(必须)
+your-plugin.zip
+├── plugin.json # 必需:插件清单(放在根目录或任意子目录均可)
+└── (可选)其他静态资源
```
-(可附带自定义资源文件,服务端会一并解压到插件目录。)
+打包要求:
+
+- 必须是 zip 格式,`plugin.json` 需为 **UTF-8** 编码
+- 插件名(`name`)会被用作目录名,建议使用中文或英文短名,避免 `/`、`\` 等字符
+- 单包不超过 50MB
+
+安装方式:
+
+| 方式 | 操作 |
+|---|---|
+| 后台上传 | 后台「插件管理 → 安装插件」选择 zip → 上传并安装 |
+| 插件市场 | 后台「插件商店」中一键安装(自动下载并校验 SHA256) |
+| 手动部署 | 解压到 `data/plugins/<插件名>/` 后刷新后台插件列表 |
-## 2. plugin.json 清单
+安装后需在「插件管理」中点击**启用**才会生效。
+
+---
+
+## 2. plugin.json 完整字段
```json
{
- "name": "anti-ad",
+ "name": "插件名(必需,唯一)",
"version": "1.0.0",
- "author": "夏日之瓜",
- "description": "自动过滤广告关键词并推送到审核机器人",
- "hooks": [
- {
- "hook": "content_filter",
- "type": "filter",
- "match": "(加微信|代购|刷单)",
- "replace": "***"
- },
- {
- "hook": "post_created",
- "type": "http",
- "url": "https://your-bot.example.com/webhook"
- },
- {
- "hook": "footer_html",
- "type": "html",
- "html": "<div style=\"text-align:center;font-size:12px\">由 anti-ad 插件提供支持</div>"
- }
- ]
+ "author": "作者",
+ "description": "一句话说明",
+ "requires": "2.0.0",
+ "config": [ ... ],
+ "hooks": [ ... ]
}
```
+| 字段 | 必填 | 说明 |
+|---|---|---|
+| `name` | ✅ | 插件名,同时作为安装目录名 |
+| `version` | | 版本号,展示用 |
+| `author` | | 作者名 |
+| `description` | | 简介,展示在插件列表与插件市场 |
+| `requires` | | 建议的最低表白墙版本(仅作展示提示) |
+| `config` | | 配置项声明,见第 3 节 |
+| `hooks` | ✅ | 钩子列表,见第 4 节 |
+
+---
+
+## 3. 配置项(config)
+
+插件可以声明若干配置项,表白墙会**自动在后台「插件管理 → 插件设置」渲染表单**,
+管理员填写后立即生效,无需重新安装插件。
+
+```json
+"config": [
+ { "key": "nicknames", "label": "专享昵称", "type": "textarea",
+ "default": "官方,公告", "help": "多个昵称用逗号或换行分隔" },
+ { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方" },
+ { "key": "enable", "label": "启用提示", "type": "switch", "default": "1" },
+ { "key": "position", "label": "显示位置", "type": "select",
+ "default": "bottom", "options": ["top", "bottom"] }
+]
+```
+
| 字段 | 说明 |
|---|---|
-| name | 插件唯一名(安装目录名,必填) |
-| version / author / description | 展示信息 |
-| hooks[].hook | 钩子名(见下表) |
-| hooks[].type | `html` / `filter` / `http` 三种类型 |
+| `key` | 字段名,供 `{config:key}` 引用 |
+| `label` | 后台表单里显示的名称 |
+| `type` | `text`(单行)/ `textarea`(多行)/ `switch`(开关,值为 `1`/`0`)/ `select`(下拉)/ `number` |
+| `default` | 未配置时的默认值 |
+| `help` | 表单下方的说明文字 |
+| `options` | 仅 `select` 使用,候选项数组 |
-## 3. 钩子一览
+### 在钩子里引用配置
-### 3.1 html 类型 —— 页面注入
+任意字符串字段都支持 `{config:字段名}` 占位符,运行时会被替换为管理员填写的值:
-| 钩子名 | 注入位置 |
-|---|---|
-| `header_html` | `<body>` 顶部(全站,含后台) |
-| `footer_html` | 页脚区域 |
+```json
+{ "hook": "compose_guard", "type": "guard",
+ "nicknames": "{config:nicknames}",
+ "label": "{config:badge}" }
+```
+
+配置值保存在数据库 `settings` 表(键名 `plugin.<插件名>.<字段名>`),随站点一起备份。
+
+---
+
+## 4. 钩子一览
+
+`hooks` 是一个数组,每个元素至少包含 `hook` 与 `type`:
+
+| type | hook | 作用 | 关键字段 |
+|---|---|---|---|
+| `html` | `header_html` | 前台页面 `<head>` 后注入 HTML | `html` |
+| `html` | `footer_html` | 前台页面底部注入 HTML | `html` |
+| `html` | `admin_plugins_top` | 后台「插件管理」页顶部注入 HTML | `html` |
+| `filter` | `content_filter` | 用正则改写帖子 / 评论内容 | `match`、`replace` |
+| `http` | `post_created` | 发帖成功后回调 Webhook | `url` |
+| `http` | `comment_created` | 评论后回调 Webhook | `url` |
+| `http` | `report_created` | 举报后回调 Webhook | `url` |
+| `guard` | `compose_guard` | **发帖前置守卫**:命中昵称时强制验证后台账号 | `nicknames`、`require`、`label`、`message` |
+| `badge` | `post_badge` | 给指定昵称的帖子打上标识 | `nicknames`、`label` |
+
+> Webhook 以 `POST` + JSON 发送,请求头带 `X-ClearLove-Event`,超时 10 秒,失败只记录日志、不影响发帖。
+
+### html 钩子示例
+
+```json
+{ "hook": "footer_html", "type": "html",
+ "html": "<div class='daily-quote'>今天也要加油鸭</div>" }
+```
+
+注入的 HTML 会被原样输出到页面,因此**可以包含 `<style>` 与 `<script>`** —— 这也是插件实现
+自定义交互(弹窗、动效、统计)的主要方式。请勿注入恶意代码。
+
+### filter 钩子示例
+
+```json
+{ "hook": "content_filter", "type": "filter", "match": "广告|加群|代刷", "replace": "***" }
+```
+
+`match` 是 Go 正则表达式,对所有发帖与评论内容生效(插件启用后 30 秒内自动热加载)。
-`html` 字段为注入的 HTML 片段(**不会**被模板转义,请勿注入脚本恶意代码)。
+### http 钩子示例
-### 3.2 filter 类型 —— 内容过滤
+```json
+{ "hook": "post_created", "type": "http", "url": "https://example.com/hook" }
+```
+
+回调体包含事件名与业务字段,例如发帖回调:
+
+```json
+{ "event": "post_created", "post_id": 12, "nickname": "匿名", "content": "...", "time": "2026-09-12T15:00:00Z" }
+```
-| 钩子名 | 触发时机 |
+---
+
+## 5. guard:发帖守卫(需要验证后台账号)
+
+`compose_guard` 用于**防止他人冒充官方身份**:当发帖昵称命中名单时,
+表白墙会在提交前弹出「管理员身份验证」弹窗,要求输入后台管理员账号与密码,
+**服务端校验通过后才会发布**(不通过直接拒绝,无法绕过),并可给帖子打上标识。
+
+| 字段 | 说明 |
|---|---|
-| `content_filter` | 发帖(前台与 API)时对内容执行正则替换 |
+| `nicknames` | 触发的昵称名单,逗号 / 分号 / 换行分隔,支持 `{config:key}` |
+| `require` | `admin` = 必须验证后台管理员账号密码;留空表示仅打标识、不校验 |
+| `label` | 校验通过后写入帖子的标识文字(前台显示为昵称后的彩色徽章) |
+| `message` | 校验失败时返回给用户的提示语 |
-- `match`:Go 正则表达式;`replace`:替换文本(支持 `$1` 分组引用)。
-- 多条规则按插件加载顺序依次执行,规则缓存 30 秒。
+```json
+{
+ "hook": "compose_guard",
+ "type": "guard",
+ "nicknames": "官方,公告板",
+ "require": "admin",
+ "label": "官方",
+ "message": "该昵称为官方专享,请验证管理员账号后发布"
+}
+```
-### 3.3 http 类型 —— 事件 Webhook
+行为说明:
-| 钩子名 | 触发时机 | 载荷字段 |
-|---|---|---|
-| `post_created` | 帖子发布成功 | post_id, nickname, content |
-| `comment_created` | 评论发布成功 | post_id, content |
-| `report_created` | 收到举报 | post_id, reason |
+- 前台发帖页会在昵称命中时**自动弹出验证弹窗**(昵称名单由插件配置实时提供)
+- 表单发帖(`/compose`)与 API 发帖(`POST /api/v1/posts`)都会强制校验
+- API 发帖需在 JSON 中额外提供 `admin_user` 与 `admin_pass`
+- 同一 IP 连续失败 5 次会被锁定 10 分钟,防止暴力破解后台密码
+- 多个插件同时命中时,标识会合并、校验要求取并集
+- 通过验证的帖子会同时带上内置的「**管理员**」标识(表示发布者已验证为管理员),
+ 插件自定义的 `label` 会追加在后面,例如 `["管理员", "官方认证"]`
+
+### badge:帖子标识(无需验证)
+
+只想给某些昵称的帖子加个标记时使用,不涉及验证:
+
+```json
+{ "hook": "post_badge", "type": "badge", "nicknames": "小编,编辑", "label": "编辑" }
+```
+
+标识在发帖时写入帖子记录,**不随插件卸载而消失**(已发布的历史帖子仍保留标识)。
+
+---
-服务端以 `POST` 方式异步请求 `url`,`Content-Type: application/json`,
-请求头带 `X-ClearLove-Event: <钩子名>`,载荷中额外包含 `event` 与 `time` 字段。
-超时 10 秒,失败仅记录日志、不影响主流程。
+## 6. 完整示例:官方身份守卫插件
-## 4. 示例:投稿同步到企业微信机器人
+`plugin.json`:
```json
{
- "name": "wecom-notify",
+ "name": "官方身份守卫",
"version": "1.0.0",
+ "author": "yourname",
+ "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
+ "requires": "2.0.0",
+ "config": [
+ { "key": "nicknames", "label": "专享昵称", "type": "textarea", "default": "官方,公告",
+ "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔" },
+ { "key": "badge", "label": "帖子标识", "type": "text", "default": "官方",
+ "help": "验证通过的帖子在昵称后显示的徽章文字" },
+ { "key": "message", "label": "失败提示", "type": "text",
+ "default": "该昵称为官方专享,请验证管理员账号后发布" }
+ ],
"hooks": [
- { "hook": "post_created", "type": "http", "url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx" }
+ { "hook": "compose_guard", "type": "guard",
+ "nicknames": "{config:nicknames}",
+ "require": "admin",
+ "label": "{config:badge}",
+ "message": "{config:message}" },
+ { "hook": "footer_html", "type": "html",
+ "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>" }
]
}
```
-## 5. 安装与管理
+打包与安装:
-1. 后台 → 插件管理 → 选择 zip → 上传并安装;
-2. 在列表中「启用」,即刻生效(无需重启,规则缓存最长 30 秒);
-3. 「禁用」暂停全部钩子,「卸载」删除插件目录并从启用列表移除。
+```bash
+zip -r official-guard.zip plugin.json
+```
+
+上传到后台「插件管理 → 安装插件」→ 启用 → 在「插件设置」里填写专享昵称即可。
+
+> 表白墙内置了「管理员专享昵称」(网站设置),覆盖最常见的官方身份保护场景;
+> 这个插件演示的是如何用插件实现同类能力,并支持自定义标识文字与多插件组合。
+
+---
-## 6. 限制与安全
+## 7. 发布到插件市场
-- 单个 zip 不超过 50MB;路径穿越(`../`)文件会被忽略。
-- Webhook 为异步执行,不会阻塞用户请求;请勿在钩子中依赖同步返回。
-- 插件为「配置式」钩子,不加载第三方二进制代码,天然隔离运行风险。
+1. 在插件社区注册开发者账号(邮箱验证):<https://clearlove.kazx.top/dev/register>
+2. 开发者后台「发布插件」上传 zip,填写分类、说明与标签
+3. 管理员审核上架后,所有站点都能在后台「插件商店」中一键安装
+4. 之后可在开发者后台查看下载量与安装量,并上传新版本
+
+---
+
+## 8. 调试建议
+
+| 场景 | 建议 |
+|---|---|
+| 改了 plugin.json 不生效 | 插件清单每次请求都会重新读取磁盘,但 `content_filter` 规则有 30 秒缓存 |
+| 配置改了没生效 | 配置存在 `settings` 表并带内存缓存,保存后立即刷新,无需重启 |
+| 钩子没触发 | 确认插件已在「插件管理」中**启用**(安装 ≠ 启用) |
+| 前台注入的 JS 报错 | 打开浏览器控制台查看;注入内容会在渲染时直接输出,注意转义引号 |
+| 想看当前插件与配置 | 后台「插件管理」页可查看全部插件、状态与配置表单 |
diff --git a/internal/database/database.go b/internal/database/database.go
index 2f941ed..93ba24d 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -70,7 +70,7 @@ CREATE TABLE IF NOT EXISTS posts (
id {PK}, user_id {I} DEFAULT 0, nickname VARCHAR(64) DEFAULT '匿名', content {LT},
topic_id {I} DEFAULT 0, ip VARCHAR(64) DEFAULT '', fingerprint VARCHAR(128) DEFAULT '',
status {I} DEFAULT 1, like_count {I} DEFAULT 0, comment_count {I} DEFAULT 0,
- is_admin {I} DEFAULT 0, created_at VARCHAR(40)
+ is_admin {I} DEFAULT 0, badges VARCHAR(255) DEFAULT '', created_at VARCHAR(40)
);
CREATE TABLE IF NOT EXISTS medias (
@@ -126,12 +126,13 @@ func Migrate() error {
}
}
}
- // 兼容旧库:posts 增加 is_admin 列(列已存在时报错,忽略即可)
+ // 兼容旧库:posts 增加 is_admin / badges 列(列已存在时报错,忽略即可)
alter := "ALTER TABLE posts ADD COLUMN is_admin INTEGER DEFAULT 0"
if Driver == "mysql" {
alter = "ALTER TABLE posts ADD COLUMN is_admin INT DEFAULT 0"
}
_, _ = DB.Exec(alter)
+ _, _ = DB.Exec("ALTER TABLE posts ADD COLUMN badges VARCHAR(255) DEFAULT ''")
// 常用索引(忽略已存在错误)
indexes := []string{
diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go
index d45b2d9..10e1858 100644
--- a/internal/handlers/admin.go
+++ b/internal/handlers/admin.go
@@ -7,6 +7,7 @@ import (
"bytes"
"encoding/json"
"fmt"
+ "html/template"
"io"
"io/fs"
"net/http"
@@ -719,12 +720,58 @@ func AdminNoticesSave(w http.ResponseWriter, r *http.Request) {
// ---------- 插件管理 ----------
-// AdminPlugins 插件列表
+// AdminPlugins 插件列表(含各插件声明的配置表单)
func AdminPlugins(w http.ResponseWriter, r *http.Request) {
if a := requireAdmin(w, r, models.PermPlugin); a == nil {
return
}
- Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{"plugins": plugin.List()})
+ list := plugin.List()
+ configs := map[string]map[string]string{}
+ for i := range list {
+ if len(list[i].Config) > 0 {
+ p := list[i].Plugin
+ configs[p.Name] = plugin.GetConfig(&p)
+ }
+ }
+ Render(w, r, "admin_layout.html", "pg_admin_plugins", map[string]any{
+ "plugins": list,
+ "configs": configs,
+ // 插件可通过 {"hook":"admin_plugins_top","type":"html"} 往本页顶部注入内容
+ "adminTop": template.HTML(plugin.CallHTML("admin_plugins_top")),
+ })
+}
+
+// AdminPluginConfig 保存插件配置(仅接受该插件声明过的字段)
+func AdminPluginConfig(w http.ResponseWriter, r *http.Request) {
+ if a := requireAdmin(w, r, models.PermPlugin); a == nil {
+ return
+ }
+ name := filepath.Base(r.FormValue("plugin"))
+ p, ok := plugin.Load()[name]
+ if !ok {
+ http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
+ return
+ }
+ values := map[string]string{}
+ for _, f := range p.Config {
+ if f.Key == "" {
+ continue
+ }
+ if f.Type == "switch" {
+ values[f.Key] = "0"
+ if r.FormValue("cfg_"+f.Key) == "1" {
+ values[f.Key] = "1"
+ }
+ continue
+ }
+ values[f.Key] = r.FormValue("cfg_" + f.Key)
+ }
+ if err := plugin.SaveConfig(p, values); err != nil {
+ http.Error(w, "保存失败: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ util.Log("info", "插件 %s 配置已更新", name)
+ http.Redirect(w, r, "/admin/plugins", http.StatusSeeOther)
}
// AdminPluginUpload 上传安装插件 zip
diff --git a/internal/handlers/api.go b/internal/handlers/api.go
index 5800415..b63f648 100644
--- a/internal/handlers/api.go
+++ b/internal/handlers/api.go
@@ -184,29 +184,21 @@ func APICreatePost(w http.ResponseWriter, r *http.Request) {
if nickname == "" {
nickname = "匿名"
}
- // 管理员专享昵称:校验后台账号密码
- isAdmin := 0
- if models.IsAdminNickname(nickname) {
- ip := util.ClientIP(r)
- if adminTryBlocked(ip) {
- fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
- return
- }
- if !verifyAdminCreds(strings.TrimSpace(body.AdminUser), body.AdminPass) {
- adminTryFail(ip)
- fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
- return
- }
- adminTryReset(ip)
- isAdmin = 1
+ // 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
+ isAdmin, badgeList, code, gErr := guardCompose(nickname,
+ strings.TrimSpace(body.AdminUser), body.AdminPass, util.ClientIP(r))
+ if gErr != "" {
+ fail(w, code, gErr)
+ return
}
topicID := int64(0)
if t := util.StripHTML(body.Topic); t != "" {
topicID = ensureTopic(t)
}
res, err := database.DB.Exec(
- "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
- userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"), isAdmin, models.Now())
+ "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
+ userIDOf(u), nickname, content, topicID, util.ClientIP(r), r.Header.Get("X-Fingerprint"),
+ isAdmin, models.BadgesJSON(badgeList), models.Now())
if err != nil {
fail(w, 500, "发布失败")
return
diff --git a/internal/handlers/front.go b/internal/handlers/front.go
index 2a6b504..ad37d74 100644
--- a/internal/handlers/front.go
+++ b/internal/handlers/front.go
@@ -72,6 +72,44 @@ func adminTryReset(ip string) {
delete(adminTries, ip)
}
+// guardCompose 执行发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard 钩子。
+// 返回 (是否管理员帖, 帖子标识列表, 错误码, 错误提示)
+func guardCompose(nickname, adminUser, adminPass, ip string) (int, []string, int, string) {
+ labels := plugin.NicknameBadges(nickname)
+ needAdmin := false
+ msg := ""
+
+ if models.IsAdminNickname(nickname) {
+ needAdmin = true
+ labels = append(labels, "管理员")
+ msg = "「" + nickname + "」为管理员专享昵称,请填写正确的后台账号与密码"
+ }
+ if g := plugin.CheckComposeGuard(nickname); g != nil {
+ labels = append(labels, g.Labels...)
+ if g.Require {
+ needAdmin = true
+ if msg == "" {
+ msg = g.Message
+ }
+ }
+ }
+ if !needAdmin {
+ return 0, models.MergeBadges(labels), 0, ""
+ }
+ if msg == "" {
+ msg = "该昵称为管理员专享,请填写正确的后台账号与密码"
+ }
+ if adminTryBlocked(ip) {
+ return 0, nil, 429, "验证失败次数过多,请 10 分钟后再试"
+ }
+ if !verifyAdminCreds(adminUser, adminPass) {
+ adminTryFail(ip)
+ return 0, nil, 403, msg
+ }
+ adminTryReset(ip)
+ return 1, models.MergeBadges(labels), 0, ""
+}
+
// CommentItem 评论预览项
type CommentItem struct {
Nickname string `json:"nickname"`
@@ -91,6 +129,8 @@ type Card struct {
Likes int64 `json:"likes"`
CommentCount int64 `json:"comment_count"`
IsAdmin int `json:"is_admin"`
+ BadgesRaw string `json:"-"`
+ Badges []string `json:"badges"`
Comments []CommentItem `json:"comments"`
CreatedAt string `json:"created_at"`
}
@@ -112,7 +152,7 @@ func latestNotice() (int64, string, string) {
func fetchCards(before int64, limit int, topic string) []Card {
q := `
SELECT p.id,p.user_id,IFNULL(p.nickname,'匿名'),IFNULL(p.content,''),IFNULL(t.name,''),
- p.like_count,p.comment_count,IFNULL(p.is_admin,0),p.created_at
+ p.like_count,p.comment_count,IFNULL(p.is_admin,0),IFNULL(p.badges,''),p.created_at
FROM posts p LEFT JOIN topics t ON t.id=p.topic_id
WHERE p.status=1 AND (? <= 0 OR p.id < ?)`
args := []any{before, before}
@@ -133,7 +173,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
for rows.Next() {
c := Card{Images: []string{}, Videos: []string{}, Comments: []CommentItem{}}
if err := rows.Scan(&c.ID, &c.UserID, &c.Nickname, &c.Content, &c.Topic,
- &c.Likes, &c.CommentCount, &c.IsAdmin, &c.CreatedAt); err == nil {
+ &c.Likes, &c.CommentCount, &c.IsAdmin, &c.BadgesRaw, &c.CreatedAt); err == nil {
order = append(order, c.ID)
byID[c.ID] = &c
}
@@ -181,6 +221,7 @@ func fetchCards(before int64, limit int, topic string) []Card {
out := make([]Card, 0, len(order))
for _, id := range order {
c := byID[id]
+ c.Badges = models.DisplayBadges(int64(c.IsAdmin), c.BadgesRaw)
// 评论按时间正序展示
for i, j := 0, len(c.Comments)-1; i < j; i, j = i+1, j-1 {
c.Comments[i], c.Comments[j] = c.Comments[j], c.Comments[i]
@@ -269,6 +310,7 @@ func singleCard(id int64) *Card {
c := &Card{
ID: p.ID, UserID: p.UserID, Nickname: p.Nickname, Content: p.Content,
Likes: p.LikeCount, CommentCount: p.CommentCount, IsAdmin: p.IsAdmin,
+ Badges: models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw),
CreatedAt: p.CreatedAt,
Images: []string{}, Videos: []string{}, Comments: []CommentItem{},
}
@@ -326,8 +368,10 @@ func ComposePage(w http.ResponseWriter, r *http.Request) {
"maxImages": 15, "maxVideos": 3,
// AI 发帖预审是否生效(开启且模型已接入)——前端据此展示审核动画
"aiPrecheck": models.GetSetting("ai_precheck") == "1" && ai.Enabled(),
- // 管理员专享昵称(逗号连接),前端据此在提交时弹出后台账号验证弹窗
- "adminNicks": strings.Join(models.AdminNicknames(), ","),
+ // 需要验证后台账号的昵称(内置管理员昵称 + 插件守卫),
+ // 前端据此在提交时弹出账号验证弹窗
+ "adminNicks": strings.Join(
+ append(models.AdminNicknames(), plugin.GuardNicknames()...), ","),
})
}
@@ -370,21 +414,12 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
nickname = "匿名"
}
- // 管理员专享昵称:必须校验后台账号密码,通过后才允许发布并标记为管理员帖
- isAdmin := 0
- if models.IsAdminNickname(nickname) {
- ip := util.ClientIP(r)
- if adminTryBlocked(ip) {
- fail(w, 429, "验证失败次数过多,请 10 分钟后再试")
- return
- }
- if !verifyAdminCreds(strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass")) {
- adminTryFail(ip)
- fail(w, 403, "「"+nickname+"」为管理员专享昵称,请填写正确的后台账号与密码")
- return
- }
- adminTryReset(ip)
- isAdmin = 1
+ // 发帖守卫:内置「管理员专享昵称」+ 插件 compose_guard
+ isAdmin, badgeList, code, gErr := guardCompose(nickname,
+ strings.TrimSpace(r.FormValue("admin_user")), r.FormValue("admin_pass"), util.ClientIP(r))
+ if gErr != "" {
+ fail(w, code, gErr)
+ return
}
// 先全量校验文件(数量/大小/格式),再落盘,避免部分文件写入后失败
@@ -463,8 +498,9 @@ func ComposeSubmit(w http.ResponseWriter, r *http.Request) {
// 入库
res, err := database.DB.Exec(
- "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,created_at) VALUES(?,?,?,?,?,?,1,?,?)",
- userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin, models.Now())
+ "INSERT INTO posts(user_id,nickname,content,topic_id,ip,fingerprint,status,is_admin,badges,created_at) VALUES(?,?,?,?,?,?,1,?,?,?)",
+ userIDOf(user), nickname, content, topicID, util.ClientIP(r), fp, isAdmin,
+ models.BadgesJSON(badgeList), models.Now())
if err != nil {
fail(w, 500, "发布失败,请稍后重试")
return
@@ -595,8 +631,8 @@ func PostEditSave(w http.ResponseWriter, r *http.Request) {
func loadPost(id int64) *models.Post {
p := &models.Post{}
err := database.DB.QueryRow(
- "SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE id=?", id,
- ).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
+ "SELECT id,user_id,IFNULL(nickname,'匿名'),IFNULL(content,''),topic_id,status,like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE id=?", id,
+ ).Scan(&p.ID, &p.UserID, &p.Nickname, &p.Content, &p.TopicID, &p.Status, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
if err != nil {
return nil
}
@@ -648,7 +684,7 @@ func Profile(w http.ResponseWriter, r *http.Request) {
where = "user_id=?"
}
rows, err := database.DB.Query(
- "SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
+ "SELECT id,IFNULL(nickname,'匿名'),IFNULL(content,''),like_count,comment_count,IFNULL(is_admin,0),IFNULL(badges,''),IFNULL(created_at,'') FROM posts WHERE "+where+" ORDER BY id DESC LIMIT 100", uid)
if err != nil {
http.Error(w, "查询失败", http.StatusInternalServerError)
return
@@ -661,12 +697,16 @@ func Profile(w http.ResponseWriter, r *http.Request) {
LikeCount int64
CommentCount int64
IsAdmin int
+ BadgesRaw string
+ Badges []string
CreatedAt string
}
var posts []own
for rows.Next() {
var p own
- _ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount, &p.IsAdmin, &p.CreatedAt)
+ _ = rows.Scan(&p.ID, &p.Nickname, &p.Content, &p.LikeCount, &p.CommentCount,
+ &p.IsAdmin, &p.BadgesRaw, &p.CreatedAt)
+ p.Badges = models.DisplayBadges(int64(p.IsAdmin), p.BadgesRaw)
posts = append(posts, p)
}
Render(w, r, "layout.html", "pg_profile", map[string]any{
diff --git a/internal/models/models.go b/internal/models/models.go
index 26bb491..69b9a57 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -72,10 +72,64 @@ type Post struct {
Status int // 1 可见 0 隐藏(AI/管理员处理)
LikeCount int64
CommentCount int64
- IsAdmin int // 1 = 管理员专享昵称发布(已通过后台账号验证)
+ IsAdmin int // 1 = 管理员专享昵称发布(已通过后台账号验证)
+ BadgesRaw string // 插件写入的标识列表(JSON 数组字符串)
CreatedAt string
}
+// ---------- 帖子标识(内置管理员标识 + 插件 badge 标识) ----------
+
+// BadgesJSON 标识列表转 JSON 字符串(空列表返回空串,便于兼容旧数据)
+func BadgesJSON(list []string) string {
+ if len(list) == 0 {
+ return ""
+ }
+ b, err := json.Marshal(list)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+// ParseBadges 解析 JSON 标识列表
+func ParseBadges(s string) []string {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil
+ }
+ var out []string
+ if err := json.Unmarshal([]byte(s), &out); err != nil {
+ return nil
+ }
+ return out
+}
+
+// MergeBadges 合并多个标识列表并去重(保持首次出现的顺序)
+func MergeBadges(lists ...[]string) []string {
+ var out []string
+ seen := map[string]bool{}
+ for _, list := range lists {
+ for _, v := range list {
+ v = strings.TrimSpace(v)
+ if v == "" || seen[v] {
+ continue
+ }
+ seen[v] = true
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+// DisplayBadges 计算帖子最终展示的标识(管理员标识 + 插件标识)
+func DisplayBadges(isAdmin int64, raw string) []string {
+ var builtin []string
+ if isAdmin == 1 {
+ builtin = append(builtin, "管理员")
+ }
+ return MergeBadges(builtin, ParseBadges(raw))
+}
+
// AdminNicknames 后台配置的「管理员专享昵称」列表。
// 支持中英文逗号、分号与换行分隔,用于防止他人冒充官方身份。
func AdminNicknames() []string {
diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go
index ffb9483..92c0280 100644
--- a/internal/plugin/plugin.go
+++ b/internal/plugin/plugin.go
@@ -27,23 +27,43 @@ import (
"clearlove/internal/util"
)
+// ConfigField 插件配置项声明。后台「插件管理」会按此自动渲染设置表单,
+// 配置值保存在 settings 表中(键名前缀 plugin.<插件名>.),可在钩子中通过
+// {config:字段名} 占位符引用。
+type ConfigField struct {
+ Key string `json:"key"` // 字段名(供 {config:key} 引用)
+ Label string `json:"label"` // 显示名称
+ Type string `json:"type"` // text | textarea | switch | select | number
+ Default string `json:"default"` // 默认值
+ Help string `json:"help"` // 说明文字
+ Options []string `json:"options"` // select 型的候选项
+}
+
// HookDef 单个钩子定义
type HookDef struct {
Hook string `json:"hook"` // 钩子名
- Type string `json:"type"` // html | filter | http
+ Type string `json:"type"` // html | filter | http | guard | badge
HTML string `json:"html"` // html 型:注入片段
Match string `json:"match"` // filter 型:正则
Replace string `json:"replace"` // filter 型:替换文本
URL string `json:"url"` // http 型:Webhook 地址
+
+ // guard / badge 型专用
+ Nicknames string `json:"nicknames"` // 触发昵称,逗号分隔,支持 {config:key}
+ Require string `json:"require"` // admin = 必须验证后台账号密码
+ Label string `json:"label"` // 命中后写入帖子的标识文字
+ Message string `json:"message"` // 校验失败时的提示语
}
// Plugin 插件清单
type Plugin struct {
- Name string `json:"name"`
- Version string `json:"version"`
- Author string `json:"author"`
- Description string `json:"description"`
- Hooks []HookDef `json:"hooks"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ Author string `json:"author"`
+ Description string `json:"description"`
+ Requires string `json:"requires"` // 建议的最低表白墙版本(仅提示)
+ Config []ConfigField `json:"config"` // 配置项声明(可选)
+ Hooks []HookDef `json:"hooks"`
}
// Meta 插件目录信息(管理页展示)
@@ -110,6 +130,183 @@ func List() []Meta {
return out
}
+// ---------- 插件配置 ----------
+
+// configKey 插件配置在 settings 表中的键名
+func configKey(pluginName, key string) string {
+ return "plugin." + pluginName + "." + key
+}
+
+// GetConfig 读取插件配置(未设置的字段回落到声明的默认值)
+func GetConfig(p *Plugin) map[string]string {
+ out := make(map[string]string, len(p.Config))
+ for _, f := range p.Config {
+ if f.Key == "" {
+ continue
+ }
+ v := models.GetSetting(configKey(p.Name, f.Key))
+ if v == "" {
+ v = f.Default
+ }
+ out[f.Key] = v
+ }
+ return out
+}
+
+// ConfigOf 按插件名读取配置(插件不存在时返回空表)
+func ConfigOf(pluginName string) map[string]string {
+ if p, ok := Load()[pluginName]; ok {
+ return GetConfig(p)
+ }
+ return map[string]string{}
+}
+
+// SaveConfig 保存插件配置(只接受该插件声明过的字段)
+func SaveConfig(p *Plugin, values map[string]string) error {
+ for _, f := range p.Config {
+ if f.Key == "" {
+ continue
+ }
+ if v, ok := values[f.Key]; ok {
+ if err := models.SetSetting(configKey(p.Name, f.Key), util.StripHTML(v)); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+// reConfigVar 配置占位符 {config:key}
+var reConfigVar = regexp.MustCompile(`\{config:[A-Za-z0-9_\-]+\}`)
+
+// expand 展开 {config:key} 占位符
+func expand(s string, cfg map[string]string) string {
+ if s == "" || !strings.Contains(s, "{config:") {
+ return s
+ }
+ return reConfigVar.ReplaceAllStringFunc(s, func(m string) string {
+ return cfg[m[len("{config:"):len(m)-1]]
+ })
+}
+
+// splitNames 拆分逗号/分号/换行分隔的名单
+func splitNames(s string) []string {
+ fields := strings.FieldsFunc(s, func(r rune) bool {
+ return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\r'
+ })
+ out := make([]string, 0, len(fields))
+ for _, f := range fields {
+ if v := strings.TrimSpace(f); v != "" {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+// ---------- 发帖守卫(guard)与帖子标识(badge) ----------
+
+// GuardResult 发帖守卫判定结果
+type GuardResult struct {
+ Require bool // true = 必须验证后台管理员账号密码
+ Labels []string // 通过后写入帖子的标识文字
+ Message string // 未通过时的提示语
+}
+
+// CheckComposeGuard 检查昵称是否命中任一启用插件的发帖守卫(compose_guard)。
+// 未命中返回 nil。多个插件同时命中时会合并标识与要求。
+func CheckComposeGuard(nickname string) *GuardResult {
+ name := strings.TrimSpace(nickname)
+ if name == "" {
+ return nil
+ }
+ enabled := enabledSet()
+ res := &GuardResult{}
+ hit := false
+ for pname, p := range Load() {
+ if !enabled[pname] {
+ continue
+ }
+ cfg := GetConfig(p)
+ for _, h := range p.Hooks {
+ if h.Type != "guard" || h.Hook != "compose_guard" {
+ continue
+ }
+ matched := false
+ for _, n := range splitNames(expand(h.Nicknames, cfg)) {
+ if strings.EqualFold(n, name) {
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ continue
+ }
+ hit = true
+ if h.Require == "admin" {
+ res.Require = true
+ }
+ if h.Label != "" {
+ res.Labels = append(res.Labels, expand(h.Label, cfg))
+ }
+ if res.Message == "" && h.Message != "" {
+ res.Message = expand(h.Message, cfg)
+ }
+ }
+ }
+ if !hit {
+ return nil
+ }
+ return res
+}
+
+// GuardNicknames 返回所有「需要验证后台账号」的守卫昵称。
+// 发帖页据此在提交前弹出账号验证弹窗,避免用户提交后才看到报错。
+func GuardNicknames() []string {
+ enabled := enabledSet()
+ var out []string
+ for pname, p := range Load() {
+ if !enabled[pname] {
+ continue
+ }
+ cfg := GetConfig(p)
+ for _, h := range p.Hooks {
+ if h.Type != "guard" || h.Hook != "compose_guard" || h.Require != "admin" {
+ continue
+ }
+ out = append(out, splitNames(expand(h.Nicknames, cfg))...)
+ }
+ }
+ return out
+}
+
+// NicknameBadges 返回该昵称在发帖时应携带的标识(post_badge 钩子)
+func NicknameBadges(nickname string) []string {
+ name := strings.TrimSpace(nickname)
+ if name == "" {
+ return nil
+ }
+ enabled := enabledSet()
+ var out []string
+ for pname, p := range Load() {
+ if !enabled[pname] {
+ continue
+ }
+ cfg := GetConfig(p)
+ for _, h := range p.Hooks {
+ if h.Type != "badge" || h.Hook != "post_badge" || h.Label == "" {
+ continue
+ }
+ for _, n := range splitNames(expand(h.Nicknames, cfg)) {
+ if strings.EqualFold(n, name) {
+ out = append(out, expand(h.Label, cfg))
+ break
+ }
+ }
+ }
+ }
+ return out
+}
+
// Install 从上传的 zip 安装插件(zip 内应包含 plugin.json)
func Install(filename string, data []byte) error {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
diff --git a/main.go b/main.go
index 70d03f0..0aac06f 100644
--- a/main.go
+++ b/main.go
@@ -154,6 +154,7 @@ func buildMux() *http.ServeMux {
mux.HandleFunc("POST /admin/plugins/upload", handlers.AdminPluginUpload)
mux.HandleFunc("POST /admin/plugins/toggle", handlers.AdminPluginToggle)
mux.HandleFunc("POST /admin/plugins/delete", handlers.AdminPluginDelete)
+ mux.HandleFunc("POST /admin/plugins/config", handlers.AdminPluginConfig)
// 插件商店(对接云端插件社区)
mux.HandleFunc("GET /admin/store", handlers.AdminStore)
mux.HandleFunc("POST /admin/store/install", handlers.AdminStoreInstall)
diff --git a/plugins/official-guard.zip b/plugins/official-guard.zip
new file mode 100644
index 0000000..5203c1d
Binary files /dev/null and b/plugins/official-guard.zip differ
diff --git a/plugins/official-guard/plugin.json b/plugins/official-guard/plugin.json
new file mode 100644
index 0000000..0b9eaff
--- /dev/null
+++ b/plugins/official-guard/plugin.json
@@ -0,0 +1,44 @@
+{
+ "name": "官方身份守卫",
+ "version": "1.0.0",
+ "author": "ClearLove",
+ "description": "使用指定昵称发帖时必须验证后台管理员账号,帖子显示自定义标识,防止冒充官方身份",
+ "requires": "2.0.0",
+ "config": [
+ {
+ "key": "nicknames",
+ "label": "专享昵称",
+ "type": "textarea",
+ "default": "官方,公告",
+ "help": "使用这些昵称发帖时需验证后台账号,多个用逗号或换行分隔"
+ },
+ {
+ "key": "badge",
+ "label": "帖子标识",
+ "type": "text",
+ "default": "官方",
+ "help": "验证通过的帖子在昵称后显示的徽章文字"
+ },
+ {
+ "key": "message",
+ "label": "失败提示",
+ "type": "text",
+ "default": "该昵称为官方专享,请验证管理员账号后发布"
+ }
+ ],
+ "hooks": [
+ {
+ "hook": "compose_guard",
+ "type": "guard",
+ "nicknames": "{config:nicknames}",
+ "require": "admin",
+ "label": "{config:badge}",
+ "message": "{config:message}"
+ },
+ {
+ "hook": "footer_html",
+ "type": "html",
+ "html": "<style>.post-badge{box-shadow:0 1px 6px rgba(255,123,169,.45)}</style>"
+ }
+ ]
+}
diff --git a/web/static/app.css b/web/static/app.css
index 1ff36f2..57544d1 100644
--- a/web/static/app.css
+++ b/web/static/app.css
@@ -256,15 +256,19 @@ textarea { resize: vertical; }
.captcha-modal-box .form-err { margin: 10px 0 0; }
.captcha-modal-box .btn-row { margin-top: 14px; }
-/* ---------------- 管理员专享昵称 ---------------- */
+/* ---------------- 昵称标识(管理员 / 插件 badge) ---------------- */
.nick-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
-.admin-badge {
+.post-badge {
display: inline-flex; align-items: center; flex: none;
padding: 1px 8px; border-radius: 999px;
font-size: 11px; font-weight: 600; letter-spacing: .5px;
background: linear-gradient(135deg, var(--clv-accent), var(--clv-accent-2));
color: #fff;
}
+
+/* ---------------- 插件设置表单(后台) ---------------- */
+.plugin-config h3 { margin-bottom: 6px; }
+.plugin-config .plugin-help { margin: -6px 0 8px; }
.admin-verify-box { width: min(360px, 100%); }
.admin-verify-box h3 { margin-bottom: 6px; }
.admin-verify-box > .muted { font-size: 13px; margin: 0 0 6px; }
diff --git a/web/static/app.js b/web/static/app.js
index 7583f71..1e61b9c 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -132,8 +132,11 @@
}
var nickRow = el('div', 'nick-row');
nickRow.appendChild(nick);
- // 管理员专享昵称发布的帖子:显示「管理员」标识
- if (p.is_admin) nickRow.appendChild(el('span', 'admin-badge', '管理员'));
+ // 帖子标识(内置「管理员」+ 插件 badge 钩子写入)
+ var badges = p.badges || [];
+ for (var bi = 0; bi < badges.length; bi++) {
+ nickRow.appendChild(el('span', 'post-badge', badges[bi]));
+ }
meta.appendChild(nickRow);
var t = el('time', '', timeText(p.created_at));
meta.appendChild(t);
diff --git a/web/templates/admin.html b/web/templates/admin.html
index f35b2cf..384f3a9 100644
--- a/web/templates/admin.html
+++ b/web/templates/admin.html
@@ -461,8 +461,40 @@
<input type="file" name="file" accept=".zip" required>
<button class="btn btn-primary" type="submit">上传并安装</button>
</form>
- <p class="muted">插件为 zip 包,需包含 plugin.json(钩子式:html 注入 / filter 内容过滤 / http 事件 Webhook)。开发文档见项目 docs/PLUGIN.md。</p>
+ <p class="muted">插件为 zip 包,需包含 plugin.json,支持 html 注入 / filter 内容过滤 / http 事件 Webhook / guard 发帖守卫 / badge 帖子标识。开发文档见项目 docs/PLUGIN.md。</p>
</div>
+
+{{if .adminTop}}
+<div class="card">{{.adminTop}}</div>
+{{end}}
+
+{{range .plugins}}
+{{if .Config}}
+<form class="card plugin-config" method="post" action="/admin/plugins/config">
+ <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}}
+ <label>{{.Label}}
+ {{if eq .Type "textarea"}}
+ <textarea name="cfg_{{.Key}}" rows="3" placeholder="{{.Default}}">{{index $cfg .Key}}</textarea>
+ {{else if eq .Type "switch"}}
+ <span class="check-row"><input type="checkbox" name="cfg_{{.Key}}" value="1" {{if eq (index $cfg .Key) "1"}}checked{{end}}> 开启</span>
+ {{else if eq .Type "select"}}
+ {{$cur := index $cfg .Key}}
+ <select name="cfg_{{.Key}}">{{range .Options}}<option value="{{.}}" {{if eq . $cur}}selected{{end}}>{{.}}</option>{{end}}</select>
+ {{else}}
+ <input type="text" name="cfg_{{.Key}}" value="{{index $cfg .Key}}" placeholder="{{.Default}}">
+ {{end}}
+ </label>
+ {{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>
+</form>
+{{end}}
+{{end}}
+
<div class="card table-card">
<h3>已安装插件</h3>
<div class="table-scroll">
diff --git a/web/templates/front.html b/web/templates/front.html
index 0f53bad..8f81901 100644
--- a/web/templates/front.html
+++ b/web/templates/front.html
@@ -20,7 +20,7 @@
<div class="meta">
<div class="nick-row">
{{if .UserID}}<a class="nickname" href="/u/{{.UserID}}">{{.Nickname}}</a>{{else}}<span class="nickname">{{.Nickname}}</span>{{end}}
- {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+ {{range .Badges}}<span class="post-badge" title="该帖带有管理员或插件标识">{{.}}</span>{{end}}
</div>
<time>{{datefmt .CreatedAt}}</time>
</div>
@@ -259,7 +259,7 @@
<div class="meta">
<div class="nick-row">
<span class="nickname">{{.Nickname}}</span>
- {{if .IsAdmin}}<span class="admin-badge" title="管理员专享昵称发布">管理员</span>{{end}}
+ {{range .Badges}}<span class="post-badge">{{.}}</span>{{end}}
</div>
<time>{{datefmt .CreatedAt}}</time>
</div>