最近逛友链博客,发现 Leonus 大佬又搞了不少新活,像“博主收藏”、“友链查询”等等,还有各种外链卡片,都是很实用的功能,非常奈斯啊。今天我也来整一个类似的文章收藏功能。

概述

首先要在原本的朋友圈文章卡片上新增一个收藏按钮,点击目标文章卡片上的按钮即可收藏该文章,然后在朋友圈文章列表的上方增加一条收藏栏,在收藏栏中显示所有的收藏文章列表。其次,一般只有博主本人才有权限增删收藏栏的文章,所以还要添加一个密码验证功能。

收藏栏

密码输入框

详细效果请前往 订阅 页面查看。

关于收藏栏的后端部分,这里采用的是Github+Supabase+Vercel的方案,另外配有缓存机制,首次进入页面调用云端数据库的数据,重新进入页面则调用本地缓存,缓存设有 1 天的有效期,过期后会自动拉取云端数据库以更新本地缓存。另外为实现多端同步,在收藏栏右上角设有刷新按钮,可以实现一键同步。收藏栏默认显示两行,多余部分折叠隐藏,可点击右上角按钮展开全部内容。

朋友圈配置

这个文章收藏是在友链朋友圈的基础上延伸出来的一个附加功能,所以基础是要先配置好友链朋友圈功能。朋友圈我是按照 官方文档 配置的,其中后端部分采用的是Github+Sqlite+Vercel无服务器部署 方式,前端部分采用的是 林木木 的方案。林木木的方案不带管理面板,如果需要更改设置的话,会比较麻烦,但优点是代码量少,简短易读,方便我二次修改。

林木木的朋友圈方案配有一个顶栏,可以显示“订阅”、“活跃”、“日志”等消息,点击“订阅”可以查看随机一个友链的文章列表,点击“活跃”可以切换私有友链库和公共库两种数据源,点击“日志”可以手动清空本地缓存,重新拉取云端数据库。另外支持“创建日期”和“更新日期”两种排列方式,可以自由手动切换。

数据库配置

Vercel 的 Integrations Marketplace 上提供有很多种数据库,其中 MongoDB Atlas 之前在部署 twikoo 评论的时候用过,Upstash 在配置个人网盘的时候用过,听群友推荐说 PlanetScale 和 Supabase 也不错。这里采用 Supabase 作为项目云端数据存储。

Databases in Integrations Marketplace

注册 Supabase 后进入 Dashboard,首先点击 New project 创建一个新的项目,设置项目名称和数据库密码,选择合适的地区,然后确认创建。整个创建过程有点长,需要耐心等待几十秒的时间。

Create a new project

创建完成会自动进入项目,页面左侧有一条纵向菜单栏,点击 Table Editor 可以对数据库进行可视化操作,点击 SQL Editor 可以进行命令行操作,这对于小白来说还是非常方便的。我们进入 Table Editor 点击 New table 创建一个新数据表,设置好表名、描述,最重要的是各列的参数配置,默认的 Column 有 id 和 created_at 两列,点击 Add column 新增几条参数,分别是 index、title、link、author、avatar、time 六个参数,参数类型均设置为 text,点击 save 保存。

Create a new table

Add columns

进入数据表,点击 Insert 可以手动插入数据,因为是可视化界面,增删改查都十分直观。当然我们不需要在这里插入数据,而是要能够在外部调用它的增删改查能力。在左侧菜单栏中点击 API Docs 可以查看详细的使用方法,其中,Introduction 栏提供了初始化代码。

1
2
3
4
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = "https://xxxxxxxxxxxxx.supabase.co";
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);

这里有两个重要参数supabaseUrlsupabaseKey,需要在设置里面查找。点击左侧菜单栏中的 Project Settings,点击 API 栏,其中 Project URL 即为 supabaseUrl,Project API keys 即为 supabaseKey。

获取supabaseUrl和supabaseKey

回到 API Docs,在 Tables and Views 栏找到刚刚新建的表名,进入文档后可以看到详细的增删改查示例代码。

示例代码

后端代码

Github 新建一个私有仓库,仓库内主要包含 3 个文件,内容如下。

index.js

注意这里 supabaseUrl 做了脱敏处理,要替换成自己的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// 导入所需包和模块
const express = require("express");
require("dotenv").config(); // 导入 dotenv 并加载 .env 文件
const app = express();
app.use(function (req, res, next) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
next();
});
// 创建 Express 应用程序
app.use(express.json());
const savedKey = process.env.KEY;
// 连接数据库
const { createClient } = require("@supabase/supabase-js");
const supabaseUrl = "https://xxxxxxxxxxxxxxxxx.supabase.co";
const supabaseKey = process.env.SUPABASE_KEY;
const supabase = createClient(supabaseUrl, supabaseKey);

// 新增收藏
app.get("/subscribe", async (req, res) => {
try {
const key = req.query.key.toString();
const index = req.query.index.toString();
const title = req.query.title.toString();
const link = req.query.link.toString();
const author = req.query.author.toString();
const avatar = req.query.avatar.toString();
const time = req.query.time.toString();
if (key == savedKey) {
// 存入数据库
const { data: filterData, error: error1 } = await supabase
.from("Subscribe")
.select()
.eq("index", index);
if (error1) {
console.error("Error:", error1);
res.status(500).json({ code: "500", message: "查询失败", content: "" });
} else {
if (!filterData.length) {
const { data: createdData, error: error2 } = await supabase
.from("Subscribe")
.insert([
{
index: index,
title: title,
link: link,
author: author,
avatar: avatar,
time: time,
},
])
.select();
if (error2) {
console.error("Error:", error2);
res
.status(500)
.json({ code: "500", message: "存储失败", content: "" });
} else {
console.log("Data stored successfully:", createdData[0]);
res.status(200).json({
code: "200",
message: "存储成功",
content: createdData[0],
});
}
} else {
res
.status(402)
.json({ code: "402", message: "文章已存在", content: "" });
}
}
} else {
res.status(401).json({ code: "401", message: "密码错误", content: "" });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
}
});

// 文章删除
app.get("/delsavedtitles", async (req, res) => {
try {
const key = req.query.key.toString();
const index = req.query.index.toString();
if (key == savedKey) {
const { data, error } = await supabase
.from("Subscribe")
.delete()
.eq("index", index);
if (error) {
console.error("Error:", error);
res.status(500).json({ code: "500", message: "删除失败", content: "" });
} else {
if (!data.length) {
res
.status(404)
.json({ code: "404", message: "未找到文章", content: data });
} else {
console.log("Data delete completely");
res
.status(200)
.json({ code: "200", message: "删除成功", content: data });
}
}
} else {
res.status(401).json({ code: "401", message: "密码错误", content: "" });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
}
});

// 收藏文章查询
app.get("/getsavedtitles", async (req, res) => {
try {
const mode = req.query.mode.toString();
const column = req.query.column.toString();
const value = req.query.value.toString();
if (mode == "search") {
const { data: filterData, error } = await supabase
.from("Subscribe")
.select()
.eq(column, value);
if (error) {
console.error("Error:", error);
res.status(404).json({ code: "404", message: "查询失败", content: "" });
} else {
console.log("Data serach completely:", filterData);
res
.status(200)
.json({ code: "200", message: "查询成功", content: filterData });
}
} else if (mode == "all") {
const { data: filterData, error } = await supabase
.from("Subscribe")
.select();
if (error) {
console.error("Error:", error);
res.status(404).json({ code: "404", message: "查询失败", content: "" });
} else if (mode == "all") {
console.log("Data serach completely:", filterData);
res
.status(200)
.json({ code: "200", message: "查询成功", content: filterData });
}
} else {
res.status(401).json({ code: "401", message: "参数错误", content: "" });
}
} catch (error) {
console.error(error);
res.status(500).json({ error: "Internal Server Error" });
}
});

// 启动服务器
const server = app.listen(process.env.PORT || 3000, () => {
const port = server.address().port;
console.log(`Server is running on port ${port}`);
});

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"name": "projectname",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "Echo \"error: error\" && exit 1",
"start": "node index.js"
},
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^0.27.2",
"express": "^4.18.1",
"dotenv": "^8.2.0",
"@supabase/supabase-js": "^1.0.0"
}
}

vercel.json

注意 KEY 和 SUPABASE_KEY 要换成自己的,其中KEY为 SHA256 加密后的前端身份验证密码,SUPABASE_KEY为 Supabase 密钥。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"version": 2,
"builds": [
{
"src": "./index.js",
"use": "@vercel/node"
}
],
"routes": [
{
"src": "/(.*)",
"dest": "/"
}
],
"env": {
"KEY": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"SUPABASE_KEY": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}

仓库建好后,部署到 Vercel 上,在 Storage 栏进入 Integrations Marketplace 找到 Supabase,点击 Add Integration 将其添加进项目。然后就是配置自定义域名,自此后端配置完成。

前端代码

index.md

打开先前在配置友链朋友圈的时候创建的 /fcircle/index.md,配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
---
title: 订阅
descr: 使用 友链朋友圈 订阅友链最新文章
cover: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
date: xxxxxxxxxxxxxxxxxxxxxxx
---

