阅读视图

发现新文章,点击刷新页面。

构建无障碍组件之Link Pattern

Link Pattern 详解:构建无障碍链接组件

链接是 Web 页面中最核心的导航元素,它将用户从一个资源引导到另一个资源。根据 W3C WAI-ARIA Link Pattern 规范,正确实现的链接组件不仅要提供清晰的导航功能,更要确保所有用户都能顺利访问,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Link Pattern 的核心概念、实现要点以及最佳实践。

一、链接的定义与核心功能

链接是一个允许用户导航到另一个页面、页面位置或其他资源的界面组件。链接的本质是超文本引用,它告诉用户这里有你可能感兴趣的另一个资源。与按钮执行动作不同,链接的作用是导航,这是两者最本质的区别。

在实际开发中,浏览器为原生 HTML 链接提供了丰富的功能支持,例如在新窗口中打开目标页面、将目标 URL 复制到系统剪贴板等。因此,应尽可能使用 HTML a 元素创建链接

二、何时需要自定义链接实现

在某些情况下,需要使用非 a 元素实现链接功能,例如:

  • 图片作为导航入口
  • 使用 CSS 伪元素创建的可视化链接
  • 复杂的 UI 组件中需要链接行为的元素

根据 WAI-ARIA 规范,当必须使用非 a 元素时,需要手动添加必要的 ARIA 属性和键盘支持。

三、键盘交互规范

键盘可访问性是 Web 无障碍设计的核心要素之一。链接组件必须支持完整的键盘交互,确保无法使用鼠标的用户也能顺利操作。根据 Link Pattern 规范:

回车键是激活链接的主要方式。当用户按下回车键时,链接被触发执行导航操作。

上下文菜单(可选):按 Shift + F10 键可以打开链接的上下文菜单,提供复制链接地址、在新窗口中打开等选项。

操作系统 打开上下文菜单
Windows Shift + F10Menu
macOS Control + 点击

四、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍链接组件的技术基础。

角色声明是基础要求。非 a 元素的链接需要将 role 属性设置为 link,向辅助技术表明这是一个链接组件。

示例:使用 span 元素模拟链接:

<span
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')">
  示例网站
</span>

可访问名称是链接最重要的可访问性特征之一。链接必须有可访问的名称,可以通过元素文本内容、aria-label 或 alt 属性提供。

示例 1:使用 img 元素作为链接时,通过 alt 属性提供可访问名称:

<img
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')"
  src="logo.png"
  alt="示例网站" />

示例 2:使用 aria-label 为链接提供可访问名称:

<span
  tabindex="0"
  role="link"
  class="text-link"
  onclick="goToLink(event, 'https://example.com/')"
  onkeydown="goToLink(event, 'https://example.com/')"
  aria-label="访问示例网站"
  >🔗</span
>

焦点管理需要使用 tabindex="0",将链接元素包含在页面 Tab 序列中,使其可通过键盘聚焦。

五、完整示例

以下是使用不同元素实现链接的完整示例:

<!-- 示例 1:span 元素作为链接 -->
<span
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')">
  W3C 网站
</span>

<!-- 示例 2:img 元素作为链接 -->
<img
  tabindex="0"
  role="link"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')"
  src="logo.svg"
  alt="W3C 网站" />

<!-- 示例 3:使用 aria-label 的链接 -->
<span
  tabindex="0"
  role="link"
  class="link-styled"
  onclick="goToLink(event, 'https://w3.org/')"
  onkeydown="goToLink(event, 'https://w3.org/')"
  aria-label="W3C 网站"
  >🔗</span
>

<script>
  function goToLink(event, url) {
    if (event.type === 'keydown' && event.key !== 'Enter') {
      return;
    }
    window.open(url, '_blank');
  }
</script>

<style>
  .link-styled {
    color: blue;
    text-decoration: underline;
    cursor: pointer;
  }
  .link-styled:focus {
    outline: 2px solid blue;
    outline-offset: 2px;
  }
</style>

六、最佳实践

6.1 优先使用原生元素

尽可能使用原生 HTML a 元素创建链接。浏览器为原生链接提供了丰富的功能和更好的兼容性,无需额外添加 ARIA 属性。

<!-- 推荐做法 -->
<a
  href="https://example.com/"
  target="_blank"
  >访问示例</a
>

<!-- 不推荐做法 -->
<span
  role="link"
  tabindex="0"
  >访问示例</span
>

