前言
有将近一个月没有更新文章了,原因很简单——我一直在调整博客的样式和结构,给博客加上新功能。六月初把follow.it的邮箱订阅和RSS订阅的页面加上了,等我后头忙完这阵在详细说说怎么接入follow.it(给自己挖坑+1)。这个月在洪哥和Liushen的分享下找到一家中转站——鱼鱼中转,价格非常低廉,就是时不时plus会不可用,我用他提供的GPT 5.5深度修改了我的博客,使它看起来更高级、富有科技感、增加亲切感,不得不说GPT5.5审美一直是在线的,渐变色彩看得我非常舒服,还通过<object>嵌套来实现图片永久访问。本来想问问其他伙伴们有没有适用于Waline说说的开源的插件,结果得到的答复是——没有,得自己从零开始写,一顿摸爬滚打 +恐吓AI ,终于实现了。月底一抬头看日期,玛雅,我居然整整一个月没更新!诶,正好把接 Waline 的方法分享出来水篇文章,于是就有了这篇——Astro 接入 Waline 并实现说说。
一、部署Waline
这里演示·最简单的部署方式,通过Vercel+TiDB部署Waline,我就是通过这么一种简便又快捷的方式部署的。
1.部署前端
点击 部署到 Vercel 即可。
全程你可能需要登录Vercel账号、绑定Github,按操作一步一步走就好了。如果点击部署后没有报错,恭喜你,Waline的前端部署已完成。
但是,Vercel默认分配的域名被墙了,你需要绑定自己的域名,具体操作如下:



2.部署后端
现在来到后端部分,登录 TiDB Cloud
登录或注册,一路Skip,创建一个组织,接着你会进入以下页面,跟着图片一起来创建实例
- 点击【创建实例】

2.选择实例
选择Starter免费计划,给实例取个名字(如:Waline),选择服务商这里建议选择Alibaba Cloud,地区选离中国近的,再点击创建,如图所示:

创建好之后等几分钟,TiDB正在搭建
3.初始化后端
首先我们先来认识和熟悉一下页面

在代码区依次粘贴以下代码,依次运行,复制之后点RUN:
CREATE DATABASE `waline`
USE waline
CREATE TABLE `wl_Comment` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int DEFAULT NULL,
`comment` text,
`insertedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`ip` varchar(100) DEFAULT '',
`link` varchar(255) DEFAULT NULL,
`mail` varchar(255) DEFAULT NULL,
`nick` varchar(255) DEFAULT NULL,
`pid` int DEFAULT NULL,
`rid` int DEFAULT NULL,
`sticky` boolean DEFAULT NULL,
`status` varchar(50) NOT NULL DEFAULT '',
`like` int DEFAULT NULL,
`ua` text,
`url` varchar(255) DEFAULT NULL,
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) CHARSET=utf8mb4
CREATE TABLE `wl_Counter` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`time` int DEFAULT NULL,
`reaction0` int DEFAULT NULL,
`reaction1` int DEFAULT NULL,
`reaction2` int DEFAULT NULL,
`reaction3` int DEFAULT NULL,
`reaction4` int DEFAULT NULL,
`reaction5` int DEFAULT NULL,
`reaction6` int DEFAULT NULL,
`reaction7` int DEFAULT NULL,
`reaction8` int DEFAULT NULL,
`url` varchar(255) NOT NULL DEFAULT '',
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) CHARSET=utf8mb4
CREATE TABLE `wl_Users` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`display_name` varchar(255) NOT NULL DEFAULT '',
`email` varchar(255) NOT NULL DEFAULT '',
`password` varchar(255) NOT NULL DEFAULT '',
`type` varchar(50) NOT NULL DEFAULT '',
`label` varchar(255) DEFAULT NULL,
`url` varchar(255) DEFAULT NULL,
`avatar` varchar(255) DEFAULT NULL,
`github` varchar(255) DEFAULT NULL,
`twitter` varchar(255) DEFAULT NULL,
`facebook` varchar(255) DEFAULT NULL,
`google` varchar(255) DEFAULT NULL,
`weibo` varchar(255) DEFAULT NULL,
`qq` varchar(255) DEFAULT NULL,
`oidc` varchar(255) DEFAULT NULL,
`2fa` varchar(32) DEFAULT NULL,
`createdAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) CHARSET=utf8mb4
4.前后端连接
运行完以后,点击左侧的 Overview 按钮进入首页,选择右上角的 Connect 获取连接信息。
Connect with 选择 General。此外还需要点击下一行的 Reset password 生成下新密码,再把信息粘贴到TXT中,如图:

把信息依次按照下表填入Vercel的变量(Environment Variable)
| Vercel变量 | TiDB对应数值 |
|---|---|
| TIDB_HOST | HOST中的数据 |
| TIDB_PORT | PORT的数值 |
| TIDB_DB | DATABASE的数据 |
| TIDB_USER | USERNAME的数据 |
| TIDB_PASSWORD | PASSWORD的数据 |
记得重新部署一次以生效
5.管理员注册
访问 https://你的域名/ui/register 注册成为管理员
二、Astro引用
1.基本使用
在 \src\components 创建 waline.astro
---
interface Props {
path?: string;
}
const serverURL = 'https://你的waline域名';
const pagePath = Astro.props.path ?? null;
---
<div
class="waline hex-waline"
data-waline-comment
data-server-url={serverURL}
{...(pagePath ? { 'data-page-path': pagePath } : {})}
></div>
<script is:inline type="module">
const WALINE_CLIENT_URL = 'https://esm.sh/@waline/client@3';
if (!window.__hexvorkWalineSetup) {
window.__hexvorkWalineModule = null;
function loadWalineModule() {
window.__hexvorkWalineModule ||= import(WALINE_CLIENT_URL);
return window.__hexvorkWalineModule;
}
function loadWaline(container) {
if (container.dataset.loaded === 'true') return;
const serverURL = container.dataset.serverUrl;
if (!serverURL) return;
container.dataset.loaded = 'true';
loadWalineModule().then(({ init }) => {
init({
el: container,
serverURL,
path: container.dataset.pagePath || undefined,
lang: 'zh-CN',
dark: 'html.dark',
emoji: [
'https://esm.sh/@waline/emojis@1.2.0/weibo',
'https://esm.sh/@waline/emojis@1.2.0/bilibili',
'https://esm.sh/@waline/emojis@1.2.0/qq',
],
});
}).catch(() => {
container.dataset.loaded = 'false';
});
}
window.__hexvorkWalineSetup = function setupWaline() {
document.querySelectorAll('[data-waline-comment]').forEach((container) => {
if (!(container instanceof HTMLElement)) return;
if (container.dataset.loaded === 'true' || container.dataset.observed === 'true') return;
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadWaline(container);
observer.disconnect();
}
}, { rootMargin: '300px 0px' });
container.dataset.observed = 'true';
observer.observe(container);
} else {
loadWaline(container);
}
});
};
document.addEventListener('astro:page-load', window.__hexvorkWalineSetup);
document.addEventListener('waline:setup', window.__hexvorkWalineSetup);
}
window.__hexvorkWalineSetup();
</script>
你只需要改动上述文件的serverURL即可,然后再你博客生成页面加入以下代码
文件最前导入
import Waline from '../components/Waline.astro';
文件末尾引用
</Waline>
2.进阶调整
这里给一个最实用的——自定义反应
在Waline.astro的loadWalineModule().then的init处加入以下代码:
reaction: [
'https://unpkg.com/@waline/emojis@1.1.0/weibo/weibo_heart_eyes.png',
'https://unpkg.com/@waline/emojis@1.1.0/weibo/weibo_dog_joy.png',
'https://unpkg.com/@waline/emojis@1.1.0/weibo/weibo_dog_consider.png',
'https://unpkg.com/@waline/emojis@1.1.0/weibo/weibo_sob.png',
],
图片你可以替换成你的
其他的功能可以去 Waline进阶功能 让AI帮你们加入
3.Astro-Waline 说说
在/src/pages/下创建shuoshuo.astro
---
import MainLayout from '../layouts/MainLayout.astro';
import Waline from '../components/Waline.astro';
---
<MainLayout title="说说">
<div id="ss-root" class="ss-root">
<div class="ss-head">
<div class="ss-head-left">
<div class="ss-head-icon"><i class="fa-solid fa-feather-pointed"></i></div>
<div>
<h1 class="ss-title">说说</h1>
<p class="ss-desc">碎碎念 · 随手记 · 日常灵感</p>
</div>
</div>
<div class="ss-controls" id="ss-controls">
<button class="ss-ctrl-btn" data-ctrl="avatar" title="头像">
<i class="fa-solid fa-circle-user"></i>
</button>
<button class="ss-ctrl-btn" data-ctrl="device" title="设备">
<i class="fa-solid fa-mobile-screen"></i>
</button>
<button class="ss-ctrl-btn" data-ctrl="location" title="位置">
<i class="fa-solid fa-location-dot"></i>
</button>
</div>
</div>
<div id="ss-list" class="ss-list">
<div class="ss-loading">
<div class="ss-loading-dot"></div>
<span>加载说说中…</span>
</div>
</div>
<div id="ss-more" class="ss-more" style="display:none">
<button id="ss-load-btn" class="ss-load-btn">加载更多</button>
</div>
<div class="ss-divider"><span>引用一条说说 · 写下你的想法</span></div>
<div id="ss-waline" class="ss-waline-section">
<Waline path="/shuoshuo" />
</div>
</div>
</MainLayout>
<script is:inline>
const SS_CONFIG = {
serverURL: 'https://你waline的链接',
adminEmail: 'waline内你的邮箱',
adminNicks: ['你的名称'],
pagePath: '/shuoshuo',
pageSize: 30,
pollInterval: 15000,
defaults: { avatar: true, device: true, location: true },
};
(function() {
const C = SS_CONFIG;
const listEl = document.getElementById('ss-list');
const moreEl = document.getElementById('ss-more');
const loadBtn = document.getElementById('ss-load-btn');
const root = document.getElementById('ss-root');
const controls = document.getElementById('ss-controls');
const settings = { ...C.defaults };
const stored = (function() { try { return JSON.parse(localStorage.getItem('ss-settings')); } catch(_) { return null; } })();
if (stored) Object.assign(settings, stored);
Object.keys(settings).forEach(function(k) {
var btn = controls.querySelector('[data-ctrl="' + k + '"]');
if (btn) btn.classList.toggle('active', settings[k]);
});
controls.addEventListener('click', function(e) {
var btn = e.target.closest('[data-ctrl]');
if (!btn) return;
var key = btn.dataset.ctrl;
settings[key] = !settings[key];
btn.classList.toggle('active', settings[key]);
localStorage.setItem('ss-settings', JSON.stringify(settings));
root.classList.toggle('ss-hide-avatar', !settings.avatar);
root.classList.toggle('ss-hide-device', !settings.device);
root.classList.toggle('ss-hide-location', !settings.location);
});
if (!settings.avatar) root.classList.add('ss-hide-avatar');
if (!settings.device) root.classList.add('ss-hide-device');
if (!settings.location) root.classList.add('ss-hide-location');
let currentPage = 1;
let hasMore = false;
let pollTimer = null;
const knownIds = new Set();
function formatDate(d) {
var t = new Date(d);
return t.getFullYear() + '-' +
String(t.getMonth()+1).padStart(2,'0') + '-' +
String(t.getDate()).padStart(2,'0') + ' ' +
String(t.getHours()).padStart(2,'0') + ':' +
String(t.getMinutes()).padStart(2,'0');
}
function esc(str) {
var div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
function attr(str) {
return esc(str).replace(/"/g, '"').replace(/'/g, ''');
}
function parseUA(ua) {
if (!ua) return null;
if (/Android/i.test(ua)) return 'Android';
if (/iPhone|iPad|iPod/i.test(ua)) return 'iOS';
if (/Windows/i.test(ua)) return 'Windows';
if (/Mac/i.test(ua)) return 'macOS';
if (/Linux/i.test(ua)) return 'Linux';
return null;
}
function formatText(text) {
var html = esc(text);
html = html.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener">$1</a>');
html = html.replace(/\n/g, '<br>');
return html;
}
function safeUrl(value) {
try {
var url = new URL(value, window.location.origin);
return ['http:', 'https:', 'data:'].indexOf(url.protocol) !== -1;
} catch (_) { return false; }
}
function sanitizeCommentHtml(html) {
var box = document.createElement('div');
box.innerHTML = html || '';
box.querySelectorAll('script, style, iframe, object, embed, form, input, button, textarea, select').forEach(function(el) { el.remove(); });
box.querySelectorAll('*').forEach(function(el) {
Array.from(el.attributes).forEach(function(attr) {
var name = attr.name.toLowerCase();
var value = attr.value;
if (name.indexOf('on') === 0 || name === 'style') { el.removeAttribute(attr.name); return; }
if ((name === 'href' || name === 'src') && !safeUrl(value)) { el.removeAttribute(attr.name); }
});
if (el.tagName === 'A') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
if (el.tagName === 'IMG') { el.setAttribute('loading', 'lazy'); el.setAttribute('decoding', 'async'); }
});
return box.innerHTML;
}
function plainComment(c) {
if (c.orig) return c.orig;
var div = document.createElement('div');
div.innerHTML = c.comment || '';
return (div.textContent || div.innerText || '').trim();
}
function commentCount(c) {
if (Array.isArray(c.children)) return c.children.length;
if (typeof c.childrenCount === 'number') return c.childrenCount;
if (typeof c.commentCount === 'number') return c.commentCount;
return 0;
}
function quoteText(text) {
var cleaned = String(text || '').replace(/\s+/g, ' ').trim();
return cleaned.length > 160 ? cleaned.slice(0, 157) + '...' : cleaned;
}
function isAdmin(c) {
if (c.status && c.status !== 'approved') return false;
var nick = String(c.nick || '').trim().toLowerCase();
var adminNicks = (C.adminNicks || []).map(function(n) { return String(n).trim().toLowerCase(); });
return (c.mail && c.mail === C.adminEmail)
|| (nick && adminNicks.indexOf(nick) !== -1)
|| (c.label && /Admin|administrator|博主|管理/i.test(c.label))
|| (c.type && /Admin|administrator|博主|管理/i.test(c.type))
|| (c.role && /Admin|administrator|博主|管理/i.test(c.role))
|| (c.sticky && c.sticky > 0);
}
function matchPage(c) {
var u = String(c.url || c.path || '').replace(/\/$/,'');
if (u.indexOf('/shuoshuo') !== -1) return true;
return false;
}
function renderCard(c) {
var dev = c.os || parseUA(c.ua);
var browser = c.browser || '';
var text = plainComment(c);
var count = commentCount(c);
var displayTime = formatDate(c.insertedAt || c.createdAt);
var nick = c.nick || '博主';
var addr = c.addr || c.city || c.region || '';
return (
'<article class="ss-card" data-id="' + esc(c.objectId) + '">' +
'<div class="ss-card-head">' +
'<div class="ss-avatar">' +
'<img src="' + esc(c.avatar || '') + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">' +
'</div>' +
'<div class="ss-author">' +
'<div class="ss-nick">' + esc(nick) + '</div>' +
'<time class="ss-time" title="' + displayTime + '">' + displayTime + '</time>' +
'</div>' +
'</div>' +
'<div class="ss-content">' + sanitizeCommentHtml(c.comment || formatText(text)) + '</div>' +
'<div class="ss-foot">' +
'<div class="ss-tags">' +
(addr ? '<span class="ss-badge ss-location"># ' + esc(addr) + '</span>' : '') +
(browser ? '<span class="ss-device"># ' + esc(browser) + '</span>' : '') +
(dev ? '<span class="ss-device"># ' + esc(dev) + '</span>' : '') +
'</div>' +
'<button class="ss-comment-btn" type="button" title="引用并评论" aria-label="引用并评论这条说说" ' +
'data-quote-id="' + attr(c.objectId) + '" ' +
'data-quote-nick="' + attr(nick) + '" ' +
'data-quote-time="' + attr(displayTime) + '" ' +
'data-quote-text="' + attr(quoteText(text)) + '">' +
'<i class="fa-solid fa-comment-dots"></i>' +
(count ? '<span class="ss-comment-count">' + count + '</span>' : '') +
'</button>' +
'</div>' +
'</article>'
);
}
function getWalineEditor() {
return document.querySelector('#ss-waline .wl-editor');
}
function insertIntoEditor(editor, text) {
if (!editor) return;
if ('value' in editor) {
var value = editor.value || '';
var start = editor.selectionStart || value.length;
var end = editor.selectionEnd || value.length;
var prefix = value && !value.endsWith('\n') ? '\n\n' : '';
editor.value = value.slice(0, start) + prefix + text + value.slice(end);
editor.selectionStart = editor.selectionEnd = start + prefix.length + text.length;
} else {
editor.textContent = (editor.textContent || '') + text;
}
editor.dispatchEvent(new Event('input', { bubbles: true }));
editor.focus();
}
function waitForWalineEditor(deadline) {
return new Promise(function(resolve) {
var started = Date.now();
function tick() {
var editor = getWalineEditor();
if (editor) return resolve(editor);
if (Date.now() - started > deadline) return resolve(null);
window.requestAnimationFrame(tick);
}
tick();
});
}
async function quoteAndComment(btn) {
var waline = document.getElementById('ss-waline');
var nick = btn.dataset.quoteNick || '博主';
var time = btn.dataset.quoteTime || '';
var text = btn.dataset.quoteText || '';
var quote = '> @' + nick + (time ? ' · ' + time : '') + '\n' +
text.split('\n').map(function(line) { return '> ' + line; }).join('\n') + '\n\n';
document.dispatchEvent(new CustomEvent('waline:setup'));
if (waline) waline.scrollIntoView({ behavior: 'smooth', block: 'start' });
var editor = getWalineEditor() || await waitForWalineEditor(4500);
insertIntoEditor(editor, quote);
}
listEl.addEventListener('click', function(e) {
var btn = e.target.closest('.ss-comment-btn');
if (!btn) return;
quoteAndComment(btn);
});
function normalizeList(data) {
if (Array.isArray(data)) return data;
if (Array.isArray(data && data.data)) return data.data;
if (Array.isArray(data && data.data && data.data.data)) return data.data.data;
if (Array.isArray(data && data.comment)) return data.comment;
if (Array.isArray(data && data.comments)) return data.comments;
return [];
}
async function requestComments(url) {
var r = await fetch(url);
if (!r.ok) return [];
return normalizeList(await r.json());
}
async function fetchPage(page) {
try {
var pathUrl = C.serverURL + '/comment?path=' + encodeURIComponent(C.pagePath) + '&pageSize=' + C.pageSize + '&page=' + page;
var comments = await requestComments(pathUrl);
var fromPath = comments.length > 0;
if (!comments.length) {
var recentUrl = C.serverURL + '/comment?type=recent&pageSize=' + C.pageSize + '&page=' + page;
comments = await requestComments(recentUrl);
}
return comments.filter(function(c) {
return isAdmin(c) && (fromPath || matchPage(c)) && (!c.rid || c.rid === '0');
}).sort(function(a, b) {
return new Date(b.insertedAt || b.createdAt || 0) - new Date(a.insertedAt || a.createdAt || 0);
});
} catch(e) {
console.error('说说获取失败:', e);
return [];
}
}
function prependCard(c) {
if (listEl.querySelector('.ss-empty')) listEl.innerHTML = '';
var tpl = document.createElement('div');
tpl.innerHTML = renderCard(c);
var card = tpl.firstElementChild;
var first = listEl.querySelector('.ss-card');
if (first) listEl.insertBefore(card, first);
else listEl.appendChild(card);
}
async function loadPage(page) {
var comments = await fetchPage(page);
hasMore = comments.length >= C.pageSize;
if (page === 1) { listEl.innerHTML = ''; knownIds.clear(); }
if (!comments.length && page === 1) {
listEl.innerHTML = '<div class="ss-empty"><div class="ss-empty-icon"><i class="fa-solid fa-feather"></i></div><p>还没有说说~</p></div>';
moreEl.style.display = 'none';
startPolling();
return;
}
for (var i = 0; i < comments.length; i++) {
knownIds.add(comments[i].objectId);
listEl.insertAdjacentHTML('beforeend', renderCard(comments[i]));
}
moreEl.style.display = hasMore ? 'block' : 'none';
if (page === 1) startPolling();
}
async function poll() {
try {
var fresh = await fetchPage(1);
for (var i = fresh.length - 1; i >= 0; i--) {
var id = fresh[i].objectId;
if (!id || knownIds.has(id)) continue;
knownIds.add(id);
prependCard(fresh[i]);
}
} catch(_) {}
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(poll, C.pollInterval);
}
loadBtn && loadBtn.addEventListener('click', function() {
currentPage++;
loadBtn.textContent = '加载中…';
loadBtn.disabled = true;
loadPage(currentPage).then(function() {
loadBtn.textContent = '加载更多';
loadBtn.disabled = false;
});
});
loadPage(1);
})();
</script>
可以找AI微调一下,注意修改 serverURL adminEmail adminNicks,替换成你的信息
结语
呼,总算写完了,如释重负。本来只想简单分享一下的,没想到写这么长、这么累,希望这么详细能帮助到你。如果你看不懂也没关系,发给AI让它加就行了。这里推荐一位站长整理的系列—— uuanqin的进阶教程 ,这里就不再赘述了。事已至此,先吃饭吧,Happy Coding!

