项目地址friendlink-verify 项目详细请见github
当前博客友链页面还没有嵌入,因为测试实例删除了,先凑合看看图片吧!
详细接口、内容等请见github,魔改效果详见link页面


项目展示1
项目展示2
项目展示3


注册 MongoDB Atlas(免费数据库)

友链审核系统需要 MongoDB 数据库存储数据,推荐使用 MongoDB Atlas 免费版。

  1. 打开 mongodb.com/atlas
  2. 创建免费 MongoDB 数据库:
    区域选离你(Vercel / Netlify / AWS Lambda / VPS)最近的区域(如新加坡 ap-southeast-1)也可选择 AWS / Oregon (us-west-2),该数据中心基建成熟,故障率低,且使用 Oregon 州的清洁能源,较为环保
  3. Database Access页面点击Add New Database User创建数据库用户,Authentication MethodPassword,在Password Authentication下设置数据库用户名和密码,建议点击Auto Generate自动生成一个不含特殊符号的强壮密码并妥善保存。点击Database User Privileges下方的Add Built In Role,Select Role选择Atlas Admin,最后点击Add User
  4. Network Access页面点击Add IP Address添加网络白名单。因为Vercel / Netlify / Lambda的出口地址不固定,因此Access List Entry输入0.0.0.0/0(允许所有 IP 地址的连接)即可。
  5. Database页面点击Connect,连接方式选择Drivers,并记录数据库连接字符串,请将连接字符串中的 : 修改为刚刚创建的数据库用户名:密码

Fork 项目到你的 GitHub

  1. 打开 github.com/shangskr/friendlink-verify
  2. 点击右上角 Fork → 创建到你自己的账号下

创建 GitHub Token(可选,用于审核后自动推送友链到仓库)

如果需要审核通过后自动更新 GitHub 仓库里的友链文件,则需要创建 Token。

  1. 访问 GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
  2. 点击 Generate new token
  3. Token namefriendlink-verify
  4. Expiration 建议选 No expiration
  5. Select scopesrepo
  6. 点击 Generate token复制 token(关掉页面后就看不到了)

项目是通过写入yml文件然后我自己通过拉取json获取的友链数据。

部署到 Vercel

  1. 打开 vercel.com → 用 GitHub 账号登录
  2. 点击 Add New → Project
  3. Import Git Repository 中找到刚才 Fork 的项目,点击 Import
  4. Environment Variables 中添加环境变量:

环境变量

变量名 说明
MONGODB_URI MongoDB 连接字符串(第一步获取的)
ADMIN_USERNAME 管理员登录用户名,如 admin
ADMIN_PASSWORD 管理员登录密码,自己设一个
JWT_SECRET JWT 加密密钥,任意随机字符串,如 abc123xyz
NEXT_PUBLIC_APP_URL 部署后的域名,格式 https://你要绑定的域名不要用 vercel 提供的 xxx.vercel.app,用自己的域名或先用占位符)

github部分也建议填写不然无法完成推送到github仓库的某个yml友链文件。

变量名 说明
GITHUB_TOKEN 第三步获取的 GitHub Token
GITHUB_REPO 友链文件所在的仓库,格式 owner/repo
GITHUB_FILE_PATH 友链文件路径,如 link.yml
EMAIL_USER SMTP 发件邮箱,如 you@163.com
EMAIL_PASS SMTP 授权码(不是邮箱密码)
EMAIL_NAME 发件人显示名称,默认同 EMAIL_USER
EMAIL_RECIPIENT 管理员接收通知的邮箱
SMTP_SERVER SMTP 服务器,如 smtp.163.com
SMTP_PORT SMTP 端口,默认 465
NEXT_PUBLIC_DARK_MODE_START 定时进入夜间模式的时间,如 18:00
NEXT_PUBLIC_DARK_MODE_END 定时退出夜间模式的时间,如 06:00

注意: NEXT_PUBLIC_APP_URL 不要填 Vercel 自动分配的 项目名.vercel.app 域名(CORS 限制),建议绑定自定义域名或在部署后先申请一个自己的域名配置上去。

  1. 点击 Deploy,等待部署完成(约 1-2 分钟)
  2. 部署成功后 Vercel 会显示域名,访问 https://你的域名.vercel.app/admin 进入管理后台

配置 SMTP 邮件(以 163 邮箱为例)