6.2 正确处理键盘事件

自定义链接需要同时处理 onclick 和 onkeydown 事件,确保用户可以通过回车键激活链接。

element.addEventListener('keydown', function (e) {
  if (e.key === 'Enter') {
    e.preventDefault();
    // 执行导航操作
    window.location.href = this.dataset.url;
  }
});

6.3 提供视觉反馈

链接应该有明确的视觉样式,让用户能够识别这是一个可交互的元素。同时,应该提供键盘焦点样式。

a,
[role='link'] {
  color: #0066cc;
  text-decoration: underline;
  cursor: pointer;
}

/* 焦点状态:仅对键盘 Tab 导航显示焦点框,鼠标点击时不显示 */
a:focus-visible,
[role='link']:focus-visible {
  outline: 2px solid #0066cc;
  outline-offset: 2px;
  border-radius: 2px;
}

/* 悬停状态:加深颜色并加粗下划线,提供鼠标交互反馈 */
a:hover,
[role='link']:hover {
  color: #004499;
  text-decoration-thickness: 2px;
}

/* 已访问状态:使用紫色标识用户已访问的链接 */
a:visited,
[role='link']:visited {
  color: #551a8b;
}

/* 激活状态:点击瞬间的颜色变化 */
a:active,
[role='link']:active {
  color: #ff0000;
}

6.4 避免过度使用 ARIA

WAI-ARIA 有一条重要原则:没有 ARIA 比糟糕的 ARIA 更好。在某些情况下,错误使用 ARIA 可能会导致比不使用更糟糕的可访问性体验。只有在确实需要时才使用自定义链接实现。

七、链接与按钮的区别

在 Web 开发中,正确区分按钮和链接至关重要。

特性 链接 按钮
功能 导航到其他资源 触发动作
HTML 元素 <a> <button><input type="button">
键盘激活 Enter Space、Enter
role 属性 link button
典型用途 页面跳转、锚点导航 提交表单、打开对话框

八、总结

构建无障碍的链接组件需要关注多个层面的细节。从语义化角度,应优先使用原生 HTML a 元素;从键盘交互角度,必须支持回车键激活;从 ARIA 属性角度,需要正确使用 role="link" 和可访问名称。

WAI-ARIA Link Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的链接组件,都是构建无障碍网络环境的重要一步。

mri@1.2.0源码阅读

mri(全称 Minimalist CLI Argument Parser)的核心作用是解析命令行传入的参数,把用户在终端输入的命令行参数(比如 --port 3000、-v)转换成 js对象

Three.js 变形动画-打造花瓣绽放

概述

本文将详细介绍如何使用 Three.js 实现变形动画效果。我们将学习如何利用 Morph Targets(形态目标)技术,让 3D 模型在不同形状之间平滑过渡,创造出花瓣绽放等生动的动画效果。

screenshot_2026-01-28_17-56-51.gif

准备工作

首先,我们需要引入必要的 Three.js 库和相关工具:

import * as THREE from "three";
// 导入轨道控制器
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
// 导入动画库
import gsap from "gsap";
// 导入dat.gui
import * as dat from "dat.gui";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

const gui = new dat.GUI();
// 1、创建场景
const scene = new THREE.Scene();

// 2、创建相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();

// 设置相机位置
camera.position.set(0, 0, 20);
scene.add(camera);

环境设置

添加 HDR 环境纹理,提升场景的真实感:

// 添加hdr环境纹理
const loader = new RGBELoader();
loader.load("./textures/038.hdr", function (texture) {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.environment = texture;
});

DRACO 压缩模型加载

使用 DRACO 压缩技术加载 GLB 模型:

// 加载压缩的glb模型
const gltfLoader = new GLTFLoader();
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath("./draco/gltf/");
dracoLoader.setDecoderConfig({ type: "js" });
dracoLoader.preload();
gltfLoader.setDRACOLoader(dracoLoader);

变形动画核心实现

这是变形动画的关键部分,通过 Morph Targets 技术实现模型变形:

let params = {
  value: 0,
  value1: 0,
};

let mixer;
let stem, petal, stem1, petal1, stem2, petal2;

