1.适配朋友圈插件

2.更新版本号为1.0.6-ce
3.README增加朋友圈插件地址
This commit is contained in:
UPToZ 2025-06-24 16:24:13 +08:00
parent e7b35470f0
commit 2a04f6945b
5 changed files with 1820 additions and 1 deletions

View File

@ -1679,6 +1679,11 @@ spec:
height: 300px
label: 底部显示内容
language: html
- $formkit: text
name: fmomentsPageSize
label: 友链每页数量
help: "填写每页(滚动加载)展示的友链数量,"
value: 12
- group: fcircle
label: 友链鱼塘

View File

@ -0,0 +1,708 @@
#fMomentsMessageBoard {
background: var(--heo-card-bg);
border-radius: 12px;
box-shadow: var(--heo-shadow-border);
border: var(--style-border);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 16px 0;
padding: 16px;
animation: slideInUp 0.6s ease-out;
}
.fMomentsUpdatedTime {
text-align: center;
padding: 8px 0;
margin-bottom: 16px;
border-bottom: 1px solid var(--heo-border-color);
font-size: 14px;
color: var(--heo-secondtext);
font-weight: 500;
}
.fMomentsStatsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.fMomentsStatCard {
background: linear-gradient(135deg, var(--heo-secondbg), var(--heo-mask-bg));
border-radius: 12px;
padding: 16px;
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--heo-border-color);
position: relative;
overflow: hidden;
}
.fMomentsStatCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--heo-main), var(--heo-blue));
transform: scaleX(0);
transition: transform 0.3s ease;
}
.fMomentsStatCard:hover {
transform: translateY(-2px);
box-shadow: var(--heo-shadow-lightblack);
}
.fMomentsStatCard:hover::before {
transform: scaleX(1);
}
.fMomentsStatIcon {
font-size: 24px;
margin-bottom: 8px;
}
.fMomentsStatNumber {
font-size: 24px;
font-weight: 700;
color: var(--heo-fontcolor);
margin-bottom: 4px;
font-family: 'SAOUI', monospace;
}
.fMomentsStatLabel {
font-size: 12px;
color: var(--heo-secondtext);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1.2;
}
/* 控制面板 */
.fMomentsControlPanel {
background: var(--heo-card-bg);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
box-shadow: var(--heo-shadow-border);
border: var(--style-border);
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.fMomentsSearchBox {
flex: 1;
min-width: 250px;
position: relative;
}
.fMomentsSearchInput {
width: 100%;
padding: 14px 16px 14px 44px;
border: 2px solid var(--heo-border-color);
border-radius: 28px;
background: var(--heo-secondbg);
color: var(--heo-fontcolor);
font-size: 14px;
transition: all 0.3s ease;
font-family: inherit;
}
.fMomentsSearchInput:focus {
outline: none;
border-color: var(--heo-main);
box-shadow: 0 0 0 3px rgba(var(--heo-main-rgb), 0.1);
}
.fMomentsSearchIcon {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--heo-secondtext);
font-size: 16px;
}
.fMomentsFilterGroup {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.fMomentsSelect {
padding: 12px 16px;
border: 2px solid var(--heo-border-color);
border-radius: 24px;
background: var(--heo-secondbg);
color: var(--heo-fontcolor);
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
min-width: 130px;
font-family: inherit;
}
.fMomentsSelect:focus {
outline: none;
border-color: var(--heo-main);
box-shadow: 0 0 0 3px rgba(var(--heo-main-rgb), 0.1);
}
.fMomentsToggle {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
color: var(--heo-fontcolor);
font-weight: 500;
}
.fMomentsSwitch {
position: relative;
width: 52px;
height: 28px;
background: var(--heo-border-color);
border-radius: 28px;
cursor: pointer;
transition: all 0.3s ease;
}
.fMomentsSwitch.active {
background: var(--heo-main);
}
.fMomentsSwitch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
background: white;
border-radius: 50%;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.fMomentsSwitch.active::after {
transform: translateX(24px);
}
/* 显示数量状态 */
.fMomentsDisplayStatus {
background: var(--heo-card-bg);
border-radius: 12px;
padding: 16px 24px;
margin: 20px 0;
box-shadow: var(--heo-shadow-border);
border: var(--style-border);
text-align: center;
font-size: 14px;
color: var(--heo-secondtext);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.fMomentsDisplayInfo {
display: flex;
align-items: center;
gap: 8px;
}
.fMomentsDisplayCount {
font-weight: 600;
color: var(--heo-main);
}
/* 加载状态 */
.fMomentsLoading {
text-align: center;
padding: 60px 20px;
background: var(--heo-card-bg);
border-radius: 16px;
margin: 20px 0;
box-shadow: var(--heo-shadow-border);
border: var(--style-border);
}
.fMomentsLoadingSpinner {
width: 40px;
height: 40px;
border: 4px solid var(--heo-border-color);
border-top: 4px solid var(--heo-main);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
.fMomentsLoadingText {
color: var(--heo-secondtext);
font-size: 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 瀑布流布局 */
#fmomentsContainer {
font-family: 'SAOUI', -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Lato, Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
margin: 20px 0;
min-height: 200px;
}
.fMomentsArticleItem {
background: var(--heo-card-bg);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--heo-shadow-border);
border: var(--style-border);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: fadeInUp 0.6s ease-out forwards;
height: 240px; /* 固定总高度: 72px(header) + 120px(content) + 48px(footer) */
display: flex;
flex-direction: column;
}
.fMomentsArticleItem:hover {
transform: translateY(-6px);
box-shadow: var(--heo-shadow-lightblack);
}
.fMomentsArticleHeader {
padding: 16px;
border-bottom: 1px solid var(--heo-border-color);
display: flex;
align-items: center;
gap: 12px;
height: 72px; /* 固定高度 */
overflow: hidden; /* 隐藏超出部分 */
}
.fMomentsAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
transition: transform 0.3s ease;
}
.fMomentsAvatar:hover {
transform: scale(1.1);
}
.fMomentsAuthorInfo {
flex: 1;
}
.fMomentsAuthorName {
font-size: 14px;
font-weight: 600;
color: var(--heo-fontcolor);
margin-bottom: 2px;
}
.fMomentsPublishTime {
font-size: 12px;
color: var(--heo-secondtext);
}
.fMomentsArticleContent {
padding: 16px;
height: 120px; /* 固定高度 */
overflow: hidden; /* 隐藏超出部分 */
display: flex;
flex-direction: column;
}
.fMomentsArticleTitle {
font-size: 16px;
font-weight: 600;
color: var(--heo-fontcolor);
line-height: 1.4;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-decoration: none;
transition: color 0.3s ease;
flex-shrink: 0; /* 防止标题被压缩 */
}
.fMomentsArticleTitle:hover {
color: var(--heo-main);
}
.fMomentsArticleDescription {
font-size: 14px;
color: var(--heo-secondtext);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1; /* 占用剩余空间 */
}
.fMomentsArticleFooter {
padding: 12px 16px;
border-top: 1px solid var(--heo-border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--heo-secondtext);
height: 48px; /* 固定高度 */
overflow: hidden; /* 隐藏超出部分 */
flex-shrink: 0; /* 防止被压缩 */
}
/* 加载更多按钮 */
#fmomentsMoreBtn {
width: 100%;
max-width: 400px;
height: 54px;
margin: 30px auto;
background: linear-gradient(135deg, var(--heo-main), var(--heo-blue));
color: white;
border: none;
border-radius: 27px;
font-weight: 600;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-shadow: 0 4px 15px rgba(var(--heo-main-rgb), 0.3);
font-family: inherit;
}
#fmomentsMoreBtn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px rgba(var(--heo-main-rgb), 0.4);
}
#fmomentsMoreBtn:disabled {
background: var(--heo-border-color);
color: var(--heo-secondtext);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.fMomentsNoMore {
text-align: center;
padding: 40px 20px;
color: var(--heo-secondtext);
font-size: 16px;
background: var(--heo-card-bg);
border-radius: 16px;
border: 2px dashed var(--heo-border-color);
margin: 20px 0;
}
.fMomentsNoMore i {
font-size: 48px;
color: var(--heo-main);
margin-bottom: 16px;
display: block;
}
/* 错误状态 */
.fMomentsErrorState {
text-align: center;
padding: 60px 20px;
color: var(--heo-secondtext);
background: var(--heo-card-bg);
border-radius: 16px;
border: 2px dashed #ff6b6b;
margin: 20px 0;
}
.fMomentsErrorState i {
font-size: 64px;
color: #ff6b6b;
margin-bottom: 20px;
display: block;
}
.fMomentsErrorState h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--heo-fontcolor);
}
.fMomentsRetryBtn {
margin-top: 20px;
padding: 12px 24px;
background: var(--heo-main);
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
}
.fMomentsRetryBtn:hover {
background: var(--heo-blue);
transform: translateY(-2px);
}
/* 空状态 */
.fMomentsEmptyState {
text-align: center;
padding: 60px 20px;
color: var(--heo-secondtext);
background: var(--heo-card-bg);
border-radius: 16px;
border: 2px dashed var(--heo-border-color);
margin: 20px 0;
}
.fMomentsEmptyState i {
font-size: 64px;
color: var(--heo-border-color);
margin-bottom: 20px;
display: block;
}
.fMomentsEmptyState h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--heo-fontcolor);
}
.fMomentsEmptyState p {
font-size: 14px;
line-height: 1.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
#fmomentsContainer {
grid-template-columns: 1fr;
gap: 16px;
}
.fMomentsControlPanel {
flex-direction: column;
align-items: stretch;
padding: 20px;
}
.fMomentsFilterGroup {
justify-content: center;
}
/* 移动端 fMomentsMessageBoard 优化 */
#fMomentsMessageBoard {
padding: 12px;
margin: 12px 0;
border-radius: 10px;
}
.fMomentsUpdatedTime {
padding: 6px 0;
margin-bottom: 12px;
font-size: 13px;
}
.fMomentsStatsGrid {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.fMomentsStatCard {
padding: 12px 8px;
border-radius: 10px;
}
.fMomentsStatIcon {
font-size: 20px;
margin-bottom: 6px;
}
.fMomentsStatNumber {
font-size: 20px;
margin-bottom: 3px;
}
.fMomentsStatLabel {
font-size: 11px;
letter-spacing: 0.3px;
}
.fMomentsDisplayStatus {
flex-direction: column;
text-align: center;
}
.fMomentsArticleHeader {
height: 68px;
padding: 12px;
}
.fMomentsArticleContent {
height: 110px;
padding: 12px;
}
.fMomentsArticleFooter {
height: 44px;
padding: 10px 12px;
}
.fMomentsArticleItem {
height: 222px; /* 调整后的总高度 */
}
}
@media (max-width: 480px) {
/* 超小屏幕的 fMomentsMessageBoard 优化 */
#fMomentsMessageBoard {
padding: 10px;
margin: 10px 0;
}
.fMomentsUpdatedTime {
padding: 4px 0;
margin-bottom: 10px;
font-size: 12px;
}
.fMomentsStatsGrid {
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.fMomentsStatCard {
padding: 10px 6px;
border-radius: 8px;
}
.fMomentsStatIcon {
font-size: 18px;
margin-bottom: 4px;
}
.fMomentsStatNumber {
font-size: 18px;
margin-bottom: 2px;
}
.fMomentsStatLabel {
font-size: 10px;
letter-spacing: 0.2px;
}
.fMomentsArticleFooter {
height: 40px;
padding: 8px 10px;
}
.fMomentsArticleContent {
height: 100px;
padding: 10px;
}
.fMomentsArticleHeader {
height: 64px;
padding: 10px;
}
.fMomentsArticleItem {
height: 204px; /* 调整后的总高度 */
}
.fMomentsFilterGroup {
flex-direction: column;
align-items: stretch;
}
.fMomentsSelect {
width: 100%;
}
.fMomentsSearchBox {
min-width: 200px;
}
}
/* 超小屏幕专用小于360px */
@media (max-width: 360px) {
#fMomentsMessageBoard {
padding: 8px;
}
.fMomentsStatsGrid {
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.fMomentsStatCard {
padding: 8px 4px;
}
.fMomentsStatIcon {
font-size: 16px;
}
.fMomentsStatNumber {
font-size: 16px;
}
.fMomentsStatLabel {
font-size: 9px;
}
}
/* 动画效果 */
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fMomentsArticleItem:nth-child(2n) {
animation-delay: 0.1s;
}
.fMomentsArticleItem:nth-child(3n) {
animation-delay: 0.2s;
}
.fMomentsArticleItem:nth-child(4n) {
animation-delay: 0.3s;
}

