chore: 集成 extra-api 统计、页脚备案配置、友链申请内嵌

- extra-api: 文章卡片/详情页展示字数+阅读时长、归档页展示全站总字数、瞬间页展示单条字数(pluginFinder.available 守卫,未安装自动隐藏)
- 页脚新增 ICP 备案号与公网安备号配置(settings.yaml footer 分组,partials/layout.html 渲染并加安全导航守卫)
- 友链申请表单由独立单页改为内嵌 /links 页底部(点击「申请友链」展开),移除独立 links-apply.html 及其自定义模板注册
- 重新打包 dist/whitespace-0.1.0.zip(移除陈旧 1.0.0)
This commit is contained in:
2026-09-28 17:37:16 +08:00
parent 8e6dccbd09
commit 6b8d9ee338
12 changed files with 344 additions and 209 deletions
+6 -4
View File
@@ -23,14 +23,13 @@
- **plugin-moments**:`/moments` 瞬间页
- **plugin-photos**:`/photos` 图库页
- **plugin-links**:`/links` 友链页;并额外提供
- `links-apply.html`:访客友链申请表单(调用插件公共 REST API,含图形验证码)
- **plugin-links**:`/links` 友链页;友链申请表单已**直接内嵌**在友链页底部(点击「申请友链」展开,调用插件公共 REST API,含图形验证码);并额外提供
- `links-subscribe.html`:RSS 订阅动态聚合页(使用 `linkFeedFinder`)
- **halo-weread-plugin**:`weread.html` 微信读书书架页(前端拉取插件 `/shelf-html` 并注入)
- **留言板**:`page_guestbook.html` 以评论区作为留言板的单页模板
- **plugin-extra-api**:API 扩展包(无独立页面),主题已适配其 shiki 代码高亮与暗色样式
- **plugin-extra-api**:API 扩展包(无独立页面),主题已适配其 shiki 代码高亮与暗色样式;并在文章卡片 / 详情页展示**字数 + 阅读时长**、归档页展示**全站总字数**、瞬间页展示**单条字数**(通过 `extraApiStatsFinder`,插件未安装时自动隐藏)
> `weread` / `留言板` / `友链申请` / `链接订阅` 均为「自定义页面模板」,在控制台新建单页并选择对应模板即可启用。
> `weread` / `留言板` / `链接订阅` 均为「自定义页面模板」,在控制台新建单页并选择对应模板即可启用。`/links` 友链页与内嵌的友链申请无需单独建单页。
## 目录结构
@@ -86,6 +85,8 @@ pnpm check # 格式化 + Lint 修复
| about | avatar / bio / github_url / twitter_url / contact_email | 关于页(自定义模板 `page_about.html`)内容 |
| seo | enable_metadata | 输出 canonical / Open Graph / Twitter Card |
| seo | enable_structured_data | 输出文章页 BlogPosting JSON-LD |
| footer | icp_number / icp_url | ICP 备案号与查询链接(默认工信部备案管理系统) |
| footer | police_number / police_url | 公网安备号与查询链接(默认全国互联网安全管理服务平台) |
## 约定与说明
@@ -93,6 +94,7 @@ pnpm check # 格式化 + Lint 修复
- 社交分享 / 结构化数据中用 `status.permalink` 与 `site.routes.*` 拼出地址,不写死可配置路由。
- 搜索由官方 `PluginSearchWidget` 插件提供,按钮仅在插件可用时出现。
- 浏览量来自 Halo 原生统计(`post.stats.visit`),无需主题自行计数。
- 字数 / 阅读时长来自 `halo-plugin-extra-api` 的 `extraApiStatsFinder`(插件未安装时相关展示自动隐藏),全站字数为缓存计算结果。
## 致谢
+7
View File
@@ -33,3 +33,10 @@ footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=Email
post.words={0} words
post.reading_time=About {0} min
archive.stats.words=Total words
links.apply=Apply for link
links.apply_sent=Application submitted, awaiting review.
links.apply_failed=Submission failed, please check and retry.
links.apply_captcha_failed=Captcha failed to load, please refresh.
+7
View File
@@ -33,3 +33,10 @@ footer.theme=Theme by {0}
about.social.github=GitHub
about.social.twitter=Twitter
about.social.email=邮箱
post.words={0} 字
post.reading_time=约 {0} 分钟
archive.stats.words=总字数
links.apply=申请友链
links.apply_sent=申请已提交,等待管理员审核。
links.apply_failed=提交失败,请检查信息后重试。
links.apply_captcha_failed=验证码加载失败,请刷新页面重试。
+25
View File
@@ -74,3 +74,28 @@ spec:
label: 输出文章结构化数据
value: true
help: 在文章页输出 Schema.org BlogPosting JSON-LD;若已由插件提供,请关闭
- group: footer
label: 页脚备案
formSchema:
- $formkit: text
name: icp_number
label: ICP 备案号
value: ""
placeholder: 京ICP备12345678号
help: 工信部备案号,留空则不显示
- $formkit: text
name: icp_url
label: ICP 备案查询链接
value: "https://beian.miit.gov.cn"
help: 点击备案号跳转的查询地址,默认工信部备案管理系统
- $formkit: text
name: police_number
label: 公网安备号
value: ""
placeholder: 京公网安备11000002000001号
help: 公安机关备案号,留空则不显示
- $formkit: text
name: police_url
label: 公网安备查询链接
value: "https://beian.mps.gov.cn"
help: 点击公网安备号跳转的地址,默认全国互联网安全管理服务平台
+11
View File
@@ -38,6 +38,17 @@
Tags
</div>
</div>
<th:block th:if="${pluginFinder.available('extra-api')}">
<div class="archive-stat">
<div
class="archive-stat-number"
th:text="${extraApiStatsFinder.getPostWordCount()}"
>0</div>
<div class="archive-stat-label" th:text="#{archive.stats.words}">
Words
</div>
</div>
</th:block>
</div>
<!-- 按年归档 -->
+32
View File
@@ -1113,6 +1113,38 @@ a.pagination-item:hover {
color: var(--color-primary);
}
.footer-beian {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem 1rem;
margin-top: var(--spacing-sm);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.footer-beian-item {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-text-muted);
}
.footer-beian-item:hover {
color: var(--color-text);
}
.footer-police-icon {
width: 0.95rem;
height: 0.95rem;
}
.post-words,
.post-reading {
color: var(--color-text-muted);
}
.moment-words {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
/* ========================================
回到顶部按钮
======================================== */
-201
View File
@@ -1,201 +0,0 @@
<include src="partials/layout.html">
<template name="head">
<title
th:text="|${singlePage.spec.title} - ${site.title}|"
th:if="${singlePage != null}"
>
申请友链
</title>
<style>
.apply-form {
max-width: 32rem;
margin: 0 auto;
}
.apply-form .form-row {
margin-bottom: 1rem;
}
.apply-form label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
color: var(--color-text);
}
.apply-form input,
.apply-form textarea {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.apply-form textarea {
min-height: 5rem;
resize: vertical;
}
.apply-captcha {
display: flex;
gap: 0.75rem;
align-items: center;
}
.apply-captcha img {
height: 2.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
cursor: pointer;
}
.apply-status {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
display: none;
}
.apply-status.ok {
display: block;
background: rgba(46, 160, 67, 0.12);
color: #2ea043;
border: 1px solid rgba(46, 160, 67, 0.4);
}
.apply-status.err {
display: block;
background: rgba(229, 57, 53, 0.12);
color: #e53935;
border: 1px solid rgba(229, 57, 53, 0.4);
}
</style>
</template>
<div class="container">
<section class="apply-form">
<h1 th:text="${singlePage.spec.title}" th:if="${singlePage != null}">申请友链</h1>
<p
class="apply-intro"
th:if="${singlePage.spec.description != null}"
th:utext="${singlePage.spec.description}"
></p>
<form id="link-apply-form" novalidate>
<div class="form-row">
<label for="f-url">网站地址 *</label>
<input id="f-url" name="url" type="url" required placeholder="https://example.com" />
</div>
<div class="form-row">
<label for="f-name">网站名称 *</label>
<input id="f-name" name="displayName" type="text" required placeholder="我的站点" />
</div>
<div class="form-row">
<label for="f-logo">Logo 地址</label>
<input id="f-logo" name="logo" type="url" placeholder="https://example.com/logo.png" />
</div>
<div class="form-row">
<label for="f-desc">网站描述</label>
<input id="f-desc" name="description" type="text" placeholder="一句话介绍你的站点" />
</div>
<div class="form-row">
<label for="f-email">联系邮箱</label>
<input id="f-email" name="email" type="email" placeholder="you@example.com" />
</div>
<div class="form-row">
<label for="f-backlink">反链页面</label>
<input id="f-backlink" name="backlink" type="url" placeholder="含本站链接的页面地址" />
</div>
<div class="form-row">
<label for="f-feeds">RSS / Atom 地址(每行一个)</label>
<textarea id="f-feeds" name="feedUrls" placeholder="https://example.com/feed.xml"></textarea>
</div>
<div class="form-row">
<label for="f-captcha">验证码 *</label>
<div class="apply-captcha">
<img id="captcha-img" alt="验证码" title="点击刷新" />
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
</div>
</div>
<button class="btn btn-primary" type="submit">提交申请</button>
</form>
<div class="apply-status" id="apply-status" role="alert"></div>
</section>
</div>
<script th:inline="none">
(function () {
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
var form = document.getElementById("link-apply-form");
var img = document.getElementById("captcha-img");
var status = document.getElementById("apply-status");
var challengeId = null;
function loadCaptcha() {
fetch(CAPTCHA, { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error("captcha");
return r.json();
})
.then(function (data) {
challengeId = data.challengeId;
img.src = data.image;
})
.catch(function () {
status.className = "apply-status err";
status.textContent = "验证码加载失败,请刷新页面重试。";
});
}
img.addEventListener("click", loadCaptcha);
form.addEventListener("submit", function (e) {
e.preventDefault();
status.className = "apply-status";
status.textContent = "";
if (!challengeId) {
status.className = "apply-status err";
status.textContent = "请等待验证码加载完成。";
return;
}
var fd = new FormData(form);
var feeds = (fd.get("feedUrls") || "")
.split(/\r?\n/)
.map(function (s) { return s.trim(); })
.filter(Boolean);
var payload = {
url: fd.get("url"),
displayName: fd.get("displayName"),
logo: fd.get("logo") || undefined,
description: fd.get("description") || undefined,
email: fd.get("email") || undefined,
backlink: fd.get("backlink") || undefined,
feedUrls: feeds,
challengeId: challengeId,
captchaCode: fd.get("captchaCode")
};
fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (r) {
if (r.status === 201) {
form.reset();
challengeId = null;
loadCaptcha();
status.className = "apply-status ok";
status.textContent = "申请已提交,等待管理员审核。";
} else {
return r.json().then(function (err) {
throw err;
});
}
})
.catch(function () {
loadCaptcha();
status.className = "apply-status err";
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
});
});
loadCaptcha();
})();
</script>
</include>
+204
View File
@@ -56,6 +56,71 @@
font-size: 0.85rem;
color: var(--color-text-secondary);
}
.links-apply {
margin-top: 2rem;
text-align: center;
}
.link-apply-panel {
max-width: 32rem;
margin: 1.25rem auto 0;
text-align: left;
}
.link-apply-title {
margin: 0 0 0.75rem;
font-size: 1.125rem;
}
.apply-form .form-row {
margin-bottom: 1rem;
}
.apply-form label {
display: block;
margin-bottom: 0.35rem;
font-weight: 600;
color: var(--color-text);
}
.apply-form input,
.apply-form textarea {
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font: inherit;
}
.apply-form textarea {
min-height: 5rem;
resize: vertical;
}
.apply-captcha {
display: flex;
gap: 0.75rem;
align-items: center;
}
.apply-captcha img {
height: 2.75rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
cursor: pointer;
}
.apply-status {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
display: none;
}
.apply-status.ok {
display: block;
background: rgba(46, 160, 67, 0.12);
color: #2ea043;
border: 1px solid rgba(46, 160, 67, 0.4);
}
.apply-status.err {
display: block;
background: rgba(229, 57, 53, 0.12);
color: #e53935;
border: 1px solid rgba(229, 57, 53, 0.4);
}
</style>
</template>
@@ -96,10 +161,149 @@
</li>
</ul>
<p th:unless="${not #lists.isEmpty(links)}">暂无链接</p>
<div class="links-apply">
<button
class="btn btn-primary"
id="link-apply-toggle"
type="button"
th:text="#{links.apply}"
>申请友链</button>
<div class="link-apply-panel apply-form" id="link-apply-panel" hidden>
<h2 class="link-apply-title" th:text="#{links.apply}">申请友链</h2>
<form id="link-apply-form" novalidate>
<div class="form-row">
<label for="f-url">网站地址 *</label>
<input id="f-url" name="url" type="url" required placeholder="https://example.com" />
</div>
<div class="form-row">
<label for="f-name">网站名称 *</label>
<input id="f-name" name="displayName" type="text" required placeholder="我的站点" />
</div>
<div class="form-row">
<label for="f-logo">Logo 地址</label>
<input id="f-logo" name="logo" type="url" placeholder="https://example.com/logo.png" />
</div>
<div class="form-row">
<label for="f-desc">网站描述</label>
<input id="f-desc" name="description" type="text" placeholder="一句话介绍你的站点" />
</div>
<div class="form-row">
<label for="f-email">联系邮箱</label>
<input id="f-email" name="email" type="email" placeholder="you@example.com" />
</div>
<div class="form-row">
<label for="f-backlink">反链页面</label>
<input id="f-backlink" name="backlink" type="url" placeholder="含本站链接的页面地址" />
</div>
<div class="form-row">
<label for="f-feeds">RSS / Atom 地址(每行一个)</label>
<textarea id="f-feeds" name="feedUrls" placeholder="https://example.com/feed.xml"></textarea>
</div>
<div class="form-row">
<label for="f-captcha">验证码 *</label>
<div class="apply-captcha">
<img id="captcha-img" alt="验证码" title="点击刷新" />
<input id="f-captcha" name="captchaCode" type="text" maxlength="5" required autocomplete="off" />
</div>
</div>
<button class="btn btn-primary" type="submit">提交申请</button>
</form>
<div class="apply-status" id="apply-status" role="alert"></div>
</div>
</div>
</section>
<div class="comments-section" th:if="${haloCommentEnabled}">
<halo:comment group="plugin.halo.run" kind="Plugin" th:attr="name=${pluginName}" />
</div>
</div>
<script th:inline="none">
(function () {
var API = "/apis/api.link.halo.run/v1alpha1/link-applications";
var CAPTCHA = "/apis/api.link.halo.run/v1alpha1/link-applications/captcha";
var toggle = document.getElementById("link-apply-toggle");
var panel = document.getElementById("link-apply-panel");
var form = document.getElementById("link-apply-form");
var img = document.getElementById("captcha-img");
var status = document.getElementById("apply-status");
var challengeId = null;
function loadCaptcha() {
fetch(CAPTCHA, { method: "POST" })
.then(function (r) {
if (!r.ok) throw new Error("captcha");
return r.json();
})
.then(function (data) {
challengeId = data.challengeId;
img.src = data.image;
})
.catch(function () {
status.className = "apply-status err";
status.textContent = "验证码加载失败,请刷新页面重试。";
});
}
toggle.addEventListener("click", function () {
panel.hidden = !panel.hidden;
if (!panel.hidden && !challengeId) {
loadCaptcha();
}
});
img.addEventListener("click", loadCaptcha);
form.addEventListener("submit", function (e) {
e.preventDefault();
status.className = "apply-status";
status.textContent = "";
if (!challengeId) {
status.className = "apply-status err";
status.textContent = "请先点击「申请友链」并等待验证码加载。";
return;
}
var fd = new FormData(form);
var feeds = (fd.get("feedUrls") || "")
.split(/\r?\n/)
.map(function (s) { return s.trim(); })
.filter(Boolean);
var payload = {
url: fd.get("url"),
displayName: fd.get("displayName"),
logo: fd.get("logo") || undefined,
description: fd.get("description") || undefined,
email: fd.get("email") || undefined,
backlink: fd.get("backlink") || undefined,
feedUrls: feeds,
challengeId: challengeId,
captchaCode: fd.get("captchaCode")
};
fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(function (r) {
if (r.status === 201) {
form.reset();
challengeId = null;
loadCaptcha();
status.className = "apply-status ok";
status.textContent = "申请已提交,等待管理员审核。";
} else {
return r.json().then(function (err) {
throw err;
});
}
})
.catch(function () {
loadCaptcha();
status.className = "apply-status err";
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
});
});
})();
</script>
</include>
+14
View File
@@ -23,4 +23,18 @@
>1 views</span
>
</th:block>
<th:block th:if="${pluginFinder.available('extra-api')}">
<th:block th:with="wc=${extraApiStatsFinder.getPostWordCount({name: post.metadata.name})}">
<th:block th:if="${wc != null and wc > 0}">
<span class="post-meta-sep">·</span>
<span class="post-words" th:text="#{post.words(${wc})}">字数</span>
<span class="post-meta-sep">·</span>
<span
class="post-reading"
th:text="#{post.reading_time(${#numbers.formatInteger((wc + 399).divide(java.math.BigInteger.valueOf(400)), 0)})}"
>阅读时长</span
>
</th:block>
</th:block>
</th:block>
</div>
+9
View File
@@ -91,6 +91,15 @@
>发布时间</time
>
</a>
<th:block th:if="${pluginFinder.available('extra-api') and content != null}">
<span
class="moment-words"
th:with="wc=${extraApiStatsFinder.getContentWordCount(content.html)}"
th:if="${wc != null and wc > 0}"
th:text="#{post.words(${wc})}"
>字数</span
>
</th:block>
</header>
<div
+29
View File
@@ -180,6 +180,35 @@
Theme by
<a href="https://kehua.me" target="_blank" rel="noopener">kehua</a>
</p>
<div
class="footer-beian"
th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number) or not #strings.isEmpty(theme.config?.footer?.police_number)}"
>
<a
class="footer-beian-item"
th:if="${not #strings.isEmpty(theme.config?.footer?.icp_number)}"
th:href="${theme.config?.footer?.icp_url}"
target="_blank"
rel="noopener noreferrer"
th:text="${theme.config?.footer?.icp_number}"
>ICP</a
>
<a
class="footer-beian-item footer-police"
th:if="${not #strings.isEmpty(theme.config?.footer?.police_number)}"
th:href="${theme.config?.footer?.police_url}"
target="_blank"
rel="noopener noreferrer"
>
<img
class="footer-police-icon"
src="https://beian.mps.gov.cn/img/footerPoliceIcon.png"
alt=""
loading="lazy"
/>
<span th:text="${theme.config?.footer?.police_number}">公网安备</span>
</a>
</div>
<halo:footer />
</div>
</footer>
-4
View File
@@ -33,10 +33,6 @@ spec:
description: 以评论区作为留言板的单页模板
screenshot:
file: page_guestbook.html
- name: 友链申请页面
description: 适配 plugin-links 的访客友链申请表单
screenshot:
file: links-apply.html
- name: 链接订阅页面
description: 适配 plugin-links 的 RSS 订阅动态聚合页
screenshot: