Files
halo-theme-WarmIsland/src/pages/links.astro
T
nxxy335 9f677a9f0d feat: Hero 背景图完整显示、导航二级菜单、瞬间朋友圈化、友链申请表单 (v1.1.9)
- Hero:背景图 opacity 0.15 -> 1 完整显示(此前近乎透明的背景图是"蒙灰"观感的
  主因);标题/副标题/一言按明暗模式补轻投影保证复杂图片上的可读性
- 导航栏二级菜单:桌面端纯 CSS 悬停/聚焦展开(零 JS),带悬停桥防误关、
  下拉箭头旋转反馈;移动端菜单以缩进子项形式呈现
- 瞬间页朋友圈化:头像(moment.owner.avatar,缺省回退站点名首字占位)+ 昵称
  + 正文 + 九宫格图片 + 时间操作;九宫格遵循微信规则(单图原比例、2/4 图两列、
  其余三列)
- 友情链接页适配 plugin-links 友链申请:同源表单(POST /links/apply/submit +
  csrfToken + GET /links/apply/captcha 验证码图,点击刷新),支持名称/链接/
  Logo/描述/邮箱/反链/RSS 字段、提交结果与错误提示;插件未开放申请时验证码
  加载失败自动隐藏表单;新增 links_apply_form_enabled 开关(默认开启)
2026-09-26 16:14:20 +08:00

609 lines
18 KiB
Plaintext