View File

@ -0,0 +1,776 @@
if (typeof window.FriendMomentsApp === 'undefined') {
class FriendMomentsApp {
constructor() {
if (window._friendMomentsInstance) {
// console.log('FriendMomentsApp instance already exists, destroying old one');
window._friendMomentsInstance.destroy();
// 等待销毁完成
if (window._friendMomentsInstance) {
return window._friendMomentsInstance;
}
}
window._friendMomentsInstance = this;
this.articles = [];
this.filteredArticles = [];
this.displayedArticles = [];
this.currentPage = 1;
this.pageSize = window.fmomentsConfig?.pageSize || 12;
this.sortField = 'pubDate';
this.sortOrder = 'desc';
this.authorFilter = '';
this.searchKeyword = '';
this.loading = false;
this.errorCount = 0;
this.maxRetries = 3;
// 新增分页相关属性
this.totalPages = 0;
this.totalCount = 0;
this.hasNext = false;
this.hasPrevious = false;
this.isFirst = true;
this.isLast = false;
// 存储事件监听器引用,用于清理
this.eventListeners = [];
this.initialized = false;
this.destroyed = false; // 新增:标记是否已销毁
this.initPromise = null; // 新增:防止重复初始化
// 生成唯一实例ID
this.instanceId = Date.now() + '_' + Math.random().toString(36).substr(2, 9);
// console.log(`Creating FriendMomentsApp instance: ${this.instanceId}`);
// 不在构造函数中调用init改为外部调用
}
async init() {
// 防止重复初始化
if (this.initPromise) {
// console.log('Init already in progress, waiting...');
return this.initPromise;
}
if (this.initialized) {
// console.log('Already initialized');
return;
}
if (this.destroyed) {
// console.log('Instance has been destroyed, cannot initialize');
return;
}
// console.log(`Initializing FriendMomentsApp instance: ${this.instanceId}`);
this.initPromise = this._doInit();
return this.initPromise;
}
async _doInit() {
try {
this.cleanup(); // 清理之前的状态
this.showLoading();
// 检查是否在初始化过程中被销毁
if (this.destroyed) {
// console.log('Instance destroyed during initialization');
return;
}
// 重置分页状态
this.currentPage = 1;
this.articles = [];
await this.loadArticles();
// 再次检查是否被销毁
if (this.destroyed) {
// console.log('Instance destroyed after loading articles');
return;
}
this.setupEventListeners();
this.setupAuthorFilter();
this.calculateStats();
this.displayArticles(true);
this.updateDisplayStatus();
this.hideLoading();
this.initialized = true;
// console.log(`FriendMomentsApp instance ${this.instanceId} initialized successfully`);
} catch (error) {
if (!this.destroyed) {
this.handleError(error);
}
} finally {
this.initPromise = null;
}
}
// 清理方法
cleanup() {
if (this.destroyed) return;
// console.log(`Cleaning up FriendMomentsApp instance: ${this.instanceId}`);
// 清理事件监听器
this.removeEventListeners();
// 清理DOM内容
const container = document.getElementById('fmomentsContainer');
if (container) {
container.innerHTML = '';
}
// 清理作者筛选选项
const authorFilter = document.getElementById('authorFilter');
if (authorFilter) {
const defaultOption = authorFilter.querySelector('option[value=""]');
authorFilter.innerHTML = '';
if (defaultOption) {
authorFilter.appendChild(defaultOption);
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = '全部作者';
authorFilter.appendChild(option);
}
}
// 重置搜索框
const searchInput = document.getElementById('searchInput');
if (searchInput) {
searchInput.value = '';
}
// 重置状态
this.articles = [];
this.filteredArticles = [];
this.displayedArticles = [];
this.currentPage = 1;
this.authorFilter = '';
this.searchKeyword = '';
this.loading = false;
this.errorCount = 0;
// 重置分页状态
this.totalPages = 0;
this.totalCount = 0;
this.hasNext = false;
this.hasPrevious = false;
this.isFirst = true;
this.isLast = false;
}
// 销毁实例
destroy() {
if (this.destroyed) {
// console.log(`Instance ${this.instanceId} already destroyed`);
return;
}
// console.log(`Destroying FriendMomentsApp instance: ${this.instanceId}`);
this.destroyed = true;
this.initialized = false;
// 取消进行中的初始化
if (this.initPromise) {
this.initPromise = null;
}
this.cleanup();
// 清理全局引用
if (window._friendMomentsInstance === this) {
window._friendMomentsInstance = null;
}
// console.log(`FriendMomentsApp instance ${this.instanceId} destroyed`);
}
// 移除事件监听器
removeEventListeners() {
if (this.eventListeners.length > 0) {
// console.log(`Removing ${this.eventListeners.length} event listeners for instance ${this.instanceId}`);
this.eventListeners.forEach(({element, event, handler}) => {
if (element && element.removeEventListener) {
element.removeEventListener(event, handler);
}
});
this.eventListeners = [];
}
}
// 添加事件监听器的辅助方法
addEventListener(element, event, handler) {
if (this.destroyed) return;
if (element) {
element.addEventListener(event, handler);
this.eventListeners.push({element, event, handler});
// console.log(`Added ${event} listener for instance ${this.instanceId}`);
}
}
showLoading() {
if (this.destroyed) return;
const loading = document.getElementById('loadingIndicator');
const container = document.getElementById('fmomentsContainer');
const errorState = document.getElementById('errorState');
if (loading) loading.style.display = 'block';
if (container) container.style.display = 'none';
if (errorState) errorState.style.display = 'none';
}
hideLoading() {
if (this.destroyed) return;
const loading = document.getElementById('loadingIndicator');
const container = document.getElementById('fmomentsContainer');
if (loading) loading.style.display = 'none';
if (container) container.style.display = 'grid';
}
async loadArticles(page = 1) {
if (this.destroyed) {
// console.log('Instance destroyed, skipping loadArticles');
return;
}
// console.log(`Loading articles for instance ${this.instanceId}, page: ${page}`);
try {
const baseUrl = window.fmomentsConfig?.apiUrl || '/apis/api.friend.moony.la/v1alpha1/friendposts';
const url = new URL(baseUrl, window.location.origin);
url.searchParams.set('page', page.toString());
url.searchParams.set('size', this.pageSize.toString());
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
}
});
// 检查请求完成后实例是否还有效
if (this.destroyed) {
// console.log('Instance destroyed after fetch, ignoring response');
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// 再次检查实例是否还有效
if (this.destroyed) {
// console.log('Instance destroyed after parsing response, ignoring data');
return;
}
if (data.items && Array.isArray(data.items)) {
const newArticles = data.items.map(item => ({
id: item.metadata.name,
author: item.spec.author,
authorUrl: item.spec.authorUrl,
title: item.spec.title,
description: item.spec.description,
postLink: item.spec.postLink,
logo: item.spec.logo,
pubDate: new Date(item.spec.pubDate),
creationTime: new Date(item.metadata.creationTimestamp),
content: `${item.spec.title} ${item.spec.author} ${item.spec.description}`.toLowerCase()
}));
// 更新分页信息
this.totalPages = data.totalPages || 0;
this.totalCount = data.total || 0;
this.hasNext = data.hasNext || false;
this.hasPrevious = data.hasPrevious || false;
this.isFirst = data.first || false;
this.isLast = data.last || false;
// 如果是第一页,重置文章数组;否则追加
if (page === 1) {
this.articles = newArticles;
} else {
this.articles = this.articles.concat(newArticles);
}
// console.log(`Successfully loaded ${newArticles.length} articles for page ${page}, total articles: ${this.articles.length} for instance ${this.instanceId}`);
this.errorCount = 0;
} else {
throw new Error('API 返回数据格式错误');
}
} catch (error) {
if (this.destroyed) {
// console.log('Instance destroyed, ignoring loadArticles error');
return;
}
// console.error(`Loading articles failed for instance ${this.instanceId}:`, error);
this.errorCount++;
if (this.errorCount < this.maxRetries && !this.destroyed) {
// console.log(`Retry ${this.errorCount} for instance ${this.instanceId}`);
await new Promise(resolve => setTimeout(resolve, 1000 * this.errorCount));
return this.loadArticles(page);
}
throw error;
}
}
calculateStats() {
if (this.destroyed) return;
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
const totalArticles = this.totalCount || this.articles.length; // 优先使用API返回的总数
const totalAuthors = new Set(this.articles.map(a => a.author)).size;
const todayCount = this.articles.filter(a => a.pubDate >= today).length;
const weekCount = this.articles.filter(a => a.pubDate >= weekAgo).length;
this.updateStatNumber('totalArticles', totalArticles);
this.updateStatNumber('totalAuthors', totalAuthors);
this.updateStatNumber('todayCount', todayCount);
this.updateStatNumber('weekCount', weekCount);
const lastUpdateTime = document.getElementById('lastUpdateTime');
if (lastUpdateTime) {
lastUpdateTime.textContent = new Date().toLocaleString('zh-CN');
}
}
updateStatNumber(elementId, value) {
if (this.destroyed) return;
const element = document.getElementById(elementId);
if (element) {
const startValue = 0;
const duration = 1000;
const startTime = performance.now();
const animate = (currentTime) => {
if (this.destroyed) return; // 动画过程中检查实例状态
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentValue = Math.floor(startValue + (value - startValue) * progress);
element.textContent = currentValue;
if (progress < 1 && !this.destroyed) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
}
setupEventListeners() {
if (this.destroyed) return;
// console.log(`Setting up event listeners for instance ${this.instanceId}`);
// 搜索功能
const searchInput = document.getElementById('searchInput');
if (searchInput) {
let searchTimeout;
const searchHandler = (e) => {
if (this.destroyed) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.destroyed) return;
this.searchKeyword = e.target.value.toLowerCase();
this.resetAndDisplay();
}, 300);
};
this.addEventListener(searchInput, 'input', searchHandler);
}
// 作者筛选
const authorFilter = document.getElementById('authorFilter');
if (authorFilter) {
const authorHandler = (e) => {
if (this.destroyed) return;
this.authorFilter = e.target.value;
this.resetAndDisplay();
};
this.addEventListener(authorFilter, 'change', authorHandler);
}
// 排序方式
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const sortHandler = (e) => {
if (this.destroyed) return;
this.sortField = e.target.value;
this.resetAndDisplay();
};
this.addEventListener(sortSelect, 'change', sortHandler);
}
// 排序顺序
const sortOrderSwitch = document.getElementById('sortOrderSwitch');
if (sortOrderSwitch) {
const sortOrderHandler = () => {
if (this.destroyed) return;
sortOrderSwitch.classList.toggle('active');
this.sortOrder = sortOrderSwitch.classList.contains('active') ? 'desc' : 'asc';
this.resetAndDisplay();
};
this.addEventListener(sortOrderSwitch, 'click', sortOrderHandler);
}
// 加载更多
const moreBtn = document.getElementById('fmomentsMoreBtn');
if (moreBtn) {
const moreBtnHandler = () => {
if (this.destroyed) return;
this.loadMore();
};
this.addEventListener(moreBtn, 'click', moreBtnHandler);
}
// 重试按钮
const retryBtn = document.getElementById('retryBtn');
if (retryBtn) {
const retryHandler = () => {
if (this.destroyed) return;
this.init();
};
this.addEventListener(retryBtn, 'click', retryHandler);
}
// 滚动加载
const scrollHandler = this.throttle(() => {
if (this.destroyed) return;
if (this.isNearBottom() && !this.loading && this.hasMoreContent()) {
this.loadMore();
}
}, 200);
this.addEventListener(window, 'scroll', scrollHandler);
}
setupAuthorFilter() {
if (this.destroyed) return;
const authorFilter = document.getElementById('authorFilter');
if (!authorFilter) return;
const existingOptions = Array.from(authorFilter.options).map(option => option.value);
const authors = [...new Set(this.articles.map(article => article.author))].sort();
authors.forEach(author => {
if (this.destroyed) return;
if (author && !existingOptions.includes(author)) {
const option = document.createElement('option');
option.value = author;
option.textContent = author;
authorFilter.appendChild(option);
}
});
}
filterAndSortArticles() {
if (this.destroyed) return [];
let filtered = this.articles.filter(article => {
if (this.authorFilter && article.author !== this.authorFilter) {
return false;
}
if (this.searchKeyword && !article.content.includes(this.searchKeyword)) {
return false;
}
return true;
});
filtered.sort((a, b) => {
let valueA, valueB;
switch (this.sortField) {
case 'pubDate':
valueA = a.pubDate;
valueB = b.pubDate;
break;
case 'creationTime':
valueA = a.creationTime;
valueB = b.creationTime;
break;
case 'author':
valueA = a.author.toLowerCase();
valueB = b.author.toLowerCase();
break;
case 'title':
valueA = a.title.toLowerCase();
valueB = b.title.toLowerCase();
break;
default:
return 0;
}
if (valueA < valueB) {
return this.sortOrder === 'asc' ? -1 : 1;
}
if (valueA > valueB) {
return this.sortOrder === 'asc' ? 1 : -1;
}
return 0;
});
this.filteredArticles = filtered;
return filtered;
}
displayArticles(isInitial = false) {
if (this.destroyed) return;
const container = document.getElementById('fmomentsContainer');
if (!container) return;
const filtered = this.filterAndSortArticles();
if (filtered.length === 0) {
this.showEmptyState();
return;
} else {
this.hideEmptyState();
}
if (isInitial) {
container.innerHTML = '';
this.displayedArticles = [];
}
// 显示所有已筛选的文章
const articlesToShow = filtered.slice(this.displayedArticles.length);
articlesToShow.forEach((article, index) => {
if (this.destroyed) return;
const articleEl = this.createArticleElement(article);
articleEl.style.animationDelay = `${index * 0.1}s`;
container.appendChild(articleEl);
});
this.displayedArticles = filtered;
this.updateDisplayStatus();
this.updateLoadingStatus();
}
createArticleElement(article) {
const articleEl = document.createElement('article');
articleEl.className = 'fMomentsArticleItem';
const safeLogoUrl = article.logo || window.fmomentsConfig?.errorImg || '/default-avatar.png';
const safeAuthorUrl = article.authorUrl || '#';
const safePostLink = article.postLink || '#';
const pubDateStr = article.pubDate.toLocaleDateString('zh-CN');
const pubTimeStr = article.pubDate.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
});
articleEl.innerHTML = `
<div class="fMomentsArticleHeader">
<img class="fMomentsAvatar" src="${safeLogoUrl}" alt="${article.author}"
onerror="this.src='${window.fmomentsConfig?.errorImg || '/default-avatar.png'}'">
<a href="${safeAuthorUrl}" target="_blank" rel="noopener nofollow"
style="color: var(--heo-main); text-decoration: none;"><div class="fMomentsAuthorInfo">
<div class="fMomentsAuthorName">${article.author}</div>
<div class="fMomentsPublishTime">${pubTimeStr}</div>
</div></a>
</div>
<div class="fMomentsArticleContent">
<a class="fMomentsArticleTitle" href="${safePostLink}" target="_blank" rel="noopener nofollow"
title="${article.title}">${article.title}</a>
<a class="fMomentsArticleTitle" href="${safePostLink}" target="_blank" rel="noopener nofollow"
title="${article.title}"><div class="fMomentsArticleDescription">${article.description}</div></a>
</div>
<div class="fMomentsArticleFooter">
<span>📅 ${pubDateStr}</span>
<span>🌐 <a href="${safeAuthorUrl}" target="_blank" rel="noopener nofollow">${article.author}</a></span>
</div>
`;
return articleEl;
}
updateDisplayStatus() {
if (this.destroyed) return;
const displayedCount = document.getElementById('displayedCount');
const totalCount = document.getElementById('totalCount');
const filterStatus = document.getElementById('filterStatus');
const filterText = document.getElementById('filterText');
if (displayedCount) displayedCount.textContent = this.displayedArticles.length;
if (totalCount) totalCount.textContent = this.totalCount || this.articles.length;
const hasFilter = this.authorFilter || this.searchKeyword;
if (filterStatus) {
filterStatus.style.display = hasFilter ? 'flex' : 'none';
}
if (filterText && hasFilter) {
let filterParts = [];
if (this.authorFilter) filterParts.push(`作者: ${this.authorFilter}`);
if (this.searchKeyword) filterParts.push(`搜索: ${this.searchKeyword}`);
filterText.textContent = filterParts.join(' | ');
}
}
resetAndDisplay() {
if (this.destroyed) return;
const container = document.getElementById('fmomentsContainer');
if (container) {
container.innerHTML = '';
}
this.displayedArticles = [];
this.displayArticles(true);
}
hasMoreContent() {
if (this.destroyed) return false;
// 基于API返回的分页信息判断是否还有更多内容
return this.hasNext && !this.isLast;
}
async loadMore() {
if (this.destroyed || this.loading || !this.hasMoreContent()) {
return;
}
this.loading = true;
try {
// 加载下一页
const nextPage = Math.floor(this.articles.length / this.pageSize) + 1;
await this.loadArticles(nextPage);
// 重新设置作者筛选选项(可能有新作者)
this.setupAuthorFilter();
// 重新计算统计信息
this.calculateStats();
// 显示新加载的文章
this.displayArticles();
} catch (error) {
if (!this.destroyed) {
console.error('Load more failed:', error);
// 可以选择显示错误提示,但不影响现有内容
}
} finally {
this.loading = false;
}
}
updateLoadingStatus() {
if (this.destroyed) return;
const moreBtn = document.getElementById('fmomentsMoreBtn');
const noMoreTip = document.getElementById('noMoreTip');
const loadingStatus = document.getElementById('loadingStatus');
const finalCount = document.getElementById('finalCount');
if (!loadingStatus) return;
if (!this.hasMoreContent()) {
if (moreBtn) moreBtn.style.display = 'none';
if (noMoreTip) noMoreTip.style.display = 'block';
if (finalCount) finalCount.textContent = this.displayedArticles.length;
loadingStatus.style.display = 'block';
} else {
if (moreBtn) moreBtn.style.display = 'flex';
if (noMoreTip) noMoreTip.style.display = 'none';
loadingStatus.style.display = 'block';
}
}
showEmptyState() {
if (this.destroyed) return;
const emptyState = document.getElementById('emptyState');
const loadingStatus = document.getElementById('loadingStatus');
if (emptyState) emptyState.style.display = 'block';
if (loadingStatus) loadingStatus.style.display = 'none';
}
hideEmptyState() {
if (this.destroyed) return;
const emptyState = document.getElementById('emptyState');
if (emptyState) emptyState.style.display = 'none';
}
handleError(error) {
if (this.destroyed) return;
console.error(`Friend Moments加载失败 for instance ${this.instanceId}:`, error);
const loading = document.getElementById('loadingIndicator');
const errorState = document.getElementById('errorState');
const errorMessage = document.getElementById('errorMessage');
const container = document.getElementById('fmomentsContainer');
if (loading) loading.style.display = 'none';
if (container) container.style.display = 'none';
if (errorState) errorState.style.display = 'block';
if (errorMessage) {
let message = '加载失败,请稍后重试';
if (error.message.includes('fetch')) {
message = '网络连接失败,请检查网络后重试';
} else if (error.message.includes('HTTP')) {
message = `服务器错误: ${error.message}`;
}
errorMessage.textContent = message;
}
}
throttle(func, limit) {
let inThrottle;
return function () {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
isNearBottom() {
return window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000;
}
}
// 导出类
window.FriendMomentsApp = FriendMomentsApp;
}