如果需要邮件通知功能,需要配置 SMTP。

  1. 登录 163 邮箱 → 设置 → POP3/SMTP/IMAP
  2. 开启 SMTP 服务,按提示发送短信获取授权码
  3. 在 Vercel 项目设置中添加环境变量:
    • SMTP_SERVERsmtp.163.com
    • SMTP_PORT465
    • EMAIL_USERyourname@163.com
    • EMAIL_PASS → 刚才获取的授权码
    • EMAIL_NAME → 显示名称,如 安小歪
    • EMAIL_RECIPIENT → 管理员接收通知的邮箱
  4. 保存后重新部署

管理后台使用

访问 https://你的域名.vercel.app/admin,用前面设置的 ADMIN_USERNAMEADMIN_PASSWORD 登录。

提交列表

默认显示所有待审核的友链申请,支持按状态筛选(全部 / 待审核 / 已通过 / 已拒绝)。

审核操作

  • 通过 — 选择友链分组(对应 Butterfly 的 class_name),友链会自动追加到 GitHub 仓库的 YAML 文件的对应分组下,检测 YAML 截图字段约定,若无法自动检测则弹窗选择 siteshot / topimg,之后选择友链分组后确认,自动推送至 GitHub
  • 拒绝 — 填写拒绝原因(支持 Markdown + OwO 表情),原因会随邮件通知发送给提交者,检测 YAML 截图字段约定(同申请),跳过分组选择,直接确认
  • 删除 — 直接从数据库中删除该条记录

自动清理

在后台设置中,可按状态分别设置保留天数:

  • 待审核:超过 N 天自动删除
  • 已通过:超过 N 天自动删除
  • 已拒绝:超过 N 天自动删除

访问后台管理面板时会触发自动清理,设置的天数超过后就会自动清理 (非定时触发)

邮件模板

后台可在线编辑邮件模板,支持以下占位符:

通用占位符:{name}{url}{description}{email}{type}{originalUrl}{time}{adminUrl}

审核结果额外支持:{resultTitle}{resultAction}{reason}{descriptionRow}{reasonRow}

邮件模板中要将https://q2.qlogo.cn/headimg_dl?dst_uin=2622979530&spec=640替换为你自己的头像链接

表情包设置

配置 OwO JSON 链接,在拒绝弹窗中显示表情选择器。可以使用OwO表情包

审核通过后,友链数据以 Butterfly YAML 格式推送到 GitHub:

1
2
3
4
5
6
7
8
- class_name: 友情链接
class_desc: 我的小伙伴们
link_list:
- name: 站点名称
link: https://example.com
avatar: https://example.com/avatar.png
siteshot: https://example.com/screenshot.png # 也兼容 topimg 字段
descr: 站点描述

系统自动适配 siteshottopimg 两种截图字段名:

  • 新增友链 — 扫描全部分组已有数据使用的字段名(siteshottopimg),保持一致;若 YAML 中无任何截图记录,后台弹窗让管理员手动选择
  • 更新友链 — 通过 originalUrl 匹配 YAML 中的 link 字段找到原记录替换,保留原记录的截图字段名
  • 提交时 — 传 siteshottopimg 皆可

小技巧: 如果 YAML 中没有任何截图记录,每次审核都会弹窗让你选择字段。可以在任意一个友链记录中手动加上 siteshot: https://(或 topimg),系统检测到有值后就会自动使用该字段名,不再弹窗。

注意: 确保 link 字段末尾不要有反斜杠 /,否则更新友链时无法匹配原数据。系统写入时会自动去除尾部斜杠,但 YAML 中已有的旧数据需要手动清理。


嵌入到 Butterfly 主题

创建 CSS 文件

themes/butterfly/source/css/ 下创建 link.css,写入样式代码

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
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
#fl-wrap {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 20px 0;
--fl-text: #363636;
--fl-text-secondary: #666;
--fl-text-muted: #999;
--fl-bg: rgba(255, 255, 255, 0.88);
--fl-border: 1px solid rgb(169, 169, 169);
--fl-input-bg: #fff;
--fl-input-border: #d1d5db;
--fl-input-text: #363636;
--fl-err-bg: #fef2f2;
--fl-err-border: #fecaca;
--fl-err-text: #dc2626;
--fl-btn-bg: #49b1f5;
--fl-btn-hover: #3d9ce0;
--fl-btn-disabled: #bbb;
--fl-success-h3: #363636;
--fl-option-btn-border: #49b1f5;
--fl-option-btn-active-bg: #49b1f5;
backdrop-filter: blur(5px) saturate(150%);
}