// 加载第一个模型(初始状态)
gltfLoader.load("./model/f4.glb", function (gltf1) {
  console.log(gltf1);
  stem = gltf1.scene.children[0];
  petal = gltf1.scene.children[1];
  gltf1.scene.rotation.x = Math.PI;

  // 遍历场景中的对象并处理材质
  gltf1.scene.traverse((item) => {
    if (item.material && item.material.name == "Water") {
      item.material = new THREE.MeshStandardMaterial({
        color: "skyblue",
        depthWrite: false,
        transparent: true,
        depthTest: false,
        opacity: 0.5,
      });
    }
    if (item.material && item.material.name == "Stem") {
      stem = item;
    }
    if (item.material && item.material.name == "Petal") {
      petal = item;
    }
  });

  // 加载第二个模型(中间状态)
  gltfLoader.load("./model/f2.glb", function (gltf2) {
    gltf2.scene.traverse((item) => {
      if (item.material && item.material.name == "Stem") {
        stem1 = item;
        // 将第二个模型的几何体作为第一个形态目标添加到基础模型
        stem.geometry.morphAttributes.position = [
          stem1.geometry.attributes.position,
        ];
        stem.updateMorphTargets();
      }
      if (item.material && item.material.name == "Petal") {
        petal1 = item;
        // 将第二个模型的几何体作为第一个形态目标添加到基础模型
        petal.geometry.morphAttributes.position = [
          petal1.geometry.attributes.position,
        ];
        petal.updateMorphTargets();
        console.log(petal.morphTargetInfluences);
      }

      // 加载第三个模型(最终状态)
      gltfLoader.load("./model/f1.glb", function (gltf2) {
        gltf2.scene.traverse((item) => {
          if (item.material && item.material.name == "Stem") {
            stem2 = item;
            // 将第三个模型的几何体作为第二个形态目标添加到基础模型
            stem.geometry.morphAttributes.position.push(
              stem2.geometry.attributes.position
            );
            stem.updateMorphTargets();
          }
          if (item.material && item.material.name == "Petal") {
            petal2 = item;
            // 将第三个模型的几何体作为第二个形态目标添加到基础模型
            petal.geometry.morphAttributes.position.push(
              petal2.geometry.attributes.position
            );
            petal.updateMorphTargets();
            console.log(petal.morphTargetInfluences);
          }
        });
      });
    });
  });

  // 使用 GSAP 创建变形动画
  gsap.to(params, {
    value: 1,
    duration: 4,
    onUpdate: function () {
      // 控制第一个形态目标的影响程度
      stem.morphTargetInfluences[0] = params.value;
      petal.morphTargetInfluences[0] = params.value;
    },
    onComplete: function () {
      // 在第一个动画完成后,开始第二个变形动画
      gsap.to(params, {
        value1: 1,
        duration: 4,
        onUpdate: function () {
          // 控制第二个形态目标的影响程度
          stem.morphTargetInfluences[1] = params.value1;
          petal.morphTargetInfluences[1] = params.value1;
        },
      });
    },
  });

  scene.add(gltf1.scene);
});

渲染器设置

配置 WebGL 渲染器:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({
  logarithmicDepthBuffer: true,
  antialias: true,
});
// 设置渲染的尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);
// 开启场景中的阴影贴图
renderer.shadowMap.enabled = true;
renderer.physicallyCorrectLights = true;
renderer.setClearColor(0xcccccc, 1);
renderer.autoClear = false;
// 设置电影渲染模式
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.sortObjects = true;
renderer.logarithmicDepthBuffer = true;

// 将webgl渲染的canvas内容添加到body
document.body.appendChild(renderer.domElement);

控制器和动画循环

设置轨道控制器和渲染循环:

// 创建轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼,让控制器更有真实效果,必须在动画循环里调用.update()。
controls.enableDamping = true;

// 添加坐标轴辅助器
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

// 设置时钟
const clock = new THREE.Clock();
function render() {
  let time = clock.getDelta();
  if (mixer) {
    mixer.update(time);
  }
  controls.update();

  renderer.render(scene, camera);
  // 渲染下一帧的时候就会调用render函数
  requestAnimationFrame(render);
}

render();

变形动画原理详解

Morph Targets(形态目标)是一种在计算机图形学中用于实现网格变形的技术。其基本原理是:

  1. 基础几何体: 定义一个基础的网格几何体
  2. 目标几何体: 定义一个或多个"目标"几何体,它们与基础几何体有相同的拓扑结构(相同的顶点数量和连接关系),但顶点位置不同
  3. 权重控制: 通过权重值(0到1之间)来控制目标几何体对基础几何体的影响程度