330
templates/friends.html Normal file
View File

@ -0,0 +1,330 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
th:replace="~{modules/layouts/layout :: layout(content = ~{::content}, htmlType = 'page',title = '朋友圈 | ' + ${site.title}, head = ~{::head})}">
<th:block th:fragment="head">
<th:block th:replace="~{modules/common/open-graph :: open-graph(_title = '朋友圈',
_permalink = '/moments',
_cover = '',
_excerpt = '友链朋友圈 - 发现更多精彩内容',
_type = 'website')}"></th:block>
<!-- 分离CSS文件 -->
<link rel="stylesheet" th:href="@{/assets/css/fmoments.css}" data-pjax>
</th:block>
<th:block th:fragment="content">
<div class="page" id="body-wrap">
<!-- 头部导航栏 -->
<header class="not-top-img" id="page-header">
<nav th:replace="~{modules/nav :: nav(title = '朋友圈')}"></nav>
</header>
<main class="layout hide-aside" id="content-inner">
<div id="page">
<!-- 朋友圈统计信息面板 -->
<div id="fMomentsMessageBoard">
<div class="fMomentsUpdatedTime">
<i class="fas fa-sync-alt"></i>
最近更新:<span id="lastUpdateTime">加载中...</span>
</div>
<div class="fMomentsStatsGrid">
<div class="fMomentsStatCard">
<div class="fMomentsStatIcon">📚</div>
<div class="fMomentsStatNumber" id="totalArticles">0</div>
<div class="fMomentsStatLabel">总文章数</div>
</div>
<div class="fMomentsStatCard">
<div class="fMomentsStatIcon">👥</div>
<div class="fMomentsStatNumber" id="totalAuthors">0</div>
<div class="fMomentsStatLabel">活跃作者</div>
</div>
<div class="fMomentsStatCard">
<div class="fMomentsStatIcon">🔥</div>
<div class="fMomentsStatNumber" id="todayCount">0</div>
<div class="fMomentsStatLabel">今日更新</div>
</div>
<div class="fMomentsStatCard">
<div class="fMomentsStatIcon"></div>
<div class="fMomentsStatNumber" id="weekCount">0</div>
<div class="fMomentsStatLabel">本周更新</div>
</div>
</div>
</div>
<!-- 控制面板 -->
<div class="fMomentsControlPanel">
<div class="fMomentsSearchBox">
<i class="fas fa-search fMomentsSearchIcon"></i>
<input type="text" class="fMomentsSearchInput" placeholder="搜索文章标题或作者..." id="searchInput">
</div>
<div class="fMomentsFilterGroup">
<select class="fMomentsSelect" id="authorFilter">
<option value="">全部作者</option>
</select>
<select class="fMomentsSelect" id="sortSelect">
<option value="pubDate">按发布时间</option>
<option value="creationTime">按创建时间</option>
<option value="author">按作者名称</option>
<option value="title">按标题</option>
</select>
<div class="fMomentsToggle">
<span>升序</span>
<div class="fMomentsSwitch active" id="sortOrderSwitch"></div>
<span>降序</span>
</div>
</div>
</div>
<!-- 显示数量状态 -->
<div class="fMomentsDisplayStatus">
<div class="fMomentsDisplayInfo">
<i class="fas fa-filter"></i>
<span>显示</span>
<span class="fMomentsDisplayCount" id="displayedCount">0</span>
<span>/</span>
<span class="fMomentsDisplayCount" id="totalCount">0</span>
<span>篇文章</span>
</div>
<div class="fMomentsDisplayInfo" id="filterStatus" style="display: none;">
<i class="fas fa-info-circle"></i>
<span id="filterText">已应用筛选条件</span>
</div>
</div>
<!-- 加载状态 -->
<div id="loadingIndicator" class="fMomentsLoading">
<div class="fMomentsLoadingSpinner"></div>
<div class="fMomentsLoadingText">正在加载动态内容...</div>
</div>
<!-- 朋友圈文章容器 -->
<div id="fmomentsContainer"></div>
<!-- 加载更多按钮 -->
<div id="loadingStatus" style="display: none;">
<button id="fmomentsMoreBtn" type="button">
<i class="fas fa-angle-double-down"></i>
<span>加载更多</span>
</button>
<div class="fMomentsNoMore" id="noMoreTip" style="display: none;">
<i class="fas fa-check-circle"></i>
<div>🎉 已显示全部内容</div>
<small>共找到 <span id="finalCount">0</span> 篇文章</small>
</div>
</div>
<!-- 错误状态 -->
<div class="fMomentsErrorState" id="errorState" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<h3>加载失败</h3>
<div id="errorMessage">请检查网络连接后重试</div>
<button class="fMomentsRetryBtn" id="retryBtn">重新加载</button>
</div>
<!-- 空状态 -->
<div class="fMomentsEmptyState" id="emptyState" style="display: none;">
<i class="fas fa-search"></i>
<h3>没有找到匹配的文章</h3>
<p>尝试更换搜索词或调整筛选条件</p>
</div>
</div>
</main>
<!-- 底部 -->
<footer th:replace="~{modules/footer}" />
<!-- 资源检查和动态加载脚本 -->
<script data-pjax th:inline="javascript">
// 资源动态加载器
window.MomentsResourceLoader = {
// 检查CSS是否已加载
isCSSLoaded: function (href) {
const links = document.getElementsByTagName('link');
for (let i = 0; i < links.length; i++) {
if (links[i].href && links[i].href.includes(href)) {
return true;
}
}
return false;
},
// 检查JS是否已加载
isJSLoaded: function (src) {
const scripts = document.getElementsByTagName('script');
for (let i = 0; i < scripts.length; i++) {
if (scripts[i].src && scripts[i].src.includes(src)) {
return true;
}
}
// 也检查是否已经定义了相关的全局变量/函数
return typeof FriendMomentsApp !== 'undefined';
},
// 动态加载CSS
loadCSS: function (href) {
return new Promise((resolve, reject) => {
if (this.isCSSLoaded(href)) {
// console.log('CSS already loaded:', href);
resolve();
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
link.setAttribute('data-pjax', '');
link.onload = () => {
// console.log('CSS loaded successfully:', href);
resolve();
};
link.onerror = () => {
console.error('Failed to load CSS:', href);
reject(new Error(`Failed to load CSS: ${href}`));
};
document.head.appendChild(link);
});
},
// 动态加载JS
loadJS: function (src) {
return new Promise((resolve, reject) => {
if (this.isJSLoaded(src)) {
// console.log('JS already loaded:', src);
resolve();
return;
}
const script = document.createElement('script');
script.src = src;
script.setAttribute('data-pjax', '');
script.onload = () => {
// console.log('JS loaded successfully:', src);
resolve();
};
script.onerror = () => {
console.error('Failed to load JS:', src);
reject(new Error(`Failed to load JS: ${src}`));
};
document.head.appendChild(script);
});
},
// 加载所有必需的资源
loadRequiredResources: async function () {
const cssPath = /*[[@{/assets/css/fmoments.css}]]*/ '/assets/css/fmoments.css';
const jsPath = /*[[@{/assets/js/fmoments.js}]]*/ '/assets/js/fmoments.js';
try {
// console.log('Checking and loading required resources...');
// 并行加载CSS和JS
await Promise.all([
this.loadCSS(cssPath),
this.loadJS(jsPath)
]);
// console.log('All resources loaded successfully');
return true;
} catch (error) {
console.error('Error loading resources:', error);
return false;
}
}
};
</script>
<!-- 初始化脚本 -->
<script data-pjax th:inline="javascript">
// 朋友圈配置
// 从主题设置获取
window.fmomentsConfig = {
apiUrl: /*[[${theme.config.link.fmomentsApiUrl}]]*/ '/apis/api.friend.moony.la/v1alpha1/friendposts',
pageSize: /*[[${theme.config.link.fmomentsPageSize}]]*/ 12,
errorImg: /*[[${theme.config.other.error_404.background}]]*/ '/assets/images/404.gif'
};
// 全局初始化状态追踪
window._momentsInitializing = false;
// 初始化朋友圈应用
async function initMomentsApp() {
// 防止并发初始化
if (window._momentsInitializing) {
// console.log('Moments initialization already in progress');
return;
}
// console.log('Starting Moments initialization');
window._momentsInitializing = true;
try {
// 首先检查并加载必需的资源
const resourcesLoaded = await window.MomentsResourceLoader.loadRequiredResources();
if (!resourcesLoaded) {
console.error('Failed to load required resources');
return;
}
// 等待一小段时间确保资源完全加载
await new Promise(resolve => setTimeout(resolve, 100));
// 检查FriendMomentsApp是否可用
if (typeof FriendMomentsApp !== 'undefined') {
// 创建新实例(构造函数会处理旧实例的清理)
window.fMomentsApp = new FriendMomentsApp();
// 显式调用初始化
await window.fMomentsApp.init();
// console.log('FriendMomentsApp initialized successfully');
} else {
console.error('FriendMomentsApp is not available after loading resources');
}
} catch (error) {
console.error('Error initializing FriendMomentsApp:', error);
} finally {
window._momentsInitializing = false;
}
}
// PJAX开始时立即清理
document.addEventListener('pjax:start', function () {
// console.log('PJAX start - cleaning up Moments');
if (window._momentsInstance) {
window._momentsInstance.destroy();
}
window._momentsInitializing = false;
});
// DOM加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMomentsApp);
} else {
// DOM已经加载完成
initMomentsApp();
}
// PJAX完成后重新初始化
document.addEventListener('pjax:complete', function () {
if (document.getElementById('fmomentsContainer')) {
// 延迟一点时间确保DOM稳定
setTimeout(initMomentsApp, 150);
}
});
</script>
</div>
</th:block>
</html>

View File

@ -52,7 +52,7 @@ spec:
issues: https://gitee.com/uptoz/halo-theme-hao/issues
settingName: "theme-hao-setting"
configMapName: "theme-hao-configMap"
version: "1.0.5-ce"
version: "1.0.6-ce"
requires: ">=2.21.0"
license:
- name: "CC BY-SA 4.0"