<h2 id="我的收藏">
我的收藏
<a class="refresh" onclick="localStorage.removeItem('savedArticles');location.reload();">
<i class="blogfont icon-refresh"></i>
</a>
<a class="fold" onclick="foldSavedArticles(event)">
<i class="blogfont icon-arrow-down"></i>
</a>
</h2>

<div id="cf-saved-post">
</div>

<h2 id="最新文章">
最新文章
</h2>

<div class="post-content">
<div id="cf-container">
与主机通讯中……
</div>
</div>

<div class="inputBoxMain" id="fcircleInputBox">
<div class="inputBox">
<div class="content-body">
<span class="title"></span>
<span class="tips">请输入密码以验证您的身份</span>
<input class="input-password" type="password" placeholder="密码">
</div>
<div class="content-bottom">
<span class="btn close" onclick="switchSecretInput()">取消</span>
<span class="btn" onclick="sendSubscribeInfo()">确认</span>
</div>
</div>
<div class="noteBox hide">
<a class="icon">
<i class="fas fa-spinner fa-spin"></i>
</a>
<span class="tips">请稍候 ...</span>
</div>
<div class="inputBoxMask">
</div>
</div>

<script type="text/javascript">
var fdataUser = {
apiurl: 'https://xxx.xxx.top/'
};
var article_index = '',
article_title = '',
article_link = '',
article_author = '',
article_avatar = '',
article_time = '',
article_pwd = '';
var savedArticlesIndex = '';
var catchNowTime = Date.now();
var updateTime = localStorage.getItem("updateTime");
updateTime == null || catchNowTime - updateTime < 86400000 ? null : localStorage.removeItem("savedArticles");//每隔1天刷新一次
var savedArticles = localStorage.getItem("savedArticles");
if (savedArticles != null) {
console.log("内存读取成功");
var savedArticlesJson = JSON.parse(savedArticles)
addArticleCard(savedArticlesJson);
savedArticlesIndex = savedArticlesJson.map(item => item.index);
} else {
fetch("https://xxx.xxx.top/getsavedtitles?mode=all&column=&value=&output=jsonp")
.then(response => response.json())
.then(data => {
if (data.code == 200) {
console.log('获取收藏夹成功');
savedArticles = data.content;
localStorage.setItem("updateTime", catchNowTime);
localStorage.setItem("savedArticles", JSON.stringify(savedArticles));
console.log(savedArticles);
addArticleCard(savedArticles);
savedArticlesIndex = savedArticles.map(item => item.index);
console.log(savedArticlesIndex);
checkStared(savedArticlesIndex);
} else {
console.log('获取收藏夹失败')
}
})
.catch(error => {
console.error('获取收藏夹失败', error);
})
}
function checkStared(s) {
for (let i = 0; i < s.length; i++) {
var j = document.querySelector("#cf-container ." + s[i] + " .cf-star");
if (j) {
j.classList.contains("saved") ? null : j.classList.add("saved");
}
}
}
function addArticleCard(a) {
var container = '';
for (let i=0; i<a.length; i++) {
var item = a[i];
container += `
<div class="cf-article ${item.index}">
<a class="cf-article-title" href="${item.link}" target="_blank" rel="noopener nofollow" data-title="${item.title}">${item.title}</a>
<a class="cf-star saved" onclick="switchSecretInput(event)"><i class="fa-regular fa-star"></i></a>
<div class="cf-article-avatar no-lightbox flink-item-icon">
<img class="cf-img-avatar avatar" src="${item.avatar}" alt="avatar" onerror="this.src=''; this.onerror = null;">
<a class="" target="_blank" rel="noopener nofollow"><span class="cf-article-author">${item.author}</span></a>
</div>
<span class="cf-article-time">
<span class="cf-time-created">${item.time}</span>
</span>
</div>
`;
}
document.getElementById("cf-saved-post").insertAdjacentHTML('beforeend', container);
}
function switchSecretInput(event) {
const a = document.getElementById("fcircleInputBox");
const b = a.querySelector(".input-password");
const f = a.querySelector(".content-body .title");
function c(e) { article_pwd = e.target.value; }
if (a.classList.contains("open")) {
a.classList.remove("open");
b.removeEventListener('input', c);
article_index = '';
article_title = '';
article_link = '';
article_author = '';
article_avatar = '';
article_time = '';
} else {
a.classList.add("open");
b.addEventListener('input', c);
var d = '';
if (event.target.nodeName.toUpperCase() == "A") {
if (event.target.classList.contains("saved")) {
sendMode = 1;
f.innerHTML = "移出收藏";
} else {
sendMode = 0;
f.innerHTML = "添加收藏";
}
d = event.target.parentElement.querySelector(".cf-article-title");
article_author = event.target.parentElement.querySelector(".cf-article-avatar .cf-article-author").innerText;
article_avatar = event.target.parentElement.querySelector(".cf-article-avatar .cf-img-avatar").src;
article_time = event.target.parentElement.querySelector(".cf-article-time .cf-time-created").innerText;
} else if (event.target.nodeName.toUpperCase() == "I") {
if (event.target.parentElement.classList.contains("saved")) {
sendMode = 1;
f.innerHTML = "移出收藏";
} else {
sendMode = 0;
f.innerHTML = "添加收藏";
}
d = event.target.parentElement.parentElement.querySelector(".cf-article-title");
article_author = event.target.parentElement.parentElement.querySelector(".cf-article-avatar .cf-article-author").innerText;
article_avatar = event.target.parentElement.parentElement.querySelector(".cf-article-avatar .cf-img-avatar").src;
article_time = event.target.parentElement.parentElement.querySelector(".cf-article-time .cf-time-created").innerText;
}
article_title = d.innerText;
article_link = d.getAttribute("href");
article_index = "cf-" + CryptoJS.MD5(article_link).toString();
}
},
function sendSubscribeInfo() {
var key = CryptoJS.SHA256(article_pwd);
var url = sendMode == 1
? "https://xxx.xxx.top/delsavedtitles?key=" + key + "&index=" + article_index
: "https://xxx.xxx.top/subscribe?key=" + key + "&index=" + article_index + "&title=" + article_title + "&link=" + article_link + "&author=" + article_author + "&avatar=" + article_avatar + "&time=" + article_time;
var inputBox = document.querySelector("#fcircleInputBox .inputBox");
var noteBox = document.querySelector("#fcircleInputBox .noteBox");
inputBox.classList.add("hide");
noteBox.classList.remove("hide");
fetch(url)
.then(response => response.json())
.then(data => {
if (data.code == 200) {
var a = document.querySelector("#cf-saved-post ." + article_index);
if (a) { a.outerHTML = ""; }
var b = document.querySelector("." + article_index + " .cf-star");
if (typeof savedArticlesIndex != 'undefined') savedArticlesIndex = savedArticlesIndex.filter(element => element !== article_index);
if (b) {b.classList.contains("saved") ? b.classList.remove("saved") : b.classList.add("saved");}
if (sendMode == 0) {
var container = `
<div class="cf-article ${article_index}">
<a class="cf-article-title" href="${article_link}" target="_blank" rel="noopener nofollow" data-title="${article_title}">${article_title}</a>
<a class="cf-star saved" onclick="switchSecretInput(event)"><i class="fa-regular fa-star"></i></a>
<div class="cf-article-avatar no-lightbox flink-item-icon">
<img class="cf-img-avatar avatar" src="${article_avatar}" alt="avatar" onerror="this.src=''; this.onerror = null;">
<a class="" target="_blank" rel="noopener nofollow"><span class="cf-article-author">${article_author}</span></a>
</div>
<span class="cf-article-time">
<span class="cf-time-created">${article_time}</span>
</span>
</div>
`;
document.getElementById("cf-saved-post").insertAdjacentHTML('beforeend', container);
}
tools.showMessage(data.message, "success", 2);
localStorage.removeItem("savedArticles");
inputBox.classList.remove("hide");
noteBox.classList.add("hide");
document.querySelector("#fcircleInputBox .btn.close").click();
} else {
tools.showMessage(data.message, "error", 2);
inputBox.classList.remove("hide");
noteBox.classList.add("hide");
document.querySelector("#fcircleInputBox .btn.close").click();
}
})
.catch(error => {
console.error('收藏失败:', error);
})
}
function foldSavedArticles(event) {
var a = document.getElementById("cf-saved-post")
var b = event.target.parentElement.querySelector("i")
if (b.style.transform == "rotate(180deg)") {
a.style.maxHeight = "240px"
b.style.transform = ""
} else {
a.style.maxHeight = typeof savedArticlesIndex != 'undefined' ? (savedArticlesIndex.length * 130 - 20) + "px" : "fit-content"
b.style.transform = "rotate(180deg)"
}
}
</script>

