部署Waline,给你的Astro博客加上Waline评论,实现说说等高级功能

部署Waline,给你的Astro博客加上Waline评论,实现说说等高级功能

前言

有将近一个月没有更新文章了,原因很简单——我一直在调整博客的样式和结构,给博客加上新功能。六月初把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,创建一个组织,接着你会进入以下页面,跟着图片一起来创建实例

  1. 点击【创建实例】
创建实例

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_HOSTHOST中的数据
TIDB_PORTPORT的数值
TIDB_DBDATABASE的数据
TIDB_USERUSERNAME的数据
TIDB_PASSWORDPASSWORD的数据

记得重新部署一次以生效

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.astroloadWalineModule().theninit处加入以下代码:

          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, '&quot;').replace(/'/g, '&#39;');
  }

  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!

部署Waline,给你的Astro博客加上Waline评论,实现说说等高级功能
/posts/astro-waline/
作者 Hexvork
发布于
更新于
许可协议 未经作者允许,禁止转载
APP工具
打赏