在 Halo 博客中使用自定义页面接入「友链自助提交插件(link-submit)」API
很多 Halo 站长希望访客能自主提交友链申请,并通过后台审核后自动发布到友链列表。但很多主题没有适配这个功能,或者设计上没有入口按钮。
本文将教你如何使用 Halo 的自定义页面功能,结合开源插件 友链自助提交(link-submit),来实现一个 独立、可复用的前端友链提交表单,不依赖主题源码修改。
📌 此方案适用于所有支持自定义页面的 Halo 博客版本,结合插件 link-submit 的 REST API 实现。
🧠 背景简介:什么是 “友链自助提交插件”
插件仓库地址:
👉 https://github.com/chengzhongxue/link-submit
这个插件提供:
- 插件端友链自助提交的 UI(可自动弹出)
- 后端 API 接口,可通过代码自定义前端提交
- 后台管理审核流程
- 分组、修改、重复校验等功能
我们并不是用插件自动提供的弹窗,而是在自定义页面中自己写一个提交表单,通过 API 直接提交数据。这样更灵活、更易定制。
📦 所需条件
- 安装并启用 Halo 博客
- 已安装并启用插件:友链自助提交(link-submit)
- 安装方式可以通过 Halo 应用市场或手动上传 Plugin。
- Halo 支持 自定义页面(Custom Page)
- 你需要知道插件的 REST API 路径(稍后说明)
🛠 API 端点概览
友链自助提交插件在后台暴露了几个关键 API 端点(版本可能更新,请以插件版本为准):
| 功能 | API 路径 |
|---|---|
| 获取友链分组 | /apis/anonymous.link.submit.kunkunyu.com/v1alpha1/linkgroups |
| 提交友链申请 | /apis/anonymous.link.submit.kunkunyu.com/v1alpha1/linksubmits/-/submit |
以上为插件提供的匿名访问 API,可直接在前端发起请求,无需登录态。
我们将在自定义页面中调用这两个 API 注册分组列表并提交数据。
✨ 页面实现思路
整体流程如下:
🎯 页面加载 → 请求分组 API → 填充表单下拉分组
🧾 用户填写其他字段 → 点击提交按钮
📤 表单序列化成 JSON → 调用提交 API
🪄 根据返回结果显示成功 / 失败提示
🧩 自定义页面代码(可直接复制)
建议直接在 Halo 后台新建一个自定义 HTML 页面,再将以下代码复制进去:
<div id="link-submit-wrapper"><style>
/* 样式隔离 */
#link-submit-wrapper {
max-width: 800px;
margin: 20px auto;
padding: 25px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
color: #333;
font-family: sans-serif;
}
#link-submit-wrapper .form-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
#link-submit-wrapper .form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
#link-submit-wrapper .full-row {
grid-column: span 2;
}
#link-submit-wrapper .form-group {
display: flex;
flex-direction: column;
}
#link-submit-wrapper label {
font-size: 14px;
margin-bottom: 6px;
font-weight: 500;
color: #555;
}
/* 修复下拉框选中无变化的问题 */
#link-submit-wrapper select,
#link-submit-wrapper input,
#link-submit-wrapper textarea {
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
background-color: #fff !important;
color: #333 !important;
outline: none;
}
#link-submit-wrapper select {
appearance: auto !important;
-webkit-appearance: menulist !important;
cursor: pointer;
}
#link-submit-wrapper textarea {
height: 80px;
resize: vertical;
}
#link-submit-wrapper .footer-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 10px;
}
#link-submit-wrapper .hint {
font-size: 12px;
color: #999;
margin-top: 4px;
}
#link-submit-wrapper .btn-submit {
background: #1a1c22;
color: #fff;
padding: 10px 30px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
#link-submit-wrapper .btn-submit:disabled {
background: #666;
cursor: not-allowed;
}
@media (max-width: 600px) {
#link-submit-wrapper .form-grid {
grid-template-columns: 1fr;
}
#link-submit-wrapper .full-row {
grid-column: span 1;
}
}
</style>
<div class="form-title">友链提交</div>
<form id="linkSubmitForm">
<div class="form-grid">
<div class="form-group">
<label>类型</label>
<select name="type" required="">
<option value="add">添加</option>
<option value="update">修改</option>
</select>
</div>
<div class="form-group">
<label>网站分组</label>
<select name="groupName" id="groupNameSelect" required="">
<option value="">加载中...</option>
</select>
</div>
<div class="form-group">
<label>网站名称</label>
<input type="text" name="displayName" required="">
</div>
<div class="form-group">
<label>网址</label>
<input type="url" name="url" required="">
</div>
<div class="form-group">
<label>Logo</label>
<input type="text" name="logo">
</div>
<div class="form-group">
<label>RSS</label>
<input type="text" name="rssUrl">
</div>
<div class="form-group full-row">
<label>描述</label>
<textarea name="description"></textarea>
</div>
<div class="form-group full-row">
<div class="footer-row">
<div style="flex: 1; margin-right: 20px;">
<label>联系邮箱</label>
<input type="email" name="email" required="" style="width: 100%;">
<div class="hint">用于接收审核结果通知</div>
</div>
<button type="submit" class="btn-submit" id="btnSubmit">
提交申请
</button>
</div>
</div>
</div></form>
<script>
(function () {
const form = document.getElementById('linkSubmitForm');
const groupSelect = document.getElementById('groupNameSelect');
const typeSelect = form.querySelector('select[name="type"]');
const btnSubmit = document.getElementById('btnSubmit');
// 加载分组
async function loadGroups() {
try {
const response = await fetch(
'/apis/anonymous.link.submit.kunkunyu.com/v1alpha1/linkgroups'
);
const items = await response.json();
groupSelect.innerHTML = '';
if (Array.isArray(items) && items.length > 0) {
items.forEach((item) => {
const opt = document.createElement('option');
opt.value = item.groupName;
opt.textContent = item.displayName;
groupSelect.appendChild(opt);
});
} else {
groupSelect.innerHTML = '<option value="">暂无分组</option>';
}
} catch (e) {
groupSelect.innerHTML = '<option value="">接口异常</option>';
}
}
// 提交表单
form.addEventListener('submit', async (e) => {
e.preventDefault();
btnSubmit.disabled = true;
btnSubmit.innerText = '提交中...';
const formData = new FormData(form);
const payload = Object.fromEntries(formData.entries());
try {
const res = await fetch(
'/apis/anonymous.link.submit.kunkunyu.com/v1alpha1/linksubmits/-/submit',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}
);
if (res.ok) {
alert('提交成功!请耐心等待后台审核。');
form.reset();
} else {
const err = await res.json();
alert('提交失败:' + (err.detail || '未知异常'));
}
} catch (err) {
alert('网络连接错误');
} finally {
btnSubmit.disabled = false;
btnSubmit.innerText = '提交申请';
}
});
loadGroups();
})();
</script></div>