[data-theme='dark'] #fl-wrap {
--fl-text: #c0c0c0;
--fl-text-secondary: #aaa;
--fl-text-muted: #999;
--fl-bg: rgba(25, 25, 25, 0.88);
--fl-border: 1px solid rgb(80, 80, 80);
--fl-input-bg: rgb(42, 42, 42);
--fl-input-border: rgb(80, 80, 80);
--fl-input-text: #eee;
--fl-err-bg: rgba(100, 30, 30, 0.3);
--fl-err-border: rgb(120, 30, 30);
--fl-err-text: #ffa0a0;
--fl-btn-bg: #49b1f5;
--fl-btn-hover: #3d9ce0;
--fl-btn-disabled: #555;
--fl-success-h3: #eee;
--fl-checkbox: #49b1f5;
--fl-option-btn-border: #49b1f5;
--fl-option-btn-active-bg: #49b1f5;
}

#fl-wrap label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 14px;
padding: 4px 0;
color: var(--fl-text);
}

#fl-wrap input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--fl-checkbox, #49b1f5);
cursor: pointer;
}

#fl-wrap .fl-hint {
font-size: 13px;
color: var(--fl-text-muted);
}

#fl-wrap .fl-hint.fl-sm {
margin: -10px 0 14px;
font-size: 11px;
}

#fl-wrap .fl-condition-hint {
margin-top: 10px;
color: #ef4444;
}

#fl-wrap .fl-form {
display: none;
margin-top: 12px;
padding: 20px;
background: var(--fl-bg);
backdrop-filter: blur(5px) saturate(150%);
border: var(--fl-border);
border-radius: 12px;
}

#fl-wrap .fl-field {
margin-bottom: 14px;
}

#fl-wrap .fl-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--fl-text);
margin-bottom: 4px;
}

#fl-wrap .fl-star {
color: #ef4444;
}

#fl-wrap .fl-input {
width: 100%;
padding: 8px 10px;
font-size: 14px;
border: 1px solid var(--fl-input-border);
border-radius: 6px;
outline: none;
box-sizing: border-box;
color: var(--fl-input-text);
background: var(--fl-input-bg);
}

#fl-wrap .fl-input:focus {
border-color: #49b1f5;
box-shadow: 0 0 0 2px rgba(73, 177, 245, .2);
}

#fl-wrap .fl-err {
display: none;
padding: 8px 12px;
background: var(--fl-err-bg);
border: 1px solid var(--fl-err-border);
border-radius: 6px;
color: var(--fl-err-text);
font-size: 13px;
margin-bottom: 14px;
}

#fl-wrap .fl-btn {
width: 100%;
padding: 9px 16px;
font-size: 14px;
font-weight: 500;
color: #fff;
background: var(--fl-btn-bg);
border: none;
border-radius: 6px;
cursor: pointer;
}

#fl-wrap .fl-btn:hover {
background: var(--fl-btn-hover);
}

#fl-wrap .fl-btn:disabled {
background: var(--fl-btn-disabled);
cursor: not-allowed;
}

#fl-wrap .fl-success {
text-align: center;
padding: 48px 24px;
}

#fl-wrap .fl-success h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--fl-success-h3);
}

#fl-wrap .fl-success p {
margin: 0;
font-size: 14px;
color: var(--fl-text-secondary);
line-height: 1.5;
}

#fl-wrap>h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: var(--fl-text);
}

#fl-wrap>p {
margin: 0 0 8px;
font-size: 13px;
color: var(--fl-text-secondary);
}

#fl-wrap .fl-form h3 {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: var(--fl-text);
}

#fl-wrap .fl-option-btns {
display: flex;
gap: 12px;
margin: 10px 0;
}

#fl-wrap .fl-option-btn {
flex: 1;
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
border: 2px solid var(--fl-input-border);
border-radius: 8px;
background: var(--fl-input-bg);
color: var(--fl-text);
cursor: pointer;
transition: all .2s;
}

#fl-wrap .fl-option-btn:hover {
border-color: var(--fl-option-btn-border);
color: var(--fl-option-btn-border);
}

#fl-wrap .fl-option-btn.active {
border-color: var(--fl-option-btn-active-bg);
background: var(--fl-option-btn-active-bg);
color: #fff;
}

#fl-wrap .fl-update-divider {
border-top: 1px solid var(--fl-border);
margin: 16px 0;
padding-top: 16px;
}


/* ===== 友链申请状态列表 ===== */
#fl-status-section {
margin-top: 32px;
padding: 20px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(5px) saturate(150%);
border: 1px solid rgb(169, 169, 169);
border-radius: 12px;
--fl-text: #363636;
--fl-text-secondary: #666;
--fl-text-muted: #999;
--fl-input-border: #d1d5db;
--fl-input-bg: #fff;
--fl-input-text: #363636;
--fl-option-btn-border: #49b1f5;
--fl-option-btn-active-bg: #49b1f5;
}