<link rel="stylesheet" href="/css/fcircle.css">
<script type="text/javascript" src="https://lf26-cdn-tos.bytecdntp.com/cdn/expire-1-M/crypto-js/4.1.1/crypto-js.min.js"></script>
<script type="text/javascript" src="/js/fcircle.js"></script>

注意,友链头像的链接含有 & 字符会产生干扰,建议采用图床存储头像图片,或者在上面代码中对 article_avatar 参数做 encodeURIComponent 和 decodeURIComponent 编解码处理。

另外,上面用到的 tools.showMessage 方法依赖于 ElementUI 库,如果你有自己的弹窗通知系统,可以将它替换掉,比如主题自带的 btf.snackbarShow。
如果你也要用 ElementUI 的弹窗,可以参照下面方法,更多用法参考文档:ElementUI文档 - Message

1
2
3
4
5
6
inject:
head:
+ - <link rel="stylesheet" href="https://npm.elemecdn.com/element-ui@2.15.6/lib/theme-chalk/index.css" media="defer" onload="this.media='all'">
bottom:
+ - <script async src="https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/vue/2.6.14/vue.min.js"></script> # 引入VUE
+ - <script async src="https://npm.elemecdn.com/element-ui@2.15.6/lib/index.js"></script> # 引入ElementUI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
var tools = {
showMessage(text, style, delay) {
new Vue({
data: function () {
this.$message({
message: text,
showClose: true,
type: style,
duration: delay * 1000
});
}
})
}
}