在代码中,我们使用 morphTargetInfluences 数组来控制每个形态目标的影响程度:

  • morphTargetInfluences[0] = 0 时,模型呈现初始状态
  • morphTargetInfluences[0] = 1 时,模型完全变成第一个目标状态
  • morphTargetInfluences[0] = 0.5 时,模型是初始状态和目标状态的中间形态

应用场景

变形动画在 3D 应用中有广泛的应用:

  1. 角色面部表情: 实现人物的表情变化
  2. 物体形态变化: 如花朵绽放、物体变形等
  3. 动画过渡: 在不同模型状态之间平滑过渡
  4. 程序化生成: 根据参数动态改变模型形状

性能优化建议

  1. 合理使用: Morph Targets 会增加内存消耗,只在必要时使用
  2. 减少目标数量: 尽量减少形态目标的数量以提高性能
  3. 压缩模型: 使用 DRACO 等压缩技术减少模型文件大小
  4. 优化动画: 使用高效的动画库如 GSAP 来控制变形过程

总结

通过这个项目,我们学习了如何使用 Three.js 的 Morph Targets 技术:

  1. 如何加载多个具有相同拓扑结构的模型
  2. 如何将目标模型的几何体作为形态目标添加到基础模型
  3. 如何通过控制权重来实现平滑的变形动画
  4. 如何使用 GSAP 等动画库来管理复杂的动画序列

变形动画是一个强大而灵活的技术,能够为你的 3D 应用增添生动有趣的视觉效果,特别是在创建有机形态变化(如植物生长、花朵绽放等)方面特别有效。

React Fiber 架构全方位梳理

背景 作为 react 高频考点,React Fiber反复出现,为啥会成为高频考点,我觉得,很大程度上是因为 React Fiber架构真的解决了问题,并且巧妙的思想或许在未来可以给我们一些性能优化

Three.js 曲线应用详解

概述 本文将详细介绍如何使用 Three.js 中的曲线功能。我们将学习如何创建 CatmullRomCurve3 样条曲线,并利用曲线来控制物体的运动轨迹,以及如何将曲线可视化地显示在场景中。 准备

我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding

我把 Claude Code 搬进了 Slack,从此蹲坑也能 Vibe Coding

周末两天肝出一个工具,让 AI 帮我干活,我在 Slack 里指挥就行

🤔 起因:一次深刻的厕所沉思

那是一个普通的下午,我正在电脑前和 Claude Code 愉快地 vibe coding。需求聊着聊着,代码写着写着,突然,肚子一阵翻涌 —— 该去解决人生大事了。

坐在马桶上,我习惯性地掏出手机刷了会儿。刷着刷着突然想起来:卧槽,刚才让 Claude 改的那个函数,我还没确认呢,它现在在干嘛?

更要命的是,我还想继续和它聊,让它把剩下的逻辑也写了。但是... 我在厕所啊!

我盯着手机屏幕陷入了沉思:

  • 回去继续?—— 但这坨还没解决完
  • 用手机 SSH?—— 上次试过,vim 在手机上简直是酷刑
  • 干等着?—— 这也太浪费时间了

就在这个充满哲学意味的时刻,一个想法击中了我:

为什么我不能在手机上继续 vibe coding?

现在 AI 编程这么火,Claude Code 那么好用,凭什么非得坐在电脑前?我就不能蹲着坑,发条消息,让 Claude 继续帮我干活?

想到这里,我感觉这坨💩都拉得更顺畅了。

说干就干。

💡 想法:让 Claude Code 成为我的远程员工

思路其实很简单:

我 (Slack) --> 消息 --> 服务器 --> Claude Code --> 代码修改 --> 结果返回 --> Slack

把 Claude Code CLI 包装成一个服务,跑在我的开发服务器上,然后通过 Slack 这类 IM 工具来和它对话。这样我就可以:

  • 🚽 蹲坑的时候继续 vibe coding
  • 📱 躺床上用手机改代码
  • 🚇 地铁上处理紧急 bug
  • 🍜 吃饭的时候让 AI 跑着任务

我给它起名叫 Heimerdinger —— 英雄联盟里那个小矮子发明家。因为这工具就像有个小机器人帮你干活一样。

🏗️ 架构设计:踩过的第一个坑

最初的设想

一开始我想得很简单:

  1. 监听 Slack 消息
  2. 调用 Claude CLI
  3. 把结果发回去

三步走,完事儿。

现实的暴击

实际做的时候才发现,事情没那么简单:

问题一:Claude Code 的输出是流式的