[data-theme='dark'] #fl-status-section {
background: rgba(25, 25, 25, 0.88);
border-color: rgb(80, 80, 80);
--fl-text: #c0c0c0;
--fl-text-secondary: #aaa;
--fl-text-muted: #999;
--fl-input-border: rgb(80, 80, 80);
--fl-input-bg: rgb(42, 42, 42);
--fl-input-text: #eee;
--fl-option-btn-border: #49b1f5;
--fl-option-btn-active-bg: #49b1f5;
}

#fl-status-section .fl-status-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
}

#fl-status-section .fl-status-title {
font-size: 15px;
font-weight: 600;
color: var(--fl-text);
margin: 0;
white-space: nowrap;
}

#fl-status-section .fl-status-title-count {
font-size: 12px;
font-weight: 400;
color: var(--fl-text-muted);
margin-left: 4px;
}

#fl-status-section .fl-status-header-right {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
}

#fl-status-section .fl-status-dropdown {
position: relative;
display: inline-block;
flex: 1;
min-width: 0;
}

#fl-status-section .fl-status-dropdown-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 14px;
font-size: 12px;
border: 1px solid var(--fl-input-border);
border-radius: 6px;
background: transparent;
color: var(--fl-text-secondary);
cursor: pointer;
transition: all .2s;
width: 100%;
}

#fl-status-section .fl-status-dropdown-trigger:hover {
border-color: var(--fl-option-btn-border);
color: var(--fl-option-btn-border);
}

#fl-status-section .fl-status-dropdown-arrow {
font-size: 10px;
transition: transform .2s;
}

#fl-status-section .fl-status-dropdown.open .fl-status-dropdown-arrow {
transform: rotate(180deg);
}

#fl-status-section .fl-status-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 110px;
background: var(--fl-input-bg);
border: 1px solid var(--fl-input-border);
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
display: none;
z-index: 100;
overflow: hidden;
}

[data-theme='dark'] #fl-status-section .fl-status-dropdown-menu {
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}

#fl-status-section .fl-status-dropdown.open .fl-status-dropdown-menu {
display: block;
}

#fl-status-section .fl-status-dropdown-item {
padding: 4px 12px;
font-size: 12px;
color: var(--fl-text-secondary);
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}

#fl-status-section .fl-status-dropdown-item:hover {
background: rgba(73, 177, 245, 0.1);
color: var(--fl-option-btn-border);
}

#fl-status-section .fl-status-dropdown-item.active {
color: var(--fl-option-btn-border);
font-weight: 500;
background: rgba(73, 177, 245, 0.08);
}

#fl-status-search {
padding: 5px 12px 5px 28px;
font-size: 12px;
border: 1px solid var(--fl-input-border);
border-radius: 6px;
outline: none;
background: var(--fl-input-bg);
color: var(--fl-input-text);
width: 100%;
transition: all .2s;
}

#fl-status-search:focus {
border-color: var(--fl-option-btn-border);
}

#fl-status-search-wrap {
position: relative;
flex: 1;
min-width: 0;
}

#fl-status-search-icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
color: var(--fl-text-muted);
pointer-events: none;
line-height: 1;
}

#fl-status-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}

#fl-status-grid .fl-status-empty,
#fl-status-grid .fl-status-loading,
#fl-status-grid .fl-status-error {
grid-column: 1 / -1;
text-align: center;
padding: 40px 16px;
color: var(--fl-text-muted);
font-size: 14px;
}

#fl-status-grid .fl-status-error {
color: var(--fl-err-text);
}

#fl-status-grid .fl-status-item {
min-width: 0;
padding: 14px;
border: 1.5px solid #b0b0b0;
border-radius: 10px;
background: var(--fl-input-bg);
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: all .2s;
}

#fl-status-grid .fl-status-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
min-width: 0;
flex-wrap: wrap;
}

#fl-status-grid .fl-status-item:hover {
border-color: var(--fl-option-btn-border);
transform: translateY(-2px);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1);
}

[data-theme='dark'] #fl-status-grid .fl-status-item {
border-color: #555;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}

[data-theme='dark'] #fl-status-grid .fl-status-item:hover {
border-color: var(--fl-option-btn-border);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3);
}

#fl-status-grid .fl-status-name {
font-size: 14px;
font-weight: 500;
color: var(--fl-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}

#fl-status-grid .fl-status-desc {
font-size: 12px;
color: var(--fl-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}

#fl-status-grid .fl-status-top-right {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}

