跳转到内容

从Moments迁移到Ech0

更新于: 2025-11-04
LiuShen
8 分钟
3,057 字
PV --
UV --

这里是清羽AI,这篇文章讲述了作者从Moments迁移到Ech0的过程。作者在上班一个月后,感觉身心俱疲,但仍顺利完成第一个月的答辩。原本对简约界面的Ech0持观望态度,但随着版本更新功能完善,作者最终决定迁移过来,并结合PWA技术实现手机App般的体验。文章还介绍了部署Ech0的要求和教程,包括Docker部署和应用商店安装方法,以及前端魔改的详细步骤,使说说页面更加丰富和美观。作者期待未来Ech0能增加多标签功能,以便更好地记录生活。

碎碎念

更新了注意事项

应朋友Ljx的要求,我更新了文章,本来是不想更新的,不过,我这人,善!

上班1个月!感觉灵魂都被抽干了,已经彻底成为合格牛马的形状了。每天睁眼就是工作,闭眼就是盼着周末,结果真到了周末,可能还得面对加班的召唤。这谁顶得住,总之就是累累的,困困的,呜呜呜……

前些天的月度答辩,那叫一个紧张,PPT还没讲,我人都要裂开了。脑子里预演了一万遍被怼到怀疑人生的场景。索性我们组的哥姐们都超nice,没有为难我这个新人,知道我第一个月基本属于啥也不会的废物状态。后面跟着做了一些业务,刚开始上手确实感觉有点难度,但沉下心来搞,发现其实也并没有想象中的那么复杂。总之,第一个月总算是有惊无险地正常度过啦!

回到正题,之前朋友写了个程序叫Ech0。一开始因为他的设计理念,界面做得特别简约,甚至从程序底层就限制了一些花里胡哨的操作(没错,说的就是想搞事的我)。所以当时测试了一个周,感觉不太符合我的折腾欲,就选择了暂时观望。后来嘛,忙入职、忙培训,这事儿就暂时搁置了。结果前阵子偶然逛到他博客,发现Ech0更新了好几个版本,加的新功能简直正中我的下怀!再加上它那一直很戳我的美观界面,我当场就心动了,于是稍微捣鼓了一下,火速迁移过来啦!更棒的是,结合PWA技术,现在能直接像手机App一样发说说了!这体验感,绝了!很不错!超喜欢!

现在就盼着作者大大后面能加上多标签功能了!到时候我想把标签整成定位,比如#摸鱼#发呆之类的,人主打一个花里胡哨,记录生活嘛!

本站链接 · 来自本站,本站可确保其安全性,请放心点击跳转清羽飞扬の提笔摘星LiuShens' Blog

介绍

刚开始,我其实用的是Memos。该程序设计完善,功能花里胡哨,资源占用还低,所以吸引了大量用户,生态也相当全面。不过嘛,作者更新实在太频繁了,导致API时不时就不兼容,我那套定制的前端维护起来就有点麻烦了,具体原因可以看我之前写的这篇文章:

本站链接 · 来自本站,本站可确保其安全性,请放心点击跳转从Memos转移到MomentsLiuShen's Blog

后来我换到了Moments,这绝对是个非常优秀的程序,功能和设计上都完全满足我的要求。但美中不足的是,它没有适配PWA。当然,这不算什么大问题,纯粹是我的个人需求。正巧那段时间,有个朋友在开发他的新项目Ech0,我一眼就被它那简约又美观的界面给吸引了,立马上手体验了一段时间。不过那时候的Ech0还比较早期,功能上不太全面:没有标签系统,图片不能上传到S3,也没有我喜欢的Meting音乐插件,甚至连视频分享都没有。这对于追求花里胡哨的我来说,肯定是不够用的,所以当时就没切换。

再后来,朋友很给力,Ech0项目逐渐完善,之前缺少的功能一个个都补上了。我再次体验的时候,发现已经十分好用,于是就果断迁移了过来,还顺手把我之前那套前端给适配上了。目前使用很爽,下面是几个程序的对比:

横向滚动
特性MemosMomentsEch0
PWA 适配✅ 支持❌ 不支持✅ 支持
标签系统✅ 完善✅ 完善✅ 支持
S3 图床✅ 支持✅ 支持✅ 支持
Meting 音乐❌ 不支持✅ 支持✅ 支持
视频分享✅ 支持✅ 支持✅ 支持
设计风格功能导向,略显臃肿功能全面,类似后台简约美观,轻量
开发活跃度非常活跃,API易变相对稳定活跃,快速迭代
个人评价功能强大,但更新太折腾,生态不稳定。功能完美,但没PWA对我是减分项界面戳我,功能追上来了,PWA是加分项,目前的最爱

话不多说,开始教程!

教程

部署要求

硬件要求

  • 一台服务器
  • 一个可自主解析的域名
  • 一个已经部署好的类ButterflyHexo博客

软件要求

  • docker环境
  • 反向代理工具(本文以Nginx为例)

效果展示

  1. 说说页面:

    本站链接 · 来自本站,本站可确保其安全性,请放心点击跳转清羽飞扬の日常说说LiuShen's Blog

    由于前端才是最主要的展示区域,在这里我尽可能做了最多的适配,适配了图片分享,链接分享,音乐分享,Bilibili分享,Youtube分享,黑夜模式等多种适配,这里仅展示部分功能,yoputube由于拉低网络加载速度,不予展示。其他的具体效果可以上网站我的说说页面自行查看。

    说说效果展示

    亮色模式

    暗色模式

    音乐分享

    图片分享

    链接分享

  2. Ech0

    本站链接 · 来自本站,本站可确保其安全性,请放心点击跳转清羽飞扬の提笔摘星LiuShen's Blog

    这里也没什么过多可以展示的,因为网站功能太多,无法一一展示,建议大家直接进入网站自行查看。

    Ech0

程序部署

程序的Github地址如下:

站外引用 · 引用站外地址,不保证站点的可用性和安全性Ech0 - 面向个人的新一代开源、自托管、专注思想流动的轻量级联邦发布平台github.com@lin-snow

下面介绍两种主要部署方式,分别为Docker,应用商店。

Docker

这里我推荐使用docker-compose,原生docker不太方便,不太好记录挂载目录等,官方给出了详细的文档,以下为compose文件:

services:
  ech0:
    image: sn0wl1n/ech0:latest
    container_name: ${容器名称
    environment:
      - JWT_SECRET=${随便写}
    ports:
      - "${PANEL_APP_PORT_HTTP}:6277"
    volumes:
      - ./data/ech0-data:/app/data
      - ./data/backup:/app/backup
    restart: always

将以上内容写入任意文件内,命名为docker-compose.yml,在当前目录下执行:

docker compose up -d

如果不出意外,容器已经启动,自行实现反向代理即可。

应用商店

如果你为1Panel用户,可以选择直接通过我个人维护的第三方应用商店实现安装,

在以前的文章曾介绍了如何添加三方应用商店,这里就不重复了,首先按照以下教程安装三方应用商店:

本站链接 · 来自本站,本站可确保其安全性,请放心点击跳转不同姿势部署Anheyu-App应用LiuShen's Blog

安装好后,在应用商店直接搜索安装即可,更加快捷,更好维护!

前端魔改

说说页面

由于该项目利用了MetingJSAPlayer,所以请提前引入这两个包,Hexo-theme-butterfly中虽然有内置的两个包,仅需修改配置文件即可开启,但是版本比较老,这里我建议自行引入最新版本,在配置中引入以下文件,注意cssjs应该是分开引入的,在主题hexo-theme-butterfly中,仅需要修改配置文件即可。

<link
  rel="stylesheet"
  href="https://fastly.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.css"
  media="all"
  onload='this.media="all"'
/>
<script src="https://fastly.jsdelivr.net/npm/aplayer@1.10.1/dist/APlayer.min.js"></script>
<script src="https://fastly.jsdelivr.net/npm/meting@2.0.1/dist/Meting.min.js"></script>

新建页面shuoshuo,在文件内写入以下内容:

---
title: 日常哔哔,键盘侠的日常吐槽
aside: false
---

<div id="talk"></div>
<div class="limit">- 只展示最近30条说说 -</div>
<script src="/js/shuoshuo.js" no-pjax></script>

其中的JS文件地址请自行修改,自行创建,并写入以下内容:

(function () {
  const TALK_API_URL = "https://mm.liushen.fun/api/echo/page";
  const TALK_CACHE_KEY = "liushenEchoCacheV2";
  const TALK_CACHE_TIME_KEY = "liushenEchoCacheTimeV2";
  const TALK_CACHE_DURATION = 30 * 60 * 1000;
  const TALK_AVATAR = "https://p.liiiu.cn/i/2025/03/13/67d2fc82d329c.webp";
  const shuoshuoState =
    window.__liushenShuoshuoState ||
    (window.__liushenShuoshuoState = {
      resizeHandler: null,
      afterRenderTimer: null,
      listenersBound: false,
    });

  function cleanupShuoshuo() {
    if (shuoshuoState.afterRenderTimer) {
      window.clearTimeout(shuoshuoState.afterRenderTimer);
      shuoshuoState.afterRenderTimer = null;
    }

    if (shuoshuoState.resizeHandler) {
      window.removeEventListener("resize", shuoshuoState.resizeHandler);
      shuoshuoState.resizeHandler = null;
    }
  }

  function renderTalks() {
    cleanupShuoshuo();

    const talkContainer = document.querySelector("#talk");
    if (!talkContainer) return;

    talkContainer.innerHTML = "";

    const generateIconSVG = () => {
      return '<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" class="is-badge icon"><path d="m512 268c0 17.9-4.3 34.5-12.9 49.7s-20.1 27.1-34.6 35.4c.4 2.7.6 6.9.6 12.6 0 27.1-9.1 50.1-27.1 69.1-18.1 19.1-39.9 28.6-65.4 28.6-11.4 0-22.3-2.1-32.6-6.3-8 16.4-19.5 29.6-34.6 39.7-15 10.2-31.5 15.2-49.4 15.2-18.3 0-34.9-4.9-49.7-14.9-14.9-9.9-26.3-23.2-34.3-40-10.3 4.2-21.1 6.3-32.6 6.3-25.5 0-47.4-9.5-65.7-28.6-18.3-19-27.4-42.1-27.4-69.1 0-3 .4-7.2 1.1-12.6-14.5-8.4-26-20.2-34.6-35.4-8.5-15.2-12.8-31.8-12.8-49.7 0-19 4.8-36.5 14.3-52.3s22.3-27.5 38.3-35.1c-4.2-11.4-6.3-22.9-6.3-34.3 0-27 9.1-50.1 27.4-69.1s40.2-28.6 65.7-28.6c11.4 0 22.3 2.1 32.6 6.3 8-16.4 19.5-29.6 34.6-39.7 15-10.1 31.5-15.2 49.4-15.2s34.4 5.1 49.4 15.1c15 10.1 26.6 23.3 34.6 39.7 10.3-4.2 21.1-6.3 32.6-6.3 25.5 0 47.3 9.5 65.4 28.6s27.1 42.1 27.1 69.1c0 12.6-1.9 24-5.7 34.3 16 7.6 28.8 19.3 38.3 35.1 9.5 15.9 14.3 33.4 14.3 52.4zm-266.9 77.1 105.7-158.3c2.7-4.2 3.5-8.8 2.6-13.7-1-4.9-3.5-8.8-7.7-11.4-4.2-2.7-8.8-3.6-13.7-2.9-5 .8-9 3.2-12 7.4l-93.1 140-42.9-42.8c-3.8-3.8-8.2-5.6-13.1-5.4-5 .2-9.3 2-13.1 5.4-3.4 3.4-5.1 7.7-5.1 12.9 0 5.1 1.7 9.4 5.1 12.9l58.9 58.9 2.9 2.3c3.4 2.3 6.9 3.4 10.3 3.4 6.7-.1 11.8-2.9 15.2-8.7z" fill="#1da1f2"></path></svg>';
    };

    const waterfall = container => {
      function getMargin(side, element) {
        const styles = window.getComputedStyle(element);
        return parseFloat(styles[`margin${side}`]) || 0;
      }

      function toPx(value) {
        return `${value}px`;
      }

      function getTop(element) {
        return parseFloat(element.style.top);
      }

      function getLeft(element) {
        return parseFloat(element.style.left);
      }

      function getWidth(element) {
        return element.clientWidth;
      }

      function getHeight(element) {
        return element.clientHeight;
      }

      function getBottom(element) {
        return (
          getTop(element) + getHeight(element) + getMargin("Bottom", element)
        );
      }

      function getRight(element) {
        return (
          getLeft(element) + getWidth(element) + getMargin("Right", element)
        );
      }

      function sortColumns(elements) {
        elements.sort((left, right) => {
          return getBottom(left) === getBottom(right)
            ? getLeft(right) - getLeft(left)
            : getBottom(right) - getBottom(left);
        });
      }

      if (typeof container === "string") {
        container = document.querySelector(container);
      }

      if (!container) return;

      const items = Array.from(container.children).map(item => {
        item.style.position = "absolute";
        return item;
      });

      container.style.position = "relative";

      const columns = [];
      if (items.length) {
        items[0].style.top = "0px";
        items[0].style.left = toPx(getMargin("Left", items[0]));
        columns.push(items[0]);
      }

      let index = 1;
      for (; index < items.length; index += 1) {
        const previous = items[index - 1];
        const current = items[index];
        const fits =
          getRight(previous) + getWidth(current) <= getWidth(container);

        if (!fits) break;

        current.style.top = previous.style.top;
        current.style.left = toPx(
          getRight(previous) + getMargin("Left", current)
        );
        columns.push(current);
      }

      for (; index < items.length; index += 1) {
        sortColumns(columns);
        const current = items[index];
        const column = columns.pop();

        current.style.top = toPx(getBottom(column) + getMargin("Top", current));
        current.style.left = toPx(getLeft(column));
        columns.push(current);
      }

      sortColumns(columns);
      const tallestColumn = columns[0];
      container.style.height = tallestColumn
        ? toPx(getBottom(tallestColumn) + getMargin("Bottom", tallestColumn))
        : "0px";

      const currentWidth = getWidth(container);
      shuoshuoState.resizeHandler = () => {
        const currentContainer = document.querySelector("#talk");
        if (!currentContainer || !document.body.contains(currentContainer)) {
          cleanupShuoshuo();
          return;
        }

        if (getWidth(currentContainer) !== currentWidth) {
          waterfall(currentContainer);
        }
      };

      window.addEventListener("resize", shuoshuoState.resizeHandler);
    };

    const parseMaybeJson = value => {
      return value && typeof value === "object" ? value : null;
    };

    const getEchoExtension = item => {
      return parseMaybeJson(item?.extension);
    };

    const getEchoExtensionType = item => {
      return getEchoExtension(item)?.type || "";
    };

    const getEchoExtensionPayload = item => {
      const extension = getEchoExtension(item);
      return extension?.payload || null;
    };

    const getEchoImages = item => {
      if (!Array.isArray(item?.echo_files)) return [];

      return item.echo_files
        .map(entry => entry?.file || entry)
        .filter(file => {
          const category = String(file?.category || "").toLowerCase();
          const contentType = String(file?.content_type || "").toLowerCase();
          return category === "image" || contentType.startsWith("image/");
        })
        .map(file => file?.url)
        .filter(Boolean);
    };

    const getEchoTags = item => {
      if (!Array.isArray(item?.tags) || !item.tags.length) return ["无标签"];
      return item.tags.map(tag => tag?.name || tag).filter(Boolean);
    };

    const formatTime = time => {
      const date = new Date(time);
      const pad = value => String(value).padStart(2, "0");
      return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
    };

    const renderTextContent = content => {
      return (content || "")
        .replace(
          /\[(.*?)\]\((.*?)\)/g,
          '<a href="$2" target="_blank" rel="nofollow noopener">@$1</a>'
        )
        .replace(/- \[ \]/g, "[]")
        .replace(/- \[x\]/gi, "[x]")
        .replace(/\n/g, "<br>");
    };

    const buildImageHtml = images => {
      if (!images.length) return "";

      const imageLinks = images
        .map(url => {
          const safeUrl = `${url}?fmt=webp&q=75`;
          return `<a href="${safeUrl}" data-fancybox="gallery" class="fancybox"><img src="${safeUrl}" loading="lazy"></a>`;
        })
        .join("");

      return `<div class="zone_imgbox">${imageLinks}</div>`;
    };

    const getGithubTitle = repoUrl => {
      if (!repoUrl) return "";

      const match = repoUrl.match(/^https?:\/\/github\.com\/[^/]+\/([^/?#]+)/i);
      if (match) return match[1];

      try {
        const parts = new URL(repoUrl).pathname.split("/").filter(Boolean);
        return parts.pop() || repoUrl;
      } catch (error) {
        return repoUrl;
      }
    };

    const buildExternalHtml = (type, payload) => {
      if (!payload) return "";

      let siteUrl = "";
      let title = "";
      let background = "https://p.liiiu.cn/i/2024/07/27/66a4632bbf06e.webp";

      if (type === "WEBSITE") {
        siteUrl = payload.site || payload.url || "";
        title = payload.title || siteUrl;
      }

      if (type === "GITHUBPROJ") {
        siteUrl = payload.repoUrl || payload.url || "";
        title = payload.title || getGithubTitle(siteUrl);
        background = "https://p.liiiu.cn/i/2024/07/27/66a461a3098aa.webp";
      }

      if (!siteUrl) return "";

      return `
            <div class="shuoshuo-external-link">
                <a class="external-link" href="${siteUrl}" target="_blank" rel="nofollow noopener">
                    <div class="external-link-left" style="background-image:url(${background})"></div>
                    <div class="external-link-right">
                        <div class="external-link-title">${title}</div>
                        <div>点击跳转<i class="fa-solid fa-angle-right"></i></div>
                    </div>
                </a>
            </div>
        `;
    };

    const getMusicInfo = payload => {
      const link = payload?.url;
      if (!link) return null;

      let server = "";
      if (link.includes("music.163.com")) server = "netease";
      if (link.includes("y.qq.com")) server = "tencent";

      const idMatch = link.match(/id=(\d+)/);
      if (!server || !idMatch) return null;

      return { server, id: idMatch[1] };
    };

    const buildMusicHtml = payload => {
      const music = getMusicInfo(payload);
      if (!music) return "";

      return `<meting-js server="${music.server}" type="song" id="${music.id}" api="https://met.liiiu.cn/meting/api?server=:server&type=:type&id=:id&auth=:auth&r=:r"></meting-js>`;
    };

    const getYoutubeVideoId = value => {
      if (!value) return "";
      if (/^[a-zA-Z0-9_-]{11}$/.test(value)) return value;

      try {
        const url = new URL(value);
        if (url.hostname.includes("youtu.be"))
          return url.pathname.replace("/", "");
        if (url.hostname.includes("youtube.com")) {
          return (
            url.searchParams.get("v") ||
            url.pathname.split("/").filter(Boolean).pop() ||
            ""
          );
        }
      } catch (error) {
        return "";
      }

      return "";
    };

    const buildVideoHtml = payload => {
      const rawValue = payload?.videoId || payload?.url || "";

      if (!rawValue) return "";

      let embedUrl = "";

      if (/^BV[0-9A-Za-z]+$/i.test(rawValue)) {
        embedUrl = `https://www.bilibili.com/blackboard/html5mobileplayer.html?bvid=${rawValue}&as_wide=1&high_quality=1&danmaku=0`;
      } else {
        const youtubeId = getYoutubeVideoId(rawValue);
        if (youtubeId) {
          embedUrl = `https://www.youtube.com/embed/${youtubeId}`;
        }
      }

      if (!embedUrl) return "";

      return `
            <div style="position: relative; padding: 30% 45%; margin-top: 10px;">
                <iframe
                    style="position:absolute;width:100%;height:100%;left:0;top:0;border-radius:12px;"
                    src="${embedUrl}"
                    frameborder="0"
                    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
                    allowfullscreen
                    loading="lazy">
                </iframe>
            </div>
        `;
    };

    const normalizeTalk = item => {
      const extensionType = getEchoExtensionType(item);
      const extensionPayload = getEchoExtensionPayload(item);
      const textContent = item?.content || "";
      const images = getEchoImages(item);

      let content = `<div class="talk_content_text">${renderTextContent(textContent)}</div>`;
      content += buildImageHtml(images);

      if (extensionType === "WEBSITE" || extensionType === "GITHUBPROJ") {
        content += buildExternalHtml(extensionType, extensionPayload);
      }

      if (extensionType === "MUSIC") {
        content += buildMusicHtml(extensionPayload);
      }

      if (extensionType === "VIDEO") {
        content += buildVideoHtml(extensionPayload);
      }

      return {
        content,
        user: item?.username || "匿名",
        avatar: TALK_AVATAR,
        date: formatTime(item?.created_at),
        tags: getEchoTags(item),
        quoteText: textContent,
      };
    };

    const generateTalkElement = item => {
      const talkItem = document.createElement("div");
      talkItem.className = "talk_item";

      const talkMeta = document.createElement("div");
      talkMeta.className = "talk_meta";

      const avatar = document.createElement("img");
      avatar.className = "no-lightbox avatar";
      avatar.src = item.avatar;

      const info = document.createElement("div");
      info.className = "info";

      const nick = document.createElement("span");
      nick.className = "talk_nick";
      nick.innerHTML = `${item.user} ${generateIconSVG()}`;

      const date = document.createElement("span");
      date.className = "talk_date";
      date.textContent = item.date;

      info.appendChild(nick);
      info.appendChild(date);
      talkMeta.appendChild(avatar);
      talkMeta.appendChild(info);

      const talkContent = document.createElement("div");
      talkContent.className = "talk_content";
      talkContent.innerHTML = item.content;

      const talkBottom = document.createElement("div");
      talkBottom.className = "talk_bottom";

      const tags = document.createElement("div");
      const tag = document.createElement("span");
      tag.className = "talk_tag";
      tag.textContent = `# ${item.tags.join(" / ")}`;
      tags.appendChild(tag);

      const commentLink = document.createElement("a");
      commentLink.href = "javascript:;";
      commentLink.addEventListener("click", () => goComment(item.quoteText));

      const icon = document.createElement("span");
      icon.className = "icon";
      icon.innerHTML = '<i class="fa-solid fa-message fa-fw"></i>';
      commentLink.appendChild(icon);

      talkBottom.appendChild(tags);
      talkBottom.appendChild(commentLink);

      talkItem.appendChild(talkMeta);
      talkItem.appendChild(talkContent);
      talkItem.appendChild(talkBottom);

      return talkItem;
    };

    const goComment = text => {
      const textarea = document.querySelector(".atk-textarea");
      if (!textarea) return;

      textarea.value = `> ${text || ""}\n\n`;
      textarea.focus();

      if (window.btf?.snackbarShow) {
        btf.snackbarShow("已为您引用该说说,删除空格效果更佳");
      }
    };

    const afterRender = () => {
      waterfall("#talk");

      if (window.btf?.loadLightbox) {
        btf.loadLightbox(
          document.querySelectorAll("#talk img:not(.no-lightbox)")
        );
      }

      if (window.lazyLoadInstance?.update) {
        lazyLoadInstance.update();
      }
    };

    const renderTalksList = list => {
      list
        .map(normalizeTalk)
        .forEach(item => talkContainer.appendChild(generateTalkElement(item)));
      afterRender();

      const media = talkContainer.querySelectorAll("img, iframe, meting-js");
      media.forEach(element => {
        element.addEventListener("load", afterRender, { once: true });
      });

      shuoshuoState.afterRenderTimer = window.setTimeout(afterRender, 300);
    };

    const fetchTalks = () => {
      const cachedData = localStorage.getItem(TALK_CACHE_KEY);
      const cachedTime = Number(localStorage.getItem(TALK_CACHE_TIME_KEY));
      const now = Date.now();

      if (cachedData && cachedTime && now - cachedTime < TALK_CACHE_DURATION) {
        renderTalksList(JSON.parse(cachedData));
        return;
      }

      fetch(TALK_API_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ page: 1, pageSize: 30, search: "" }),
      })
        .then(response => response.json())
        .then(data => {
          if (data?.code !== 1 || !Array.isArray(data?.data?.items)) {
            console.warn("Unexpected API response format:", data);
            renderTalksList([]);
            return;
          }

          localStorage.setItem(TALK_CACHE_KEY, JSON.stringify(data.data.items));
          localStorage.setItem(TALK_CACHE_TIME_KEY, now.toString());
          renderTalksList(data.data.items);
        })
        .catch(error => console.error("Error fetching data:", error));
    };

    fetchTalks();
  }

  function initShuoshuoPage() {
    renderTalks();
  }

  window.initShuoshuoPage = initShuoshuoPage;

  if (!shuoshuoState.listenersBound) {
    document.addEventListener("pjax:send", cleanupShuoshuo);
    document.addEventListener("pjax:complete", initShuoshuoPage);
    shuoshuoState.listenersBound = true;
  }

  initShuoshuoPage();
})();

自行修改js文件中的Ech0地址为你的地址,在文件中,有一个gocomment函数,实现的是获取卡片中的文本内容,如果如果出现不匹配的情况,请自行修改一下类名,这里我匹配的是artalk的输入框。

然后引入样式文件,这个文件可以在配置文件中引用,也可以在页面文件中类似于shuoshuo.js一样引用,样式内容如下:

:root {
  --liushen-card-bg: #fff;
  --liushen-card-border: 1px solid #e3e8f7;
  --card-box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.09);
  --card-hover-box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.2);
  --liushen-card-secondbg: #f1f3f8;
  --liushen-button-hover-bg: #2679cc;
  --liushen-text: #4c4948;
  --liushen-button-bg: #f1f3f8;
  --liushen-fancybox-bg: rgba(255, 255, 255, 0.5);
}

:root,
[data-theme="dark"] {
  --liushen-card-bg: #181818;
  --liushen-card-secondbg: #30343f;
  --liushen-card-border: 1px solid #42444a;
  --card-box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.09);
  --card-hover-box-shadow: 0 3px 8px 6px rgba(7, 17, 27, 0.2);
  --liushen-button-bg: #30343f;
  --liushen-button-hover-bg: #2679cc;
  --liushen-text: rgba(255, 255, 255, 0.702);
  --liushen-fancybox-bg: rgba(0, 0, 0, 0.5);
}

/* 卡片初始化 */
#talk .talk_item {
  width: calc(33.333% - 6px);
  background: var(--liushen-card-bg);
  border: var(--liushen-card-border);
  box-shadow: var(--card-box-shadow);
  transition: box-shadow 0.3s ease-in-out;
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  padding: 20px;
  margin-bottom: 9px;
  margin-right: 9px;
}
#talk .talk_item:hover {
  box-shadow: var(--card-hover-box-shadow);
}

@media (max-width: 900px) {
  #talk .talk_item {
    width: calc(50% - 5px);
  }
}
@media (max-width: 450px) {
  #talk .talk_item {
    width: calc(100%);
  }
}

#talk {
  position: relative;
  width: 100%;
  box-sizing: border-box;
}

#talk .talk_meta .avatar {
  margin: 0 !important;
  width: 60px;
  height: 60px;
  border-radius: 12px;
}
#talk .talk_bottom,
#talk .talk_meta {
  display: flex;
  align-items: center;
}
#talk .talk_meta {
  display: flex;
  align-items: center;
  width: 100%;
  padding-bottom: 10px;
  border-bottom: 1px dashed grey; /* 添加灰色虚线边框 */
}
#talk .talk_bottom {
  margin-top: 15px;
  padding-top: 10px;
  border-top: 1px dashed grey; /* 添加灰色虚线边框 */
  justify-content: space-between;
}
#talk .talk_meta .info {
  display: flex;
  flex-direction: column;
  margin-left: 10px;
}
#talk .talk_meta .info .talk_nick {
  color: #6dbdc3;
  font-size: 1.2rem;
}
#talk .talk_meta .info svg.is-badge.icon {
  width: 15px;
  padding-top: 3px;
}
#talk .talk_meta .info span.talk_date {
  opacity: 0.6;
}
#talk .talk_item .talk_content {
  margin-top: 10px;
}
#talk .talk_item .talk_content .zone_imgbox {
  display: flex;
  flex-wrap: wrap;
  --w: calc(25% - 8px);
  gap: 10px;
  margin-top: 10px;
}
#talk .talk_item .talk_content .zone_imgbox a {
  display: block;
  border-radius: 12px;
  width: var(--w);
  aspect-ratio: 1/1;
  position: relative;
}
#talk .talk_item .talk_content .zone_imgbox a:first-child {
  width: 100%;
  aspect-ratio: 1.8;
}
#talk .talk_item .talk_content .zone_imgbox img {
  border-radius: 10px;
  width: 100%;
  height: 100%;
  margin: 0 !important;
  object-fit: cover;
}
/* 底部 */
#talk .talk_item .talk_bottom {
  opacity: 0.9;
}
#talk .talk_item .talk_bottom .icon {
  float: right;
  transition: all 0.3s;
}
#talk .talk_item .talk_bottom .icon:hover {
  color: #49b1f5;
}
#talk .talk_item .talk_bottom span.talk_tag,
#talk .talk_item .talk_bottom span.location_tag {
  font-size: 14px;
  background-color: var(--liushen-card-secondbg);
  border-radius: 12px;
  padding: 3px 15px 3px 10px;
  transition: box-shadow 0.3s ease;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