Claude 不是一下子吐出所有结果,而是一个字一个字往外蹦的。如果我等它全部输出完再发 Slack,用户体验会很差 —— 要等好久才能看到结果。

问题二:一个 Slack workspace 可能有多个项目

不同的频道可能在聊不同的项目,我需要记住"这个频道正在操作哪个项目"。

问题三:会话连续性

Claude Code 有会话概念,同一个会话里它能记住上下文。如果每次都开新会话,那用户说"把刚才那个函数改一下",Claude 根本不知道"刚才"是啥。

最终架构

flowchart TB
    subgraph daemon["hmdg daemon"]
        subgraph adapters["IM Adapters"]
            slack["Slack Adapter"]
            feishu["Feishu Adapter"]
            discord["Discord...<br/>(Future)"]
        end

        subgraph processor["Message Processor"]
            state["Session State<br/>Project State"]
        end

        claude["Claude Code CLI<br/>(streaming JSON)"]

        slack --> processor
        feishu --> processor
        discord --> processor
        processor --> claude
    end

    user["📱 你 (手机/电脑)"] <--> slack
    user <--> feishu

我设计了一个适配器模式,把不同 IM 平台的差异封装起来。这样以后想支持飞书、Discord 什么的,只需要写个新 Adapter 就行。

🔧 技术实现:细节里全是魔鬼

1. 流式输出的处理

Claude Code 支持 --output-format stream-json 参数,会输出 JSONL 格式的流式数据:

{"type":"assistant","message":{"content":[{"type":"text","text":"让我来"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看"}]}}
{"type":"assistant","message":{"content":[{"type":"text","text":"让我来看看这个bug"}]}}

我需要实时解析这些 JSON,然后更新 Slack 消息。

但这里有个坑:Slack 有 API 频率限制

如果每收到一个 JSON 就更新一次消息,很快就会被限流。所以我做了个节流处理:

// 至少间隔 1 秒才更新一次消息
const MIN_UPDATE_INTERVAL = 1000;
let lastUpdateTime = 0;

function throttledUpdate(content: string) {
  const now = Date.now();
  if (now - lastUpdateTime >= MIN_UPDATE_INTERVAL) {
    updateMessage(content);
    lastUpdateTime = now;
  }
}

2. 会话状态管理

这是整个项目最复杂的部分。我需要维护三层状态:

// 每个频道当前选择的项目
const userStates = new Map<string, ChannelState>();

// 每个项目最后使用的会话 ID
const projectSessions = new Map<string, string>();

// 正在执行的任务(用于支持 /stop 命令)
const activeExecutions = new Map<string, ExecutionInfo>();

而且这些状态要持久化,不然服务重启就全丢了:

// 状态持久化到 ~/.heimerdinger/sessions-state.json
function saveState() {
  fs.writeFileSync(STATE_FILE, JSON.stringify({
    userStates: Object.fromEntries(userStates),
    projectSessions: Object.fromEntries(projectSessions)
  }));
}

3. 项目发现机制

Claude Code 会把项目信息存在 ~/.claude/projects/ 目录下,目录名是项目路径的编码形式:

~/.claude/projects/
├── home-dev-project-a/
├── home-dev-my-project/
└── Users-test-my-app/

编码规则很简单:把 / 替换成 -。但解码就头疼了。

比如 home-dev-my-project,它可能是:

  • /home/dev/my/project —— 4 层目录
  • /home/dev/my-project —— 3 层目录,最后一层本身带连字符

没法直接区分哪个 - 是原来的 /,哪个是路径本身就有的。

一开始我想简单处理:

// 简单粗暴,但是错的
function decodePath(encoded: string): string {
  return '/' + encoded.replace(/-/g, '/');
}
// home-dev-my-project -> /home/dev/my/project ❌ 错!

后来想到一个办法:穷举所有可能的组合,看哪个路径在文件系统里真实存在

function decodeProjectPath(encodedPath: string): string {
  const parts = encodedPath.split('-');  // ['home', 'dev', 'my', 'project']
  const result = findValidPath('', parts, 0);
  return result || `/${encodedPath.replace(/-/g, '/')}`;  // fallback
}

// 递归 + 回溯,尝试所有可能的路径组合
function findValidPath(current: string, parts: string[], index: number): string | null {
  if (index >= parts.length) {
    return existsSync(current) ? current : null;
  }

  // 从 index 开始,尝试把连续的 parts 拼成一个目录名
  for (let i = index; i < parts.length; i++) {
    const segment = parts.slice(index, i + 1).join('-');  // 'my' 或 'my-project'
    const newPath = `${current}/${segment}`;

    if (i === parts.length - 1) {
      // 最后一段了,检查路径是否存在
      if (existsSync(newPath)) return newPath;
    } else {
      // 继续递归
      const result = findValidPath(newPath, parts, i + 1);
      if (result) return result;
    }
  }
  return null;
}

举个例子,对于 home-dev-my-project

尝试 /home/dev/my/project  → existsSync() = false ❌
尝试 /home/dev/my-project  → existsSync() = true  ✅ 找到了!

本质就是暴力搜索,但因为目录层级不会太深,性能完全可以接受。

有时候最笨的办法就是最好的办法

4. 权限系统

Claude Code 有权限控制,执行某些操作需要用户确认。在 CLI 里是交互式的,但在 Slack 里怎么办?

我做了个交互式卡片

// 当 Claude 需要权限时,发送一个带按钮的卡片
async function sendPermissionCard(channel: string, tool: string, input: any) {
  await slack.chat.postMessage({
    channel,
    blocks: [
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `🔐 *需要权限确认*\n工具: ${tool}` }
      },
      {
        type: 'actions',
        elements: [
          { type: 'button', text: { type: 'plain_text', text: '✅ 允许' }, action_id: 'approve' },
          { type: 'button', text: { type: 'plain_text', text: '❌ 拒绝' }, action_id: 'deny' }
        ]
      }
    ]
  });
}

用户点击按钮后,我再用更高权限重新执行请求。

5. Slack 斜杠命令

光发消息还不够,我还想要一些快捷操作。Slack 的斜杠命令(Slash Commands)正好派上用场:

  • /project —— 切换项目
  • /stop —— 停止当前正在执行的任务
  • /clear —— 清除会话,重新开始

实现分两层:

第一层:Slack Adapter 注册命令

// 注册 /project 命令
this.app.command('/project', async ({ command, ack }) => {
  await ack();  // 必须在 3 秒内响应,否则 Slack 会报错

  const context = {
    channelId: command.channel_id,
    userId: command.user_id,
  };

  // 触发交互处理
  for (const handler of this.interactionHandlers) {
    await handler('show_project_selector', '', context);
  }
});

第二层:Message Processor 处理交互

async handleInteraction(action: string, value: string, context: MessageContext) {
  if (action === 'show_project_selector') {
    // 展示项目选择卡片
    await this.showProjectSelector(adapter, context);
  } else if (action === 'stop_execution') {
    // 停止当前任务
    await this.handleStopExecution(adapter, context);
  } else if (action === 'clear_session') {
    // 清除会话
    await this.handleClearSession(adapter, context);
  }
}

/stop 的实现有点意思 —— 需要能够中断正在运行的 Claude 进程:

private async handleStopExecution(adapter: IMAdapter, context: MessageContext) {
  const execution = this.activeExecutions.get(context.channelId);

  if (!execution) {
    await adapter.sendMessage(context.channelId, '没有正在运行的任务。');
    return;
  }

  // 标记为已中止,防止后续消息更新
  execution.aborted = true;
  // 触发 AbortController
  execution.abort();

  await adapter.updateMessage(context.channelId, execution.messageTs, '🛑 已停止');
}

这里用了 AbortController,它是 Node.js 原生支持的中止信号机制。在启动 Claude 进程时传入 abortSignal,调用 abort() 就能优雅地终止进程。

一个小细节:Slack 要求斜杠命令必须在 3 秒内响应(ack()),否则会显示错误。所以我先 ack(),再异步处理实际逻辑。这样用户体验会好很多。

😱 踩坑实录:那些让我头秃的问题

坑 1:Bun + Slack WebSocket = 💥

我一开始用 Bun 来开发,build 也用 Bun。结果发现一个诡异的问题:Slack 的 Socket Mode 在 Bun 运行时下经常断连。

排查了半天,发现是 Bun 的 WebSocket 实现和 @slack/bolt 不太兼容。

解决方案:开发时用 tsx(Node.js 运行时),生产构建用 Bun 打包但改成 Node.js 的 shebang:

bun build ./src/cli.ts --outdir ./dist --target node && \
sed -i '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' ./dist/cli.js

是的,有点丑陋,但它能用。

坑 2:Slack 消息长度限制

Slack 单条消息最大 40KB(实际建议 38KB 以内)。但 Claude 有时候会输出很长的内容,特别是它在解释代码的时候。

直接截断?不行,可能截到一半把 markdown 格式搞坏了。

解决方案:按字节长度截断,并确保截断点不在多字节字符中间:

function truncateToByteLength(str: string, maxBytes: number): string {
  const encoder = new TextEncoder();
  const encoded = encoder.encode(str);

  if (encoded.length <= maxBytes) return str;

  // 找到合适的截断点
  let truncated = encoded.slice(0, maxBytes);
  // 确保不截断 UTF-8 多字节字符
  while (truncated.length > 0 && (truncated[truncated.length - 1] & 0xc0) === 0x80) {
    truncated = truncated.slice(0, -1);
  }

  return new TextDecoder().decode(truncated) + '\n\n... (内容过长,已截断)';
}

坑 3:Markdown 格式转换

Claude 输出的是标准 Markdown,但 Slack 用的是自己的 mrkdwn 格式。两者语法不一样:

Markdown Slack mrkdwn
**bold** *bold*
*italic* _italic_
[text](url) <url|text>
`code` `code`

我写了个转换函数,处理这些差异。但最坑的是代码块 —— Slack 对代码块的渲染很奇怪,超过一定长度就会出问题。

最终方案:长代码不放在消息里,而是上传成代码片段(Snippet):

if (codeBlock.length > 2000) {
  await slack.files.uploadV2({
    channel_id: channel,
    content: codeBlock,
    filename: 'code.txt',
    title: 'Code Output'
  });
}

坑 4:语音消息支持

既然是在手机上用,那支持语音消息岂不是更方便?说一句话就能让 Claude 干活。

我集成了 whisper.cpp 做语音转文字:

async function transcribe(audioPath: string): Promise<string> {
  // 先用 ffmpeg 转换成 whisper 需要的格式
  await exec(`ffmpeg -i ${audioPath} -ar 16000 -ac 1 -f wav ${wavPath}`);

  // 调用 whisper
  const { stdout } = await exec(`whisper-cli -m ${modelPath} -f ${wavPath}`);
  return stdout.trim();
}

但这里又有坑:Slack 发来的语音是 .mp4 格式,需要先下载再转换。而且 whisper 模型挺大的,第一次运行要下载...

坑 5:进程管理

作为一个后台服务,进程管理是必须的:

  • 如何优雅地启动/停止?
  • 如何检测服务是否在运行?
  • 如何处理僵尸进程?

我用 PID 文件 + 信号量来管理:

async function stop() {
  const pid = readPidFile();
  if (!pid) return;

  // 先尝试优雅退出
  process.kill(pid, 'SIGTERM');

  // 等待 5 秒
  await sleep(5000);

  // 如果还在运行,强制杀掉
  if (isRunning(pid)) {
    process.kill(pid, 'SIGKILL');
  }
}

✨ 最终效果

折腾了两天,终于能用了:

# 安装
npm install -g chat-heimerdinger

# 初始化配置
hmdg init

# 启动服务
hmdg start

然后在 Slack 里:

2db91133029cfc157e7dbbcea5630576.jpg

或者直接发语音

cae84620508677ae00533e3fc5d68573.jpg

蹲在马桶上,动动手指,代码就改好了。Vibe coding,永不断档!

如果不确定Claude code的改动是否正确,每次对话的thread里还有本次修改的diff

image.png

写在最后

工具的价值在于节省时间。 虽然开发这个工具花了我两个整天,但以后每次蹲坑时继续 vibe coding 省下的时间,迟早会赚回来的(大概)。

slack目前支持已经基本完善,但飞书我是盲调的(公司内网有防火墙,连不到飞书的socket,所以飞书的不保证可用,后续我还会继续调整)。后续我还会考虑支持上钉钉、企微、微信,让更多的IM工具都能实现马桶vibe coding


项目地址:GitHub - chat-heimerdinger

如果觉得有用,欢迎 Star ⭐

有问题欢迎评论区交流,我会尽量回复(如果我不是在厕所里指挥 Claude 干活的话)。

少年你渴望变强吗?

GPT可以根据之前的聊天来判断我们现在最该做什么、学什么、以及处于什么阶段! 你也可以这样问一下你的AI助手:你可以根据我之前问你的技术问题来告诉我,我现在处于什么等级?需要怎么提升?不要粉饰
❌