#fl-status-grid .fl-status-badge {
padding: 2px 10px;
font-size: 11px;
font-weight: 500;
border-radius: 12px;
white-space: nowrap;
line-height: 1.5;
}

#fl-status-grid .fl-status-type {
font-size: 10px;
color: var(--fl-text-muted);
padding: 2px 6px;
background: var(--fl-input-bg);
border: 1px solid var(--fl-input-border);
border-radius: 4px;
line-height: 1.5;
}

#fl-status-grid .fl-status-badge.pending {
background: #fef9c3;
color: #a16207;
}

#fl-status-grid .fl-status-badge.approved {
background: #dcfce7;
color: #15803d;
}

#fl-status-grid .fl-status-badge.rejected {
background: #fef2f2;
color: #dc2626;
}

[data-theme='dark'] #fl-status-grid .fl-status-badge.pending {
background: rgba(234, 179, 8, 0.2);
color: #facc15;
}

[data-theme='dark'] #fl-status-grid .fl-status-badge.approved {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}

[data-theme='dark'] #fl-status-grid .fl-status-badge.rejected {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}

/* ===== 翻页 ===== */
#fl-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 16px;
flex-wrap: wrap;
}

#fl-pagination .fl-page-btn {
min-width: 32px;
height: 32px;
padding: 0 10px;
font-size: 13px;
border: 1px solid var(--fl-input-border);
border-radius: 6px;
background: var(--fl-input-bg);
color: var(--fl-text);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all .2s;
}

#fl-pagination .fl-page-btn:hover {
border-color: var(--fl-option-btn-border);
color: var(--fl-option-btn-border);
}

#fl-pagination .fl-page-btn.active {
background: var(--fl-option-btn-active-bg);
border-color: var(--fl-option-btn-active-bg);
color: #fff;
}

#fl-pagination .fl-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}

#fl-pagination .fl-page-dots {
color: var(--fl-text-muted);
font-size: 13px;
padding: 0 4px;
}

/* ===== 响应式 ===== */
@media (max-width: 768px) {
#fl-status-grid {
grid-template-columns: repeat(2, 1fr);
}
#fl-status-section .fl-status-header {
flex-direction: column;
align-items: flex-start;
}
#fl-status-section .fl-status-header-right {
margin-left: 0;
width: 100%;
flex-wrap: nowrap;
}
#fl-status-dropdown,
#fl-status-search-wrap {
flex: 1;
min-width: 0;
}
}

@media (max-width: 480px) {
#fl-status-grid {
grid-template-columns: 1fr;
}
}

创建友链页面