fcircle.js

fcircle.js 的改动不多,主要是卡片 HTML 结构修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
/*
Last Modified time : 20220326 15:38 by https://immmmm.com
已适配 FriendCircle 公共库和主库
*/

//默认数据
var fdata = {
jsonurl: "",
apiurl: "",
apipublieurl: "https://fcircle-pub.rct.cool/", //默认公共库
initnumber: 24, //首次加载文章数
stepnumber: 12, //更多加载文章数
article_sort: "created", //文章排序 updated or created
error_img: "https://xxxxxxxxxxxxxx.webp",
};
//可通过 var fdataUser 替换默认值
if (typeof fdataUser !== "undefined") {
for (var key in fdataUser) {
if (fdataUser[key]) {
fdata[key] = fdataUser[key];
}
}
}
var article_num = "",
sortNow = "",
UrlNow = "",
friends_num = "";
var container =
document.getElementById("cf-container") ||
document.getElementById("fcircleContainer");
// 获取本地 排序值、加载apiUrl,实现记忆效果
var localSortNow = localStorage.getItem("sortNow");
var localUrlNow = localStorage.getItem("urlNow");
if (localSortNow && localUrlNow) {
sortNow = localSortNow;
UrlNow = localUrlNow;
} else {
sortNow = fdata.article_sort;
if (fdata.jsonurl) {
UrlNow = fdata.apipublieurl + "postjson?jsonlink=" + fdata.jsonurl + "&";
} else if (fdata.apiurl) {
UrlNow = fdata.apiurl + "all?";
} else {
UrlNow = fdata.apipublieurl + "all?";
}
console.log("当前模式:" + UrlNow);
localStorage.setItem("urlNow", UrlNow);
localStorage.setItem("sortNow", sortNow);
}
// 打印基本信息
function loadStatistical(sdata) {
article_num = sdata.article_num;
friends_num = sdata.friends_num;
var messageBoard = `
<div id="cf-state" class="cf-new-add">
<div class="cf-state-data">
<div class="cf-data-friends" onclick="openToShow()">
<span class="cf-label">订阅</span>
<span class="cf-message">${sdata.friends_num}</span>
</div>
<div class="cf-data-active" onclick="changeEgg()">
<span class="cf-label">活跃</span>
<span class="cf-message">${sdata.active_num}</span>
</div>
<div class="cf-data-article" onclick="clearLocal()">
<span class="cf-label">日志</span>
<span class="cf-message">${sdata.article_num}</span>
</div>
</div>
<div id="cf-change">
<span id="cf-change-created" data-sort="created" onclick="changeSort(event)" class="${
sortNow == "created" ? "cf-change-now" : ""
}">Created</span> | <span id="cf-change-updated" data-sort="updated" onclick="changeSort(event)" class="${
sortNow == "updated" ? "cf-change-now" : ""
}" >Updated</span>
</div>
</div>
`;
var loadMoreBtn = `
<div id="cf-more" class="cf-new-add" onclick="loadNextArticle()"><i class="fas fa-angle-double-down"></i></div>
<div id="cf-footer" class="cf-new-add">
<span id="cf-version-up" onclick="checkVersion()"></span>
<span class="cf-data-lastupdated">更新于:${sdata.last_updated_time}</span>
Powered by <a target="_blank" href="https://github.com/Rock-Candy-Tea/hexo-circle-of-friends" target="_blank">FriendCircle</a>
<br>
Designed by <a target="_blank" href="https://immmmm.com" target="_blank">林木木</a>
<br>
Adapted by <a target="_blank" href="https://gavin-chen.top" target="_blank">南方嘉木</a>
</div>
<div id="cf-overlay" class="cf-new-add" onclick="closeShow()"></div>
<div id="cf-overshow" class="cf-new-add"></div>
`;
if (container) {
container.insertAdjacentHTML("beforebegin", messageBoard);
container.insertAdjacentHTML("afterend", loadMoreBtn);
}
}
// 打印文章内容 cf-article <span class="cf-article-floor">${item.floor}</span>
function loadArticleItem(datalist, start, end) {
var articleItem = "";
var articleNum = article_num;
var endFor = end;
if (end > articleNum) {
endFor = articleNum;
}
if (start < articleNum) {
for (var i = start; i < endFor; i++) {
var item = datalist[i];
var id = "cf-" + CryptoJS.MD5(item.link).toString();
articleItem += `
<div class="cf-article ${id}">
<a class="cf-article-title" href="${
item.link
}" target="_blank" rel="noopener nofollow" data-title="${
item.title
}">${item.title}</a>
<a class="cf-star" onclick="switchSecretInput(event)"><i class="fa-regular fa-star"></i></a>
<div class="cf-article-avatar no-lightbox flink-item-icon">
<img class="cf-img-avatar avatar" src="${
item.avatar
}" alt="avatar" onerror="this.src='${
fdata.error_img
}'; this.onerror = null;">
<a onclick="openMeShow(event)" data-link="${
item.link
}" class="" target="_blank" rel="noopener nofollow" href="javascript:;"><span class="cf-article-author">${
item.author
}</span></a>
</div>
<span class="cf-article-time">
<span class="cf-time-created" style="${
sortNow == "created" ? "" : "display:none"
}">${item.created}</span>
<span class="cf-time-updated" style="${
sortNow == "updated" ? "" : "display:none"
}">${item.updated}</span>
</span>
</div>
`;
}
container.insertAdjacentHTML("beforeend", articleItem);
if (savedArticlesIndex != null) {
checkStared(savedArticlesIndex);
}
// 预载下一页文章
fetchNextArticle();
} else {
// 文章加载到底
document.getElementById(
"cf-more"
).outerHTML = `<div id="cf-more" class="cf-new-add" onclick="loadNoArticle()"><small>一切皆有尽头!</small></div>`;
}
}
// 打印个人卡片 cf-overshow
function loadFcircleShow(userinfo, articledata) {
var showHtml = `
<div class="cf-overshow">
<div class="cf-overshow-head">
<img class="cf-img-avatar avatar" src="${userinfo.avatar}" alt="avatar" onerror="this.src='${fdata.error_img}'; this.onerror = null;">
<a class="" target="_blank" rel="noopener nofollow" href="${userinfo.link}">${userinfo.name}</a>
</div>
<div class="cf-overshow-content">
`;
for (var i = 0; i < userinfo.article_num; i++) {
var item = articledata[i];
showHtml += `
<p><a class="cf-article-title" href="${item.link}" target="_blank" rel="noopener nofollow" data-title="${item.title}">${item.title}</a><span>${item.created}</span></p>
`;
}
showHtml += "</div></div>";
document
.getElementById("cf-overshow")
.insertAdjacentHTML("beforeend", showHtml);
document.getElementById("cf-overshow").className = "cf-show-now";
}

