项目地址friendlink-verify 项目详细请见github 当前博客友链页面还没有嵌入,因为测试实例删除了,先凑合看看图片吧!
注册 MongoDB Atlas(免费数据库) 友链审核系统需要 MongoDB 数据库存储数据,推荐使用 MongoDB Atlas 免费版。
打开 mongodb.com/atlas
创建免费 MongoDB 数据库: 区域选离你(Vercel / Netlify / AWS Lambda / VPS)最近的区域(如新加坡 ap-southeast-1)也可选择 AWS / Oregon (us-west-2),该数据中心基建成熟,故障率低,且使用 Oregon 州的清洁能源,较为环保
在Database Access页面点击Add New Database User创建数据库用户,Authentication Method选Password,在Password Authentication下设置数据库用户名和密码,建议点击Auto Generate自动生成一个不含特殊符号的强壮密码并妥善保存。点击Database User Privileges下方的Add Built In Role,Select Role选择Atlas Admin,最后点击Add User
在Network Access页面点击Add IP Address添加网络白名单。因为Vercel / Netlify / Lambda的出口地址不固定,因此Access List Entry输入0.0.0.0/0(允许所有 IP 地址的连接)即可。
在Database页面点击Connect,连接方式选择Drivers,并记录数据库连接字符串,请将连接字符串中的 : 修改为刚刚创建的数据库用户名:密码
Fork 项目到你的 GitHub
打开 github.com/shangskr/friendlink-verify
点击右上角 Fork → 创建到你自己的账号下
创建 GitHub Token(可选,用于审核后自动推送友链到仓库) 如果需要审核通过后自动更新 GitHub 仓库里的友链文件,则需要创建 Token。
访问 GitHub Settings → Developer settings → Personal access tokens → Tokens (classic)
点击 Generate new token
Token name 填 friendlink-verify
Expiration 建议选 No expiration
Select scopes 选 repo
点击 Generate token → 复制 token (关掉页面后就看不到了)
项目是通过写入yml文件然后我自己通过拉取json获取的友链数据。
部署到 Vercel
打开 vercel.com → 用 GitHub 账号登录
点击 Add New → Project
在 Import Git Repository 中找到刚才 Fork 的项目,点击 Import
在 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 限制),建议绑定自定义域名或在部署后先申请一个自己的域名配置上去。
点击 Deploy ,等待部署完成(约 1-2 分钟)
部署成功后 Vercel 会显示域名,访问 https://你的域名.vercel.app/admin 进入管理后台
配置 SMTP 邮件(以 163 邮箱为例) 如果需要邮件通知功能,需要配置 SMTP。
登录 163 邮箱 → 设置 → POP3/SMTP/IMAP
开启 SMTP 服务 ,按提示发送短信获取授权码
在 Vercel 项目设置中添加环境变量:
SMTP_SERVER → smtp.163.com
SMTP_PORT → 465
EMAIL_USER → yourname@163.com
EMAIL_PASS → 刚才获取的授权码
EMAIL_NAME → 显示名称,如 安小歪
EMAIL_RECIPIENT → 管理员接收通知的邮箱
保存后重新部署
管理后台使用 访问 https://你的域名.vercel.app/admin,用前面设置的 ADMIN_USERNAME 和 ADMIN_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 主题 创建 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 #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 : #363636 ; --fl-btn-hover : #555 ; --fl-btn-disabled : #bbb ; --fl-success-h3 : #363636 ; 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 : #65b0ff ; --fl-btn-hover : #4a9fe8 ; --fl-btn-disabled : #555 ; --fl-success-h3 : #eee ; } #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 : #65b0ff ; 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-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 : #65b0ff ; box-shadow : 0 0 0 2px rgba (101 , 176 , 255 , .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-update-divider { border-top : 1px solid var (--fl-border); margin : 16px 0 ; padding-top : 16px ; } #fl-wrap .fl-update-divider p { margin : 0 0 12px ; font-size : 13px ; color : var (--fl-text-secondary); font-weight : 500 ; }
创建友链页面 在 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 {% raw %} <link rel ="stylesheet" href ="/css/link.css" > <div id ="fl-wrap" > <h3 > 申请条件</h3 > <p > 请先确认满足以下条件:</p > <label > <input type ="checkbox" id ="fl-cb1" > 我已添加安小歪博客的友情链接</label > <label > <input type ="checkbox" id ="fl-cb2" > 我的网站现在可以在中国大陆区域正常访问</label > <label > <input type ="checkbox" id ="fl-cb3" > 网站内容符合中国大陆法律法规</label > <div id ="fl-options" style ="display:none" > <div class ="fl-hint" > 请选择操作</div > <label > <input type ="checkbox" id ="fl-cb-apply" > 申请友链</label > <label > <input type ="checkbox" id ="fl-cb-update" > 更新友链</label > <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 ="https://example.com" > </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" > 头像地址</label > <input class ="fl-input" id ="fl-aa" type ="url" placeholder ="https://example.com/avatar.png" > </div > <div class ="fl-field" > <label class ="fl-label" > 站点截图</label > <input class ="fl-input" id ="fl-as" type ="url" placeholder ="https://example.com/screenshot.png" > </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 ="you@example.com" > </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 ="https://原来的地址.com" > </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 ="https://example.com" > </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" > 新头像地址</label > <input class ="fl-input" id ="fl-ua" type ="url" placeholder ="https://example.com/avatar.png" > </div > <div class ="fl-field" > <label class ="fl-label" > 新站点截图</label > <input class ="fl-input" id ="fl-us" type ="url" placeholder ="https://example.com/screenshot.png" > </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 ="you@example.com" > </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' ),opts=document .getElementById ('fl-options' );function updateOpts ( ){opts.style .display =(cb1.checked &&cb2.checked &&cb3.checked )?'block' :'none' ;opts.querySelectorAll ('#fl-options input[type="checkbox"]' ).forEach (function (c ){c.checked =false });document .querySelectorAll ('#fl-options .fl-form' ).forEach (function (f ){f.style .display ='none' })}cb1.addEventListener ('change' ,updateOpts);cb2.addEventListener ('change' ,updateOpts);cb3.addEventListener ('change' ,updateOpts); document .getElementById ('fl-cb-apply' ).addEventListener ('change' ,function ( ){document .getElementById ('fl-form-apply' ).style .display =this .checked ?'block' :'none' ;if (this .checked )document .getElementById ('fl-cb-update' ).checked =false ,document .getElementById ('fl-form-update' ).style .display ='none' });document .getElementById ('fl-cb-update' ).addEventListener ('change' ,function ( ){document .getElementById ('fl-form-update' ).style .display =this .checked ?'block' :'none' ;if (this .checked )document .getElementById ('fl-cb-apply' ).checked =false ,document .getElementById ('fl-form-apply' ).style .display ='none' });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-cb-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-cb-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 %}
注意: 将代码中的 https://你的域名.vercel.app 替换为你实际部署的域名。
CSS的引入 示例引入到了source/link/index.md你也可以删除source/link/index.md中的引入在themes/butterfly/_config.yml中引入 在 Butterfly 主题配置 themes/butterfly/_config.yml 的 inject.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 >