source/link/index.md(如果没有则新建 source/link/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
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
> 你可以通过**评论**或**填写表单**两种方式来申请友链或更新信息,优先推荐使用**填写表单**的方式!

{% raw %}
<link rel="stylesheet" href="/css/link.css">

<div id="fl-wrap">
<h3>申请条件</h3>
<p>请先确认满足以下条件:</p>
<label><input type="checkbox" id="fl-cb1"> 我已添加 <strong>安小歪</strong> 的友情链接</label>
<label><input type="checkbox" id="fl-cb2"> 我的网站现在可以在中国大陆区域正常访问</label>
<label><input type="checkbox" id="fl-cb3"> 网站内容符合中国大陆法律法规</label>
<label><input type="checkbox" id="fl-cb4"> 我的链接主体为<strong>个人</strong>,网站类型为<strong>博客</strong></label>
<label><input type="checkbox" id="fl-cb5"> 网站域名不是 us.kg 等免费域名(github.io、gitee.io 除外)</label>
<div class="fl-hint fl-condition-hint">⚠ 请先勾选所有条件后再填写申请表单</div>

<div id="fl-options" style="display:none">
<div class="fl-hint">请选择操作</div>
<div class="fl-option-btns">
<button class="fl-option-btn" id="fl-btn-apply">申请友链</button>
<button class="fl-option-btn" id="fl-btn-update">更新友链/信息</button>
</div>

<div class="fl-form" id="fl-form-apply">
<h3>申请友链</h3>
<form id="fl-f-apply">
<div class="fl-field">
<label class="fl-label">站点名称 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-an" required placeholder="站点名称">
</div>
<div class="fl-field">
<label class="fl-label">站点地址 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-au" type="url" required placeholder="网站地址">
</div>
<div class="fl-field">
<label class="fl-label">站点描述</label>
<input class="fl-input" id="fl-ad" placeholder="例如:一个关于技术和设计的博客">
</div>
<div class="fl-field">
<label class="fl-label">头像地址 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-aa" type="url" required placeholder="头像地址">
</div>
<div class="fl-field">
<label class="fl-label">站点截图</label>
<input class="fl-input" id="fl-as" type="url" placeholder="站点截图链接(支持siteshot和topimg字段)">
</div>
<div class="fl-field">
<label class="fl-label">邮箱 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-ae" type="email" required placeholder="联系邮箱">
</div>
<div class="fl-hint fl-sm">用于接收审核结果通知</div>
<div class="fl-err" id="fl-err-apply"></div>
<button type="submit" class="fl-btn" id="fl-sb-apply">提交</button>
</form>
</div>

<div class="fl-form" id="fl-form-update">
<h3>更新友链/信息</h3>
<form id="fl-f-update">
<div class="fl-field">
<label class="fl-label">原站点地址 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-uorig" type="url" required placeholder="原来的网站地址">
</div>
<div class="fl-update-divider">
<p>新的信息(只填需要修改的字段)</p>
</div>
<div class="fl-field">
<label class="fl-label">新站点名称 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-un" required placeholder="站点名称">
</div>
<div class="fl-field">
<label class="fl-label">新站点地址 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-uu" type="url" required placeholder="网站地址">
</div>
<div class="fl-field">
<label class="fl-label">新站点描述</label>
<input class="fl-input" id="fl-ud" placeholder="例如:一个关于技术和设计的博客">
</div>
<div class="fl-field">
<label class="fl-label">新头像地址 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-ua" type="url" required placeholder="头像地址">
</div>
<div class="fl-field">
<label class="fl-label">新站点截图</label>
<input class="fl-input" id="fl-us" type="url" placeholder="站点截图链接(支持siteshot和topimg字段)">
</div>
<div class="fl-field">
<label class="fl-label">邮箱 <span class="fl-star">*</span></label>
<input class="fl-input" id="fl-ue" type="email" required placeholder="联系邮箱">
</div>
<div class="fl-hint fl-sm">用于接收审核结果通知</div>
<div class="fl-err" id="fl-err-update"></div>
<button type="submit" class="fl-btn" id="fl-sb-update">提交</button>
</form>
</div>
</div>
</div>

<script>
var API = 'https://你的域名.vercel.app/api/submissions';
var cb1=document.getElementById('fl-cb1'),cb2=document.getElementById('fl-cb2'),cb3=document.getElementById('fl-cb3'),cb4=document.getElementById('fl-cb4'),cb5=document.getElementById('fl-cb5'),opts=document.getElementById('fl-options');

function updateOpts(){var allChecked=cb1.checked&&cb2.checked&&cb3.checked&&cb4.checked&&cb5.checked;opts.style.display=allChecked?'block':'none';document.querySelector('.fl-condition-hint').style.display=allChecked?'none':'block';document.querySelectorAll('#fl-options .fl-form').forEach(function(f){f.style.display='none'});document.querySelectorAll('.fl-option-btn').forEach(function(b){b.classList.remove('active')})}
cb1.addEventListener('change',updateOpts);cb2.addEventListener('change',updateOpts);cb3.addEventListener('change',updateOpts);cb4.addEventListener('change',updateOpts);cb5.addEventListener('change',updateOpts);

document.getElementById('fl-btn-apply').addEventListener('click',function(){document.getElementById('fl-form-apply').style.display='block';document.getElementById('fl-form-update').style.display='none';this.classList.add('active');document.getElementById('fl-btn-update').classList.remove('active')});
document.getElementById('fl-btn-update').addEventListener('click',function(){document.getElementById('fl-form-update').style.display='block';document.getElementById('fl-form-apply').style.display='none';this.classList.add('active');document.getElementById('fl-btn-apply').classList.remove('active')});

function submitForm(cbId,formId,getData){
document.getElementById(formId).querySelector('form').addEventListener('submit',function(e){
e.preventDefault();var btn=this.querySelector('.fl-btn'),err=this.querySelector('.fl-err');btn.disabled=true;btn.textContent='提交中...';err.style.display='none';
fetch(API,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getData(this))}).then(function(r){
if(!r.ok)return r.json().then(function(d){throw new Error(d.error||'提交失败')});
document.getElementById(formId).innerHTML='<div class="fl-success"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:block;margin:0 auto 16px"><polyline points="20 6 9 17 4 12"/></svg><h3>提交成功</h3><p>感谢您!友链申请已提交,等待管理员审核。<br>审核结果将通过邮件通知您。</p></div>';
}).catch(function(e){err.textContent=e.message;err.style.display='block';btn.disabled=false;btn.textContent='提交'});
});
}