---
import Layout from "../layouts/Layout.astro";
---
<Layout wide>
<Fragment slot="head">
<title th:text="|${theme.config?.links?.links_page_title ?: '友链'} - ${site.title}|"></title>
</Fragment>
<section
class="wi-links"
th:if="${pluginFinder.available('PluginLinks')}"
th:attr="data-style=${theme.config?.links?.links_style ?: 'cards'},data-random=${theme.config?.links?.links_random_order == true ? 'true' : 'false'}"
>
<div class="wi-container">
<div class="wi-links__header">
<h1
class="wi-links__title"
th:text="${theme.config?.links?.links_page_title ?: '友链'}"
>
友情链接
</h1>
<p class="wi-links__subtitle" th:text="'山水一程,三生有幸'">山水一程,三生有幸</p>
<span class="wi-page-accent"></span>
<button
type="button"
class="wi-links__portal"
id="wi-links-portal"
th:if="${theme.config?.links?.links_random_portal != false}"
title="随机传送"
aria-label="随机传送"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<polyline points="3.27 6.96 12 12.01 20.73 6.96" transform="translate(0 8)"></polyline>
</svg>
<span class="wi-links__portal-text">随机传送</span>
</button>
</div>
<th:block th:each="group : ${groups}">
<h2
class="wi-links__group"
th:if="${group.spec.displayName != null and !#strings.isEmpty(group.spec.displayName)}"
th:text="${group.spec.displayName}"
></h2>
<div class="wi-links__grid">
<a
th:each="link : ${group.links}"
th:href="${link.spec.url}"
th:title="${link.spec.displayName}"
target="_blank"
rel="noopener noreferrer"
class="wi-links__card"
>
<div class="wi-links__card-glow"></div>
<div class="wi-links__card-inner">
<img
th:if="${link.spec.logo}"
th:src="${link.spec.logo}"
th:alt="${link.spec.displayName}"
class="wi-links__avatar"
/>
<div
th:unless="${link.spec.logo}"
class="wi-links__avatar wi-links__avatar--placeholder"
>
<span
th:text="${#strings.substring(link.spec.displayName, 0, 1)}"
></span>
</div>
<div class="wi-links__info">
<span
class="wi-links__name"
th:text="${link.spec.displayName}"
></span>
<span
class="wi-links__desc"
th:if="${link.spec.description}"
th:text="${link.spec.description}"
></span>
</div>
<svg class="wi-links__arrow" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>
</div>
</a>
</div>
</th:block>
<div
class="wi-links__apply"
th:if="${theme.config?.links?.links_apply_form_enabled != false}"
th:with="applied=${param.applied != null and !#lists.isEmpty(param.applied) and param.applied[0] == 'true'},
applyMsg=${param.message != null and !#lists.isEmpty(param.message) ? param.message[0] : null}"
>
<h2 class="wi-links__apply-title" th:text="'申请友链'">申请友链</h2>
<p
class="wi-links__apply-result"
th:if="${applied}"
th:text="'申请已提交,等待站长审核 🌙'"
role="status"
></p>
<p
class="wi-links__apply-result wi-links__apply-result--error"
th:if="${not applied and applyMsg != null}"
th:text="${applyMsg}"
role="alert"
></p>
<form
class="wi-links__apply-form"
method="post"
th:action="@{/links/apply/submit}"
>
<input type="hidden" name="_csrf" th:value="${csrfToken}" />
<div class="wi-links__apply-row">
<label class="wi-links__apply-field">
<span th:text="'站点名称 *'">站点名称 *</span>
<input type="text" name="displayName" required maxlength="50" placeholder="你的站点名称" th:attr="value=${param.value != null and !#lists.isEmpty(param.value) ? param.value[0] : ''}" />
</label>
<label class="wi-links__apply-field">
<span th:text="'站点链接 *'">站点链接 *</span>
<input type="url" name="url" required placeholder="https://" />
</label>
</div>
<div class="wi-links__apply-row">
<label class="wi-links__apply-field">
<span th:text="'图标地址'">图标地址</span>
<input type="url" name="logo" placeholder="https://" />
</label>
<label class="wi-links__apply-field">
<span th:text="'联系邮箱'">联系邮箱</span>
<input type="email" name="email" placeholder="用于通知审核结果" />
</label>
</div>
<label class="wi-links__apply-field">
<span th:text="'站点描述'">站点描述</span>
<input type="text" name="description" maxlength="120" placeholder="一句话介绍你的站点" />
</label>
<label class="wi-links__apply-field">
<span th:text="'反链页面'">反链页面</span>
<input type="url" name="backlink" placeholder="https://" />
</label>
<label class="wi-links__apply-field">
<span th:text="'RSS / Atom 地址(每行一个)'">RSS / Atom 地址(每行一个)</span>
<textarea name="feedUrls" rows="2" placeholder="https://"></textarea>
</label>
<div class="wi-links__apply-row">
<label class="wi-links__apply-field">
<span th:text="'验证码 *'">验证码 *</span>
<input type="text" name="captchaCode" required maxlength="8" autocomplete="off" placeholder="图中五位字符" />
</label>
<div class="wi-links__apply-captcha">
<img
class="wi-links__apply-captcha-img"
th:src="@{/links/apply/captcha}"
alt="验证码"
width="120"
height="44"
loading="lazy"
title="点击刷新验证码"
onclick="this.src=this.src.split('?')[0]+'?ts='+Date.now()"
th:onerror="|this.closest('.wi-links__apply').classList.add('wi-links__apply--unavailable')|"
/>
</div>
</div>
<button class="wi-links__apply-submit" type="submit" th:text="'提交申请'">提交申请</button>
</form>
<p
class="wi-links__apply-unavailable-tip"
th:text="'友链申请暂未开放。'"
hidden
></p>
</div>
<div class="wi-links__comment" th:if="${theme.config?.links?.links_show_comment == true}">
<halo:comment group="plugin.halo.run" kind="Plugin" name="PluginLinks"></halo:comment>
</div>
</div>
</section>
<script is:inline th:inline="none">
(function () {
var section = document.querySelector(".wi-links");
if (!section) return;
var cards = Array.prototype.slice.call(section.querySelectorAll(".wi-links__card"));
if (!cards.length) return;
// 随机排列:Fisher-Yates 打乱每个分组内的卡片顺序
if (section.getAttribute("data-random") === "true") {
Array.prototype.forEach.call(section.querySelectorAll(".wi-links__grid"), function (grid) {
for (var i = grid.children.length; i >= 0; i--) {
var j = Math.floor(Math.random() * i);
grid.appendChild(grid.children[j]);
}
});
}
// 随机传送:从当前页所有友链中随机打开一个
var portal = document.getElementById("wi-links-portal");
if (portal) {
portal.addEventListener("click", function () {
var links = Array.prototype.slice
.call(section.querySelectorAll(".wi-links__card"))
.map(function (a) {
return a.getAttribute("href");
})
.filter(function (h) {
return h && h !== "#";
});
if (!links.length) return;
var target = links[Math.floor(Math.random() * links.length)];
window.open(target, "_blank", "noopener");
});
}
})();
</script>
</Layout>
<style>
.wi-links {
padding-block: 1rem;
}
.wi-links__header {
text-align: center;
margin-bottom: var(--space-3xl);
}
.wi-links__portal {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.6rem 1.1rem;
min-height: 44px;
margin-top: var(--space-md);
border-radius: 9999px;
font-size: var(--text-sm);
color: #fff;
background: linear-gradient(
135deg,
var(--accent),
color-mix(in srgb, var(--accent) 70%, #000)
);
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
transition: transform var(--duration-fast) var(--ease-out-quart),
box-shadow var(--duration-fast) var(--ease-out-quart),
opacity var(--duration-fast) var(--ease-out-quart);
}
.wi-links__portal:hover {
transform: translateY(-1px);
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent);
opacity: 0.95;
}
.wi-links__portal:active {
transform: translateY(0) scale(0.97);
}
.wi-links__portal svg {
transition: transform var(--duration-normal) var(--ease-out-expo);
}
.wi-links__portal:hover svg {
transform: rotate(180deg) scale(1.1);
}
.wi-links__comment {
margin-top: var(--space-3xl);
}
/* ===== 友链申请表单 ===== */
.wi-links__apply {
margin-top: var(--space-3xl);
padding: var(--space-xl);
border: 1px solid var(--rule);
border-radius: var(--radius-lg, 16px);
background: var(--bg-raised);
}
.wi-links__apply-title {
text-align: center;
font-size: var(--text-xl);
font-weight: 700;
color: var(--ink);
margin-bottom: var(--space-lg);
}
.wi-links__apply-result {
text-align: center;
font-size: var(--text-sm);
color: var(--accent);
margin-bottom: var(--space-md);
}
.wi-links__apply-result--error {
color: #e6162d;
}
html.dark .wi-links__apply-result--error {
color: #ff6b6b;
}
.wi-links__apply-form {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.wi-links__apply-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.wi-links__apply-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: var(--text-sm);
color: var(--ink-2);
}
.wi-links__apply-field input,
.wi-links__apply-field textarea {
padding: 0.6rem 0.8rem;
border: 1px solid var(--rule);
border-radius: 10px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: var(--text-sm);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
resize: vertical;
}
.wi-links__apply-field input:focus,
.wi-links__apply-field textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent);
}
.wi-links__apply-captcha {
display: flex;
align-items: flex-end;
padding-bottom: 2px;
}
.wi-links__apply-captcha-img {
border-radius: 8px;
border: 1px solid var(--rule);
cursor: pointer;
}
.wi-links__apply-submit {
align-self: center;
padding: 0.65rem 2.2rem;
min-height: 44px;
border: none;
border-radius: 9999px;
font-size: var(--text-sm);
font-weight: 600;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 70%, #000));
cursor: pointer;
transition: transform var(--duration-fast) var(--ease-out-quart), opacity 0.15s ease;
}
.wi-links__apply-submit:hover {
transform: translateY(-1px);
opacity: 0.95;
}
.wi-links__apply-submit:active {
transform: translateY(0) scale(0.98);
}
/* 插件未开放申请时(验证码 404)自动隐藏表单并提示 */
.wi-links__apply--unavailable .wi-links__apply-form {
display: none;
}
.wi-links__apply--unavailable .wi-links__apply-unavailable-tip {
display: block !important;
text-align: center;
color: var(--ink-3);
font-size: var(--text-sm);
}
@media (max-width: 640px) {
.wi-links__apply-row {
grid-template-columns: 1fr;
}
}
.wi-links__title {
font-size: var(--text-4xl);
margin-bottom: var(--space-xs);
}
.wi-links__subtitle {
font-size: var(--text-sm);
color: var(--ink-3);
letter-spacing: var(--tracking-wide);
margin-block-end: 0;
}
.wi-page-accent {
display: block;
width: 48px;
height: 3px;
margin: var(--space-md) auto 0;
border-radius: 9999px;
background: var(--accent);
}
.wi-links__group {
font-size: var(--text-lg);
color: var(--ink-2);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--rule);
font-weight: 600;
}
.wi-links__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.wi-links[data-style="list"] .wi-links__grid {
grid-template-columns: 1fr;
max-width: 720px;
}
.wi-links[data-style="list"] .wi-links__card-inner {
border-radius: 12px;
padding: var(--space-md) var(--space-lg);
}
.wi-links[data-style="grid"] .wi-links__grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-sm);
}
.wi-links[data-style="grid"] .wi-links__card-inner {
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-xl) var(--space-lg);
}
.wi-links[data-style="grid"] .wi-links__arrow {
display: none;
}
.wi-links__card {
position: relative;
border-radius: 16px;
text-decoration: none;
color: inherit;
overflow: hidden;
display: flex;
}
.wi-links__card-glow {
position: absolute;
inset: 0;
border-radius: 16px;
opacity: 0;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--accent) 8%, transparent) 0%,
transparent 60%
);
transition: opacity var(--duration-normal) var(--ease-out-expo);
pointer-events: none;
}
.wi-links__card:hover .wi-links__card-glow {
opacity: 1;
}
.wi-links__card-inner {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: 16px;
flex: 1;
transition:
transform var(--duration-normal) var(--ease-out-expo),
box-shadow var(--duration-normal) var(--ease-out-expo),
border-color var(--duration-normal) var(--ease-out-expo);
}
.wi-links__card:hover .wi-links__card-inner {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: color-mix(in srgb, var(--accent) 40%, var(--glass-border));
}
.wi-links__avatar {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
box-shadow: var(--shadow-sm);
transition: transform var(--duration-normal) var(--ease-out-expo);
}
.wi-links__card:hover .wi-links__avatar {
transform: scale(1.08);
}
.wi-links__avatar--placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-bg);
color: var(--accent);
font-family: var(--font-sans);
font-size: var(--text-lg);
font-weight: 700;
box-shadow: none;
border-radius: 12px;
}
.wi-links__info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.wi-links__name {
font-size: var(--text-md);
font-weight: 600;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--duration-fast) var(--ease-out-quart);
}
.wi-links__card:hover .wi-links__name {
color: var(--accent);
}
.wi-links__desc {
font-size: var(--text-sm);
color: var(--ink-3);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.wi-links__arrow {
flex-shrink: 0;
color: var(--ink-3);
opacity: 0;
transform: translate(-4px, 4px);
transition:
opacity var(--duration-normal) var(--ease-out-expo),
transform var(--duration-normal) var(--ease-out-expo),
color var(--duration-fast) var(--ease-out-quart);
}
.wi-links__card:hover .wi-links__arrow {
opacity: 1;
transform: translate(0, 0);
color: var(--accent);
}
@media (max-width: 640px) {
.wi-links__grid {
grid-template-columns: 1fr;
gap: var(--space-sm);
}
.wi-links__card-inner {
padding: var(--space-md);
}
.wi-links__avatar {
width: 42px;
height: 42px;
}
}
</style>