#talk .talk_item .talk_bottom span.location_tag {
  margin-left: 5px;
}

#talk .talk_item .talk_bottom span.talk_tag:hover,
#talk .talk_item .talk_bottom span.location_tag:hover {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
#talk .talk_item .talk_content > a {
  margin: 0 3px;
  color: #ff7d73 !important;
}
#talk .talk_item .talk_content > a:hover {
  text-decoration: none !important;
  color: #ff5143 !important;
}

@media screen and (max-width: 900px) {
  #talk .talk_item .talk_content .zone_imgbox {
    --w: calc(33% - 5px);
  }
  #talk .talk_item #post-comment {
    margin: 0 3px;
  }
}
@media screen and (max-width: 768px) {
  .zone_imgbox {
    gap: 6px;
  }
  .zone_imgbox {
    --w: calc(50% - 3px);
  }
  span.talk_date {
    font-size: 14px;
  }
}

#talk .talk_item .talk_content .douban-card {
  margin-top: 10px !important;
  text-decoration: none;
  align-items: center;
  border-radius: 12px;
  color: #faebd7;
  display: flex;
  justify-content: center;
  margin: 10px;
  max-width: 400px;
  overflow: hidden;
  padding: 15px;
  position: relative;
}

/* 外链卡片 */
#talk .talk_item .talk_content .shuoshuo-external-link {
  /* 无下划线 */
  width: 100%;
  height: 80px;
  margin-top: 10px;
  border-radius: 12px;
  background-color: var(--liushen-card-secondbg);
  color: var(--liushen-card-text);
  border: var(--liushen-card-border);
  transition: background-color 0.3s ease-in-out;
}