submitForm('fl-form-apply','fl-form-apply',function(f){return{type:'apply',name:f.querySelector('#fl-an').value,url:f.querySelector('#fl-au').value,description:f.querySelector('#fl-ad').value,avatar:f.querySelector('#fl-aa').value,siteshot:f.querySelector('#fl-as').value,email:f.querySelector('#fl-ae').value}});
submitForm('fl-form-update','fl-form-update',function(f){return{type:'update',originalUrl:f.querySelector('#fl-uorig').value,name:f.querySelector('#fl-un').value,url:f.querySelector('#fl-uu').value,description:f.querySelector('#fl-ud').value,avatar:f.querySelector('#fl-ua').value,siteshot:f.querySelector('#fl-us').value,email:f.querySelector('#fl-ue').value}});
</script>
{% endraw %}

<div id="fl-status-section">
<div class="fl-status-header">
<div class="fl-status-title">
友链申请列表
<span class="fl-status-title-count" id="fl-status-count"></span>
</div>
<div class="fl-status-header-right">
<div class="fl-status-dropdown" id="fl-status-dropdown">
<button class="fl-status-dropdown-trigger">
<span id="fl-status-dropdown-label">全部状态</span>
<span class="fl-status-dropdown-arrow"></span>
</button>
<div class="fl-status-dropdown-menu">
<div class="fl-status-dropdown-item active" data-status="">全部状态</div>
<div class="fl-status-dropdown-item" data-status="pending">待审核</div>
<div class="fl-status-dropdown-item" data-status="approved">已通过</div>
<div class="fl-status-dropdown-item" data-status="rejected">已拒绝</div>
</div>
</div>
<div id="fl-status-search-wrap">
<span id="fl-status-search-icon">🔍</span>
<input id="fl-status-search" type="text" placeholder="搜索名称">
</div>
</div>
</div>
<div id="fl-status-grid">
<div class="fl-status-loading">加载中...</div>
</div>
<div id="fl-pagination"></div>
</div>

<script>
(function(){
var gridEl=document.getElementById('fl-status-grid');
var countEl=document.getElementById('fl-status-count');
var paginationEl=document.getElementById('fl-pagination');
var searchEl=document.getElementById('fl-status-search');
var dropdown=document.getElementById('fl-status-dropdown');
var dropdownTrigger=document.querySelector('#fl-status-dropdown .fl-status-dropdown-trigger');
var dropdownMenu=document.querySelector('#fl-status-dropdown .fl-status-dropdown-menu');
var dropdownLabel=document.getElementById('fl-status-dropdown-label');
var dropdownItems=document.querySelectorAll('#fl-status-dropdown .fl-status-dropdown-item');
var currentStatus='';
var currentPage=1;
var pageSize=12;
var searchTimer=null;

dropdownTrigger.addEventListener('click',function(e){
e.stopPropagation();
dropdown.classList.toggle('open');
});

dropdownItems.forEach(function(item){
item.addEventListener('click',function(){
dropdown.classList.remove('open');
var status=this.getAttribute('data-status');
currentStatus=status;
dropdownLabel.textContent=this.textContent;
dropdownItems.forEach(function(i){i.classList.remove('active')});
this.classList.add('active');
currentPage=1;
render();
});
});

document.addEventListener('click',function(){
dropdown.classList.remove('open');
});

searchEl.addEventListener('input',function(){
clearTimeout(searchTimer);
searchTimer=setTimeout(function(){currentPage=1;render()},200);
});

function getStatusText(s){
if(s==='pending') return '待审核';
if(s==='approved') return '已通过';
if(s==='rejected') return '已拒绝';
return '';
}

function render(){
var keyword=searchEl.value.trim().toLowerCase();
var url=API+'?public=1';
if(currentStatus) url+='&status='+currentStatus;
if(keyword) url+='&search='+encodeURIComponent(keyword);
gridEl.innerHTML='<div class="fl-status-loading">加载中...</div>';
countEl.textContent='';
paginationEl.innerHTML='';
fetch(url).then(function(r){
if(!r.ok) throw new Error('请求失败');
return r.json();
}).then(function(data){
var list=data.submissions||[];
var total=list.length;
var totalPages=Math.ceil(total/pageSize)||1;
if(currentPage>totalPages) currentPage=totalPages;
var start=(currentPage-1)*pageSize;
var pageItems=list.slice(start,start+pageSize);
if(pageItems.length===0){
gridEl.innerHTML='<div class="fl-status-empty">暂无数据</div>';
countEl.textContent='共 0 条';
return;
}
countEl.textContent='共 '+total+' 条';
var html='';
pageItems.forEach(function(item){
var statusText=getStatusText(item.status);
var typeText=item.type==='update'?'更新':'新增';
var nameText=escapeHtml(item.name);
var descText=escapeHtml(item.description||'暂无描述');
html+='<div class="fl-status-item">'+
'<div class="fl-status-top">'+
'<div class="fl-status-name" title="'+nameText+'">'+nameText+'</div>'+
'<div class="fl-status-top-right">'+
'<span class="fl-status-badge '+item.status+'">'+statusText+'</span>'+
'<span class="fl-status-type">'+typeText+'</span>'+
'</div>'+
'</div>'+
'<div class="fl-status-desc" title="'+descText+'">'+descText+'</div>'+
'</div>';
});
gridEl.innerHTML=html;
renderPagination(totalPages);
}).catch(function(){
gridEl.innerHTML='<div class="fl-status-error">加载失败,请稍后重试</div>';
});
}

function renderPagination(totalPages){
if(totalPages<=1){
paginationEl.innerHTML='';
return;
}
var prevDisabled=currentPage<=1?'disabled':'';
var nextDisabled=currentPage>=totalPages?'disabled':'';
var html='';
html+='<button class="fl-page-btn" onclick="window._goPage('+(currentPage-1)+')" '+prevDisabled+'>‹</button>';
var pages=[];
if(totalPages<=7){
for(var i=1;i<=totalPages;i++) pages.push(i);
}else{
pages.push(1);
if(currentPage>3) pages.push('...');
for(var i=Math.max(2,currentPage-1);i<=Math.min(totalPages-1,currentPage+1);i++) pages.push(i);
if(currentPage<totalPages-2) pages.push('...');
pages.push(totalPages);
}
pages.forEach(function(p){
if(p==='...'){
html+='<span class="fl-page-dots">…</span>';
}else{
html+='<button class="fl-page-btn'+(p===currentPage?' active':'')+'" onclick="window._goPage('+p+')">'+p+'</button>';
}
});
html+='<button class="fl-page-btn" onclick="window._goPage('+(currentPage+1)+')" '+nextDisabled+'>›</button>';
paginationEl.innerHTML=html;
}

window._goPage=function(page){
currentPage=page;
render();
};

function escapeHtml(str){
if(!str) return '';
var d=document.createElement('div');
d.textContent=str;
return d.innerHTML;
}

render();
})();
</script>

