Files
halo-theme-WarmIsland/src/pages/links.astro
T
nxxy335 dda86294b0 fix: 按 Halo 应用市场审核意见整改外部资源与隐私(v1.2.5 修订)
- 默认零第三方自动请求:移除 21 个模板的 preconnect/dns-prefetch、PrismJS+autoloader、qrcode-generator 及内置一言/IP 定位 API
- 一言与代码高亮配色改为自托管;欢迎弹窗自定义 IP 接口改为 opt-in;微信分享改为复制链接;LightGallery 按 GPLv3 使用
- README 新增「外部资源与隐私」「验证与回归测试」章节,修正无效链接并补充字体 OFL 来源;同步 docs 与修正虚构的目录树
- friends 等 12 处 #strings.substring 加空值保护;logo 由两份 1.5MB 压缩去重为单份 146KB
- 新增 pnpm audit:external 与打包前置合规审计;制品补齐 34 个 docs 文档与预览图
2026-11-11 14:37:09 +08:00

688 lines
21 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
th:with="verification=${link.status?.verification},
accessState=${verification?.access?.state != null ? verification.access.state.name() : null}"
>
<div class="wi-links__card-glow"></div>
<div class="wi-links__card-inner">
<span class="wi-links__avatar-wrap">
<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.isEmpty(link.spec.displayName) ? #strings.substring(link.spec.displayName, 0, 1) : '·'}"
></span>
</div>
<!-- 可达性检测徽章(plugin-links 后台「友链检测」产生):绿点=可访问,红点=异常,未检测不显示 -->
<span
class="wi-links__check wi-links__check--ok"
th:if="${accessState == 'ACCESSIBLE'}"
th:title="'站点访问正常'"
></span>
<span
class="wi-links__check wi-links__check--bad"
th:if="${accessState == 'INACCESSIBLE'}"
th:title="'站点访问异常'"
></span>
</span>
<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>
<!--
友链申请:表单 POST 到插件的 /links/apply/submit,插件以 303 回跳 /links,
并带上 applied(success / error / disabled)、message、field、value 四个查询参数。
注意(踩过坑):th:attr 的赋值里不能调用 #lists 等表达式对象的方法,
Thymeleaf 会对 th:attr 走受限求值并抛
"Instantiation of new objects and access to static classes or parameters is forbidden"。
取值逻辑一律先在 th:with 里算成变量,再用 th:value 之类的单值属性输出。
-->
<div
class="wi-links__apply"
th:if="${theme.config?.links?.links_apply_form_enabled != false}"
th:with="appliedList=${param?.applied},
applied=${appliedList != null and appliedList.length > 0 ? appliedList[0] : null},
messageList=${param?.message},
applyMsg=${messageList != null and messageList.length > 0 ? messageList[0] : null},
fieldList=${param?.field},
applyField=${fieldList != null and fieldList.length > 0 ? fieldList[0] : null},
valueList=${param?.value},
applyValue=${valueList != null and valueList.length > 0 ? valueList[0] : null}"
>
<h2 class="wi-links__apply-title" th:text="'申请友链'">申请友链</h2>
<p
class="wi-links__apply-result"
th:if="${applied != null}"
th:classappend="${applied == 'success'} ? 'wi-links-check-ok' : 'wi-links-check-error'"
th:text="${applyMsg != null ? applyMsg : '申请已提交,等待站长审核 🌙'}"
role="status"
></p>
<th:block th:unless="${applied == 'disabled'}">
<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:value="${applyField == 'displayName' ? applyValue : null}"
/>
</label>
<label class="wi-links__apply-field">
<span th:text="'站点链接 *'">站点链接 *</span>
<input
type="url"
name="url"
required
placeholder="https://"
th:value="${applyField == 'url' ? applyValue : null}"
/>
</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()"
onerror="var c=this.closest('.wi-links__apply');if(c)c.classList.add('wi-links__apply--unavailable')"
/>
</div>
</div>
<button
class="wi-links__apply-submit"
type="submit"
th:text="'提交申请'"
>
提交申请
</button>
</form>
</th:block>
<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--ok,
.wi-links-check-ok {
color: var(--accent);
}
.wi-links__apply-result--error,
.wi-links-check-error {
color: #e6162d;
}
html.dark .wi-links__apply-result--error,
html.dark .wi-links-check-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;
display: flex;
/* 注意:这里绝不能加 overflow:hidden —— 悬浮时内层卡片会上移 4px 并投射大范围
阴影,裁切会把阴影切出一条"断开的光带"、卡片顶部被削平(v1.2.0 修复) */
}
.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);
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-wrap {
position: relative;
flex: none;
display: inline-flex;
line-height: 1;
}
.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__check {
position: absolute;
right: -3px;
bottom: -3px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--bg);
box-shadow: var(--shadow-sm);
}
.wi-links__check--ok {
background: #22c55e;
}
.wi-links__check--bad {
background: #ef4444;
}
.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>