540 lines
17 KiB
HTML
540 lines
17 KiB
HTML
<include src="partials/layout.html">
|
||
<template name="head">
|
||
<title th:text="|${linksTitle} - ${site.title}|">链接</title>
|
||
<style>
|
||
.links-nav {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.75rem;
|
||
margin: 1rem 0 1.5rem;
|
||
}
|
||
.links-nav a {
|
||
color: var(--color-link);
|
||
}
|
||
.links-nav a:hover,
|
||
.links-nav a.active {
|
||
color: var(--color-link-hover);
|
||
font-weight: 600;
|
||
}
|
||
.links-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr));
|
||
gap: 1rem;
|
||
}
|
||
.links-list li {
|
||
margin: 0;
|
||
display: flex;
|
||
}
|
||
.links-list a {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.8rem;
|
||
padding: 1.1rem 1.25rem;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg, 0.75rem);
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text);
|
||
transition:
|
||
border-color var(--transition-fast),
|
||
box-shadow var(--transition-fast);
|
||
}
|
||
.links-list a:hover {
|
||
border-color: var(--color-primary);
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||
}
|
||
.links-avatar {
|
||
position: relative;
|
||
display: block;
|
||
flex-shrink: 0;
|
||
}
|
||
.links-avatar img {
|
||
display: block;
|
||
width: 2.75rem;
|
||
height: 2.75rem;
|
||
border-radius: var(--radius-md);
|
||
object-fit: cover;
|
||
background: var(--color-bg);
|
||
}
|
||
/* 头像右下角状态圆点 */
|
||
.links-avatar::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: -0.1rem;
|
||
bottom: -0.1rem;
|
||
width: 0.55rem;
|
||
height: 0.55rem;
|
||
border-radius: 50%;
|
||
background: #2ea043;
|
||
border: 2px solid var(--color-bg-secondary);
|
||
}
|
||
.links-list .links-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
padding-top: 0.1rem;
|
||
}
|
||
.links-list .links-name {
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
}
|
||
.links-list .links-desc {
|
||
margin: 0.3rem 0 0;
|
||
font-size: 0.85rem;
|
||
line-height: 1.55;
|
||
color: var(--color-text-secondary);
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.links-group-title {
|
||
font-size: 1.1rem;
|
||
margin: 2rem 0 1rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
.links-section:first-of-type .links-group-title {
|
||
margin-top: 0;
|
||
}
|
||
/* 申请友链按钮 */
|
||
.links-apply {
|
||
margin-top: 2.5rem;
|
||
text-align: center;
|
||
}
|
||
.btn-apply {
|
||
display: inline-block;
|
||
padding: 0.6rem 1.75rem;
|
||
border: 1px solid var(--color-primary);
|
||
border-radius: var(--radius-full);
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
font: inherit;
|
||
font-size: 0.95rem;
|
||
cursor: pointer;
|
||
transition:
|
||
opacity var(--transition-fast),
|
||
transform var(--transition-fast);
|
||
}
|
||
.btn-apply:hover {
|
||
opacity: 0.88;
|
||
}
|
||
.btn-apply:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
/* 申请弹窗(运行时移至 body 下,避免被父级 stacking context 遮挡) */
|
||
.link-apply-modal[hidden] {
|
||
display: none;
|
||
}
|
||
.link-apply-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 1000;
|
||
}
|
||
.link-apply-dialog {
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: min(26rem, calc(100vw - 2rem));
|
||
max-height: min(85vh, 44rem);
|
||
overflow-y: auto;
|
||
background: var(--color-bg);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-lg, 0.75rem);
|
||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||
padding: 1.5rem;
|
||
z-index: 1001;
|
||
}
|
||
.link-apply-close {
|
||
position: absolute;
|
||
top: 0.75rem;
|
||
right: 0.75rem;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border: none;
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
font-size: 1.4rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
.link-apply-close:hover {
|
||
color: var(--color-text);
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.link-apply-title {
|
||
margin: 0 0 1rem;
|
||
font-size: 1.125rem;
|
||
padding-right: 2rem;
|
||
}
|
||
.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="links">
|
||
<h1 th:text="${linksTitle}">链接</h1>
|
||
|
||
<nav class="links-nav" aria-label="链接分组">
|
||
<a th:href="@{/links}" th:classappend="${group == null} ? 'active'">全部</a>
|
||
<a
|
||
th:each="item : ${simpleGroups}"
|
||
th:href="@{/links(group=${item.metadata.name})}"
|
||
th:classappend="${group == item.metadata.name} ? 'active'"
|
||
th:text="${item.spec.displayName}"
|
||
></a>
|
||
</nav>
|
||
|
||
<th:block th:if="${group == null}">
|
||
<section class="links-section" th:each="grp : ${simpleGroups}">
|
||
<h2 class="links-group-title" th:text="${grp.spec.displayName}">
|
||
分组
|
||
</h2>
|
||
<ul class="links-list">
|
||
<li
|
||
th:each="link : ${links}"
|
||
th:if="${link.spec.groupName == grp.metadata.name}"
|
||
>
|
||
<a
|
||
th:href="@{${link.spec.url}}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<span class="links-avatar">
|
||
<img
|
||
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||
th:src="@{${link.spec.logo}}"
|
||
alt=""
|
||
loading="lazy"
|
||
/>
|
||
</span>
|
||
<span class="links-body">
|
||
<span class="links-name" th:text="${link.spec.displayName}"
|
||
>链接名称</span
|
||
>
|
||
<p
|
||
class="links-desc"
|
||
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||
th:text="${link.spec.description}"
|
||
></p>
|
||
</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
<section class="links-section">
|
||
<h2 class="links-group-title">未分组</h2>
|
||
<ul class="links-list">
|
||
<li
|
||
th:each="link : ${links}"
|
||
th:if="${#strings.isEmpty(link.spec.groupName)}"
|
||
>
|
||
<a
|
||
th:href="@{${link.spec.url}}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<span class="links-avatar">
|
||
<img
|
||
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||
th:src="@{${link.spec.logo}}"
|
||
alt=""
|
||
loading="lazy"
|
||
/>
|
||
</span>
|
||
<span class="links-body">
|
||
<span class="links-name" th:text="${link.spec.displayName}"
|
||
>链接名称</span
|
||
>
|
||
<p
|
||
class="links-desc"
|
||
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||
th:text="${link.spec.description}"
|
||
></p>
|
||
</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
</th:block>
|
||
<th:block th:unless="${group == null}">
|
||
<ul th:if="${not #lists.isEmpty(links)}" class="links-list">
|
||
<li th:each="link : ${links}">
|
||
<a
|
||
th:href="@{${link.spec.url}}"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<span class="links-avatar">
|
||
<img
|
||
th:if="${not #strings.isEmpty(link.spec.logo)}"
|
||
th:src="@{${link.spec.logo}}"
|
||
alt=""
|
||
loading="lazy"
|
||
/>
|
||
</span>
|
||
<span class="links-body">
|
||
<span class="links-name" th:text="${link.spec.displayName}"
|
||
>链接名称</span
|
||
>
|
||
<p
|
||
class="links-desc"
|
||
th:if="${not #strings.isEmpty(link.spec.description)}"
|
||
th:text="${link.spec.description}"
|
||
></p>
|
||
</span>
|
||
</a>
|
||
</li>
|
||
</ul>
|
||
<p th:if="${#lists.isEmpty(links)}">暂无链接</p>
|
||
</th:block>
|
||
|
||
<div class="links-apply">
|
||
<button
|
||
class="btn-apply"
|
||
id="link-apply-toggle"
|
||
type="button"
|
||
th:text="#{links.apply}"
|
||
>申请友链</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 申请友链弹窗:脚本运行时移至 body 下 -->
|
||
<div class="link-apply-modal" id="link-apply-modal" hidden>
|
||
<div class="link-apply-backdrop" data-close></div>
|
||
<div
|
||
class="link-apply-dialog"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="link-apply-title"
|
||
>
|
||
<button
|
||
class="link-apply-close"
|
||
type="button"
|
||
data-close
|
||
aria-label="关闭"
|
||
>
|
||
×
|
||
</button>
|
||
<h2 class="link-apply-title" id="link-apply-title" th:text="#{links.apply}">
|
||
申请友链
|
||
</h2>
|
||
<form class="apply-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-apply" type="submit">提交申请</button>
|
||
</form>
|
||
<div class="apply-status" id="apply-status" role="alert"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<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 () {
|
||
// 收起没有链接的分组区块
|
||
document.querySelectorAll(".links-section").forEach(function (sec) {
|
||
if (!sec.querySelector(".links-list li")) {
|
||
sec.remove();
|
||
}
|
||
});
|
||
|
||
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 modal = document.getElementById("link-apply-modal");
|
||
var form = document.getElementById("link-apply-form");
|
||
var img = document.getElementById("captcha-img");
|
||
var status = document.getElementById("apply-status");
|
||
var challengeId = null;
|
||
|
||
// 弹窗移至 body 下,避免被页面父级 stacking context 遮挡
|
||
document.body.appendChild(modal);
|
||
|
||
function openModal() {
|
||
modal.hidden = false;
|
||
document.body.style.overflow = "hidden";
|
||
if (!challengeId) loadCaptcha();
|
||
}
|
||
function closeModal() {
|
||
modal.hidden = true;
|
||
document.body.style.overflow = "";
|
||
}
|
||
|
||
toggle.addEventListener("click", openModal);
|
||
|
||
modal.addEventListener("click", function (e) {
|
||
if (e.target.closest("[data-close]")) closeModal();
|
||
});
|
||
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape" && !modal.hidden) closeModal();
|
||
});
|
||
|
||
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 = "申请已提交,等待管理员审核。";
|
||
setTimeout(closeModal, 1500);
|
||
} else {
|
||
return r.json().then(function (err) {
|
||
throw err;
|
||
});
|
||
}
|
||
})
|
||
.catch(function () {
|
||
loadCaptcha();
|
||
status.className = "apply-status err";
|
||
status.textContent = "提交失败,请检查信息后重试(验证码已刷新)。";
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
</include>
|