注意: 将代码中的 https://你的域名.vercel.app 替换为你实际部署的域名。

CSS的引入

示例引入到了source/link/index.md你也可以删除source/link/index.md中的引入在themes/butterfly/_config.yml中引入
在 Butterfly 主题配置 themes/butterfly/_config.ymlinject.head 中添加:

1
2
3
inject:
head:
- <link rel="stylesheet" href="/css/link.css">

友链 YAML 文件

将友链数据放到github仓库即可,可以自己转化为json格式来引入到source/link/index.md

1
2
3
4
5
6
7
8
- class_name: 友情链接
class_desc: 我的小伙伴们
link_list:
- name: 名称
link: 网站链接
avatar: 头像链接
descr: 描述
siteshot: 站点截图

如果配置了 GitHub 自动推送,审核通过后系统会自动追加到该文件对应的分组下。

其他嵌入方式

Iframe 方式(最简单)

1
<iframe src="https://你的域名.vercel.app/embed" width="100%" height="520" style="border:none;border-radius:8px;"></iframe>

Script 方式

1
<script src="https://你的域名.vercel.app/embed.js"></script>

自包含 HTML 方式(通用,适合任何静态站点)

直接 POST 到 API,不依赖任何框架。

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
<div id="fl-form">
<form id="fl-f">
<input id="fl-name" required placeholder="站点名称">
<input id="fl-url" type="url" required placeholder="https://example.com">
<input id="fl-desc" placeholder="站点描述">
<input id="fl-avatar" type="url" placeholder="https://example.com/avatar.png">
<input id="fl-siteshot" type="url" placeholder="https://example.com/screenshot.png">
<input id="fl-email" type="email" required placeholder="you@example.com">
<button type="submit">提交</button>
</form>
</div>
<script>
document.getElementById('fl-f').addEventListener('submit', function(e) {
e.preventDefault();
fetch('https://你的域名.vercel.app/api/submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: document.getElementById('fl-name').value,
url: document.getElementById('fl-url').value,
description: document.getElementById('fl-desc').value,
avatar: document.getElementById('fl-avatar').value,
siteshot: document.getElementById('fl-siteshot').value,
email: document.getElementById('fl-email').value,
type: 'apply',
})
});
});
</script>