.shuoshuo-external-link:hover {
  background-color: var(--liushen-button-hover-bg);
}

.shuoshuo-external-link .external-link {
  display: flex;
  color: var(--liushen-text) !important;
  width: 100%;
  height: 100%;
}

.shuoshuo-external-link .external-link:hover {
  color: white !important;
}

.shuoshuo-external-link .external-link:hover {
  text-decoration: none !important;
}

.shuoshuo-external-link .external-link-left {
  width: 60px;
  height: 60px;
  margin: 10px;
  border-radius: 12px;
  background-size: cover;
  background-position: center;
}

.shuoshuo-external-link .external-link-right {
  display: flex;
  flex-direction: column;
  justify-content: center;
  width: calc(100% - 80px);
  padding: 10px;
}

.shuoshuo-external-link .external-link-right .external-link-title {
  font-size: 1rem;
  font-weight: 800;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.shuoshuo-external-link .external-link-right i {
  margin-left: 5px;
}

.limit {
  width: 100%;
  text-align: center;
  margin-top: 30px;
}

如果一切正常,在说说页面下应该就可以显示了,如果仍然有问题可以评论区讨论,我们在这里继续进行下一步:

首页轮播

由于请求的地址一致,这里我直接用了同一个缓存,可以使网站的加载速度更近一步,缓存时间为半个小时,缓存位置为localstorage,缓存数据共30条,由于首页的轮播只需要最新的内容,这里我们取到缓存后节选前五条即可。

在主题配置文件内部引入外部js,名称位置随意,内容如下:

let talkTimer = null;

const cacheKey = "liushenEchoCacheV2";
const cacheTimeKey = "liushenEchoCacheTimeV2";
const cacheDuration = 30 * 60 * 1000;

function getEchoExtension(item) {
  return item?.extension && typeof item.extension === "object"
    ? item.extension
    : null;
}

function getEchoExtensionType(item) {
  return getEchoExtension(item)?.type || "";
}

function getEchoImages(item) {
  if (!Array.isArray(item?.echo_files)) return [];

  return item.echo_files
    .map(entry => entry?.file || entry)
    .filter(file => {
      const category = String(file?.category || "").toLowerCase();
      const contentType = String(file?.content_type || "").toLowerCase();
      return category === "image" || contentType.startsWith("image/");
    })
    .map(file => file?.url)
    .filter(Boolean);
}

function normalizeTalkItem(item) {
  return {
    content: item?.content || "",
    images: getEchoImages(item),
    extensionType: getEchoExtensionType(item),
  };
}

function getTalkIcons(item, hasMarkdownImage, hasMarkdownLink) {
  const icons = [];

  if (item.images.length || hasMarkdownImage) icons.push("fa-solid fa-image");
  if (item.extensionType === "VIDEO") icons.push("fa-solid fa-video");
  if (item.extensionType === "MUSIC") icons.push("fa-solid fa-music");
  if (item.extensionType === "WEBSITE" || hasMarkdownLink)
    icons.push("fa-solid fa-link");
  if (item.extensionType === "GITHUBPROJ") icons.push("fa-brands fa-github");

  return [...new Set(icons)];
}

function toText(list) {
  return list.map(rawItem => {
    const item = normalizeTalkItem(rawItem);
    let content = item.content;

    const hasMarkdownImage = /\!\[.*?\]\(.*?\)/.test(content);
    const hasMarkdownLink = /\[.*?\]\(.*?\)/.test(content);

    content = content
      .replace(/#(.*?)\s/g, "")
      .replace(/\{.*?\}/g, "")
      .replace(/\!\[.*?\]\(.*?\)/g, "")
      .replace(/\[.*?\]\(.*?\)/g, "$1")
      .trim();

    const icons = getTalkIcons(item, hasMarkdownImage, hasMarkdownLink);
    const iconHtml = icons.map(icon => `<i class="${icon}"></i>`).join("");

    if (iconHtml) {
      content = content
        ? `${content} <span class="talk-resource-icons">${iconHtml}</span>`
        : `<span class="talk-resource-icons">${iconHtml}</span>`;
    }

    return content || "...";
  });
}

function renderTalkTicker(items) {
  let html = "";
  items.forEach((item, index) => {
    html += `<li class="item item-${index + 1}">${item}</li>`;
  });

  const box = document.querySelector("#bber-talk .talk-list");
  if (!box) return;

  box.innerHTML = html;

  talkTimer = setInterval(() => {
    if (box.children.length > 0) {
      box.appendChild(box.children[0]);
    }
  }, 3000);
}

function fetchTalkItems() {
  return fetch("https://mm.liushen.fun/api/echo/page", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ page: 1, pageSize: 30, search: "" }),
  })
    .then(response => response.json())
    .then(data => {
      if (data?.code === 1 && Array.isArray(data?.data?.items)) {
        return data.data.items;
      }

      console.warn("Unexpected API response format:", data);
      return [];
    });
}