// 预载下一页文章,存为本地数据 nextArticle
function fetchNextArticle() {
var start = document.querySelectorAll("#cf-container .cf-article").length;
var end = start + fdata.stepnumber;
var articleNum = article_num;
if (end > articleNum) {
end = articleNum;
}
if (start < articleNum) {
UrlNow = localStorage.getItem("urlNow");
var fetchUrl =
UrlNow + "rule=" + sortNow + "&start=" + start + "&end=" + end;
//console.log(fetchUrl)
fetch(fetchUrl)
.then((res) => res.json())
.then((json) => {
var nextArticle = eval(json.article_data);
console.log(
"已预载" + "?rule=" + sortNow + "&start=" + start + "&end=" + end
);
localStorage.setItem("nextArticle", JSON.stringify(nextArticle));
});
} else if ((start = articleNum)) {
document.getElementById(
"cf-more"
).outerHTML = `<div id="cf-more" class="cf-new-add" onclick="loadNoArticle()"><small>一切皆有尽头!</small></div>`;
}
}
// 显示下一页文章,从本地缓存 nextArticle 中获取 <span class="cf-article-floor">${item.floor}</span>

function loadNextArticle() {
var nextArticle = JSON.parse(localStorage.getItem("nextArticle"));
var articleItem = "";
for (var i = 0; i < nextArticle.length; i++) {
var item = nextArticle[i];
var id = "cf-" + CryptoJS.MD5(item.link).toString();
articleItem += `
<div class="cf-article ${id}">
<a class="cf-article-title" href="${
item.link
}" target="_blank" rel="noopener nofollow" data-title="${
item.title
}">${item.title}</a>
<a class="cf-star" onclick="switchSecretInput(event)"><i class="fa-regular fa-star"></i></a>
<div class="cf-article-avatar no-lightbox flink-item-icon">
<img class="cf-img-avatar avatar" src="${
item.avatar
}" alt="avatar" onerror="this.src='${
fdata.error_img
}'; this.onerror = null;">
<a onclick="openMeShow(event)" data-link="${
item.link
}" class="" target="_blank" rel="noopener nofollow" href="javascript:;"><span class="cf-article-author">${
item.author
}</span></a>
</div>
<span class="cf-article-time">
<span class="cf-time-created" style="${
sortNow == "created" ? "" : "display:none"
}">${item.created}</span>
<span class="cf-time-updated" style="${
sortNow == "updated" ? "" : "display:none"
}">${item.updated}</span>
</span>
</div>
`;
}
container.insertAdjacentHTML("beforeend", articleItem);
if (savedArticlesIndex != null) {
checkStared(savedArticlesIndex);
}
// 同时预载下一页文章
fetchNextArticle();
}
// 没有更多文章
function loadNoArticle() {
var articleSortData = sortNow + "ArticleData";
localStorage.removeItem(articleSortData);
localStorage.removeItem("statisticalData");
//localStorage.removeItem("sortNow")
document.getElementById("cf-more").remove();
window.scrollTo(0, document.getElementsByClassName("cf-state").offsetTop);
}
// 清空本地数据
function clearLocal() {
localStorage.removeItem("updatedArticleData");
localStorage.removeItem("createdArticleData");
localStorage.removeItem("nextArticle");
localStorage.removeItem("statisticalData");
localStorage.removeItem("sortNow");
localStorage.removeItem("urlNow");
location.reload();
}
//
function checkVersion() {
var url = fdata.apiurl + "version";
fetch(url)
.then((res) => res.json())
.then((json) => {
console.log(json);
var nowStatus = json.status,
nowVersion = json.current_version,
newVersion = json.latest_version;
var versionID = document.getElementById("cf-version-up");
if (nowStatus == 0) {
versionID.innerHTML = "当前版本:v" + nowVersion;
} else if (nowStatus == 1) {
versionID.innerHTML = "发现新版本:v" + nowVersion + " ↦ " + newVersion;
} else {
versionID.innerHTML = "网络错误,检测失败!";
}
});
}
// 切换为公共全库
function changeEgg() {
//有自定义json或api执行切换
if (fdata.jsonurl || fdata.apiurl) {
document.querySelectorAll(".cf-new-add").forEach((el) => el.remove());
localStorage.removeItem("updatedArticleData");
localStorage.removeItem("createdArticleData");
localStorage.removeItem("nextArticle");
localStorage.removeItem("statisticalData");
container.innerHTML = "";
UrlNow = localStorage.getItem("urlNow");
//console.log("新"+UrlNow)
var UrlNowPublic = fdata.apipublieurl + "all?";
if (UrlNow !== UrlNowPublic) {
//非完整默认公开库
changeUrl = fdata.apipublieurl + "all?";
} else {
if (fdata.jsonurl) {
changeUrl =
fdata.apipublieurl + "postjson?jsonlink=" + fdata.jsonurl + "&";
} else if (fdata.apiurl) {
changeUrl = fdata.apiurl + "all?";
}
}
localStorage.setItem("urlNow", changeUrl);
FetchFriendCircle(sortNow, changeUrl);
} else {
clearLocal();
}
}
// 首次加载文章
function FetchFriendCircle(sortNow, changeUrl) {
var end = fdata.initnumber;
var fetchUrl = UrlNow + "rule=" + sortNow + "&start=0&end=" + end;
if (changeUrl) {
fetchUrl = changeUrl + "rule=" + sortNow + "&start=0&end=" + end;
}
//console.log(fetchUrl)
fetch(fetchUrl)
.then((res) => res.json())
.then((json) => {
var statisticalData = json.statistical_data;
var articleData = eval(json.article_data);
var articleSortData = sortNow + "ArticleData";
loadStatistical(statisticalData);
loadArticleItem(articleData, 0, end);
localStorage.setItem("statisticalData", JSON.stringify(statisticalData));
localStorage.setItem(articleSortData, JSON.stringify(articleData));
});
}
// 点击切换排序
function changeSort(event) {
sortNow = event.currentTarget.dataset.sort;
localStorage.setItem("sortNow", sortNow);
document.querySelectorAll(".cf-new-add").forEach((el) => el.remove());
container.innerHTML = "";
changeUrl = localStorage.getItem("urlNow");
//console.log(changeUrl)
initFriendCircle(sortNow, changeUrl);
if (fdata.apiurl) {
checkVersion();
}
}
//查询个人文章列表
function openMeShow(event) {
event.preventDefault();
var parse_url =
/^(?:([A-Za-z]+):)?(\/{0,3})([0-9.\-A-Za-z]+)(?::(\d+))?(?:\/([^?#]*))?(?:\?([^#]*))?(?:#(.*))?$/;
var meLink = event.currentTarget.dataset.link.replace(parse_url, "$1:$2$3");
console.log(meLink);
var fetchUrl = "";
if (fdata.apiurl) {
fetchUrl = fdata.apiurl + "post?num=5&link=" + meLink;
} else {
fetchUrl = fdata.apipublieurl + "post?num=5&link=" + meLink;
}
//console.log(fetchUrl)
if (noClick == "ok") {
noClick = "no";
fetchShow(fetchUrl);
}
}
// 关闭 show
function closeShow() {
document.getElementById("cf-overlay").className -= "cf-show-now";
document.getElementById("cf-overshow").className -= "cf-show-now";
document.getElementById("cf-overshow").innerHTML = "";
}
// 点击开往
var noClick = "ok";
function openToShow() {
var fetchUrl = "";
if (fdata.apiurl) {
fetchUrl = fdata.apiurl + "post";
} else {
fetchUrl = fdata.apipublieurl + "post";
}
//console.log(fetchUrl)
if (noClick == "ok") {
noClick = "no";
fetchShow(fetchUrl);
}
}
// 展示个人文章列表
function fetchShow(url) {
var closeHtml = `
<div class="cf-overshow-close" onclick="closeShow()"></div>
`;
document.getElementById("cf-overlay").className = "cf-show-now";
document
.getElementById("cf-overshow")
.insertAdjacentHTML("afterbegin", closeHtml);
console.log("开往" + url);
fetch(url)
.then((res) => res.json())
.then((json) => {
//console.log(json)
noClick = "ok";
var statisticalData = json.statistical_data;
var articleData = eval(json.article_data);
loadFcircleShow(statisticalData, articleData);
});
}
// 初始化方法,如有本地数据首先调用
function initFriendCircle(sortNow, changeUrl) {
var articleSortData = sortNow + "ArticleData";
var localStatisticalData = JSON.parse(
localStorage.getItem("statisticalData")
);
var localArticleData = JSON.parse(localStorage.getItem(articleSortData));
container.innerHTML = "";
if (localStatisticalData && localArticleData) {
loadStatistical(localStatisticalData);
loadArticleItem(localArticleData, 0, fdata.initnumber);
console.log("本地数据加载成功");
var fetchUrl =
UrlNow + "rule=" + sortNow + "&start=0&end=" + fdata.initnumber;
fetch(fetchUrl)
.then((res) => res.json())
.then((json) => {
var statisticalData = json.statistical_data;
var articleData = eval(json.article_data);
//获取文章总数与第一篇文章标题
var localSnum = localStatisticalData.article_num;
var newSnum = statisticalData.article_num;
var localAtile = localArticleData[0].title;
var newAtile = articleData[0].title;
//判断文章总数或文章标题是否一致,否则热更新
if (localSnum !== newSnum || localAtile !== newAtile) {
document.getElementById("cf-state").remove();
document.getElementById("cf-more").remove();
document.getElementById("cf-footer").remove();
container.innerHTML = "";
var articleSortData = sortNow + "ArticleData";
loadStatistical(statisticalData);
loadArticleItem(articleData, 0, fdata.initnumber);
localStorage.setItem(
"statisticalData",
JSON.stringify(statisticalData)
);
localStorage.setItem(articleSortData, JSON.stringify(articleData));
console.log("热更新完成");
} else {
console.log("API数据未更新");
}
});
} else {
FetchFriendCircle(sortNow, changeUrl);
console.log("第一次加载完成");
}
}
// 执行初始化
initFriendCircle(sortNow);

CSS 样式部分比较冗杂,可自行 F12 获取。