function indexTalk() {
  if (talkTimer) {
    clearInterval(talkTimer);
    talkTimer = null;
  }

  if (!document.getElementById("bber-talk")) return;

  const cachedData = localStorage.getItem(cacheKey);
  const cachedTime = Number(localStorage.getItem(cacheTimeKey));
  const now = Date.now();

  if (cachedData && cachedTime && now - cachedTime < cacheDuration) {
    renderTalkTicker(toText(JSON.parse(cachedData)).slice(0, 6));
    return;
  }

  fetchTalkItems()
    .then(items => {
      localStorage.setItem(cacheKey, JSON.stringify(items));
      localStorage.setItem(cacheTimeKey, now.toString());
      renderTalkTicker(toText(items).slice(0, 6));
    })
    .catch(error => console.error("Error fetching data:", error));
}

function whenDOMReady() {
  indexTalk();
}

whenDOMReady();
document.addEventListener("pjax:complete", whenDOMReady);

注意自行修改其中第69行的API地址为你自己的。

然后在主题文件中添加以下的样式:

/* maintop */

#main_top {
  display: flex;
  justify-content: center;
  z-index: 1;
  max-width: 1200px;
  margin: 20px auto;
  width: 100%;
  padding: 0 15px;
  margin-top: 40px;
  margin-bottom: 0px;
}

.hide-aside #main_top {
  width: 80%;
}

.hide-aside #main_top #bber-talk {
  max-width: 936px;
}

@media screen and (min-width: 2000px) {
  .hide-aside #main_top #bber-talk {
    max-width: 80%;
  }

  #main_top {
    max-width: 70%;
  }
}

@media screen and (max-width: 1210px) {
  .hide-aside #main_top {
    padding: 0 12px;
  }
}

@media screen and (max-width: 900px) {
  .hide-aside #main_top {
    width: 100%;
    padding: 0 15px;
  }
}

@media screen and (max-width: 768px) {
  .hide-aside #main_top {
    padding: 0 5px;
  }

  div#main_top {
    margin-top: 20px;
    padding: 0 5px;
  }
}

#bber-talk {
  /* border-radius: 8px; */
  /* background: var(--card-bg); */
  /* box-shadow: none; */
  box-sizing: border-box;
  /* transition: all .3s ease-in-out; */
  cursor: pointer;
  width: 100%;
  min-height: 50px;
  padding: 0.5rem 1rem;
  display: flex;
  align-items: center;
  overflow: hidden;
  font-weight: 700;
}

#bber-talk,
#bber-talk a {
  color: var(--font-color);
}

#bber-talk svg.icon {
  width: 1em;
  height: 1em;
  vertical-align: -0.15em;
  fill: currentColor;
  overflow: hidden;
  font-size: 20px;
}

#bber-talk .item i {
  margin-left: 5px;
}

#bber-talk > i {
  font-size: 1.1rem;
}

#bber-talk .talk-list {
  flex: 1;
  max-height: 32px;
  font-size: 16px;
  padding: 0;
  margin: 0;
  overflow: hidden;
}

#bber-talk .talk-list:hover {
  color: var(--default-bg-color);
  transition: all 0.2s ease-in-out;
}

#bber-talk .talk-list li {
  list-style: none;
  width: 100%;
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
  margin-left: 10px;
}

@media screen and (min-width: 770px) {
  #bber-talk .talk-list {
    text-align: center;
    margin-right: 20px;
  }
}

此时我们还缺少一个插入点,我们需要确保我们的轮播条在最顶部的位置轮播,创建文件themes\butterfly\layout\includes\others\memos_home.pug,写入以下内容:

if (is_home())
  #main_top
    #bber-talk.cardHover.bb_talk_swipper(onclick=`pjax.loadUrl("/shuoshuo/")`)
      svg.icon(t='1660960757124', viewBox='0 0 1024 1024', version='1.1', xmlns='http://www.w3.org/2000/svg', p-id='3946', width='200', height='200')
        path(d='M526.432 924.064c-20.96 0-44.16-12.576-68.96-37.344L274.752 704H192c-52.928 0-96-43.072-96-96V416c0-52.928 43.072-96 96-96h82.752l182.624-182.624c24.576-24.576 47.744-37.024 68.864-37.024C549.184 100.352 576 116 576 160v704c0 44.352-26.72 60.064-49.568 60.064zM192 384c-17.632 0-32 14.368-32 32v192c0 17.664 14.368 32 32 32h96c8.48 0 16.64 3.36 22.624 9.376l192.064 192.096c3.392 3.36 6.496 6.208 9.312 8.576V174.016a145.824 145.824 0 0 0-9.376 8.608l-192 192C304.64 380.64 296.48 384 288 384h-96zM687.584 730.368a31.898 31.898 0 0 1-18.656-6.016c-14.336-10.304-17.632-30.304-7.328-44.672l12.672-17.344C707.392 617.44 736 578.624 736 512c0-69.024-25.344-102.528-57.44-144.928-5.664-7.456-11.328-15.008-16.928-22.784-10.304-14.336-7.04-34.336 7.328-44.672 14.368-10.368 34.336-7.04 44.672 7.328 5.248 7.328 10.656 14.464 15.968 21.504C764.224 374.208 800 421.504 800 512c0 87.648-39.392 141.12-74.144 188.32l-12.224 16.736c-6.272 8.704-16.064 13.312-26.048 13.312z', p-id='3947')
        path(d='M796.448 839.008a31.906 31.906 0 0 1-21.088-7.936c-13.28-11.648-14.624-31.872-2.976-45.152C836.608 712.672 896 628.864 896 512s-59.392-200.704-123.616-273.888c-11.648-13.312-10.304-33.504 2.976-45.184 13.216-11.648 33.44-10.336 45.152 2.944C889.472 274.56 960 373.6 960 512s-70.528 237.472-139.488 316.096c-6.368 7.232-15.2 10.912-24.064 10.912z', p-id='3948')
      ul.talk-list 说说加载中。。。

创建了元素,我们需要通过一个注入点引入这个文件,将其插入到主页中,打开文件themes\butterfly\layout\includes\layout.pug,按照下面的位置插入我们的文件引用:

……
doctype html
html(lang=config.language data-theme=theme.display_mode class=htmlClassHideAside)
  head
    include ./head.pug
  body
    if theme.preloader.enable
      !=partial('includes/loading/index', {}, {cache: true})

    if theme.background
      #web_bg

    !=partial('includes/sidebar', {}, {cache: true})

    #body-wrap(class=pageType)
      include ./header/index.pug
+     include ./others/memos_home.pug

      main#content-inner.layout(class=hideAside)
        if body
          div!= body
        else
          block content
          if theme.aside.enable && page.aside !== false
            include widget/index.pug

      - const footerBg = theme.footer_img
      - const footer_bg = footerBg ? footerBg === true ? bg_img : getBgPath(footerBg) : ''
      footer#footer(style=footer_bg)
        !=partial('includes/footer', {}, {cache: true})

    include ./rightside.pug
    !=partial('includes/rightmenu', {}, {cache: true})
    include ./additional-js.pug

添加第22行,注意不要抄前面的加号,缩进与上面的include对齐即可。这样我们的首页轮播也实现了,如果有样式不对的地方请自行微调。

如果一切正常,魔改应该就结束啦!以后就可以直接在Ech0发表说说,前端可以直接获取哦!

注意事项

S3配置

由于程序并没有写允许跨域,所以需要自行在存储桶侧配置规则,下面以缤纷云示例:

跨域设置

当然程序配置了本地存储,如果服务器较大可以直接使用。

联邦推送

设置中有推送到联邦宇宙的选项,如果需要推送,需要保证/.well-known路径可以访问,如下:

https://mm.liushen.fun/.well-known/webfinger?resource=acct:LiuShen@mm.liushen.fun

碰巧的是,改路径也为SSL证书的HTTP认证地址,如果你需要HTTP自动续签,无法修改,如果你使用的为形如DNS验证的通配符证书,那么可以将其注释掉,常见面板都会默认反代,所以需要手动修改,比如1Panel

1Panel修改反代

前端头像

由于获取ech0管理员头像还需要额外请求API,为了防止影响网站速度,所以我将头像写死到了代码中,请各位自行修改嘻嘻,如下位置:

修改前端头像

由于前端数据写了十五分钟的缓存,所以可能无法及时看到,可以自行修改代码cacheDuration值,但是个人推荐开一部分缓存,防止由于刷新攻击大法,可能导致部分带宽较小的服务器访问较慢。

网站图标

目前的网站图标并不支持自定义,但是细心的小伙伴可能会发现本站已经换了图标,如果实在想换可以通过nginx反代的方式实现,替换掉以下所有图标即可实现图标替换~

{
  "name": "提笔摘星",
  "short_name": "提笔摘星",
  "description": "提笔摘星 is a new-generation open-source self-hosted platform designed for individual users. It is ultra-lightweight and low-cost, supporting the ActivityPub protocol to let you easily publish and share ideas, writings, and links. With a clean, intuitive interface and powerful command-line tools, content management becomes simple and flexible. Your data is fully owned and controlled by you, always connected to the world, building your own network of thoughts.",
  "start_url": "/",
  "id": "/",
  "display": "standalone",
  "background_color": "#f4f1ec",
  "theme_color": "#f4f1ec",
  "icons": [
    {
      "src": "web-app-manifest-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "web-app-manifest-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "apple-touch-icon.png",
      "sizes": "180x180",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "favicon-16x16.png",
      "sizes": "16x16",
      "type": "image/png",
      "purpose": "maskable"
    },
    {
      "src": "favicon-32x32.png",
      "sizes": "32x32",
      "type": "image/png",
      "purpose": "any"
    },
    {
      "src": "favicon.ico",
      "sizes": "any",
      "type": "image/x-icon",
      "purpose": "maskable"
    }
  ],
  "screenshots": [
    {
      "src": "screenshot-desktop.png",
      "sizes": "1548x971",
      "type": "image/png",
      "form_factor": "wide"
    },
    {
      "src": "screenshot-mobile.png",
      "sizes": "640x1136",
      "type": "image/png"
    }
  ]
}

具体怎么反代我就不讲啦~

总结

Ech0可以直接安装到桌面上,这点我很满意。就像装了个专门的说说软件,想发东西的时候点一下就行,很方便,看着也舒服。

Ech0提供了一个Ech0 Hub实例,可以将所有的Ech0聚集起来,如果你成功部署了,欢迎提交你的页面!

站外引用 · 引用站外地址,不保证站点的可用性和安全性Ech0_Hubgithub.com@useEch0

工作的洪流,足以冲刷掉大部分的热情与精力,它让人疲惫,催人懒惰,也让人渐渐疏远了那些曾视若珍宝的热爱。我知道在日复一日的奔波中,维护这精神自留地是不容易的,每个月都有成本,且没有回报。但我依然选择坚持,并非为了向谁证明什么,而是想守护这份曾经的热爱,抵消自己的逐渐老去。总有人说,兴趣不过是一时兴起,三分钟热度。而我想用行动去打破这句断言,告诉他,兴趣并非转瞬即逝的烟火,它也可以是恒久燃烧的,只要我们愿意为之添柴。

后面更新可能没那么勤快了,但我会尽量保证每个月都有点东西发出来。给自己立个flag吧,希望能做到。

也希望各位朋友都能坚持下去,我们一起加油,山顶见!

每日一图

图片来自哲风壁纸

孤独的猫

Previous
魔改笔记八:外挂标签及侧边栏美化
Next
Butterfly主题实现赞赏页面及侧边卡片