阅读视图
构建无障碍组件之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 + F10 或 Menu 键 |
| 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 应用。每一个正确实现的链接组件,都是构建无障碍网络环境的重要一步。
type-challenges(ts类型体操): 7 - 对象属性只读
React Native新架构之iOS端初始化源码分析
mri@1.2.0源码阅读
vue3自定义指令合集-单例v-tooltip
JS-面试必考:手动实现一个标准的 Ajax 请求(XMLHttpRequest)
Three.js 变形动画-打造花瓣绽放
概述
本文将详细介绍如何使用 Three.js 实现变形动画效果。我们将学习如何利用 Morph Targets(形态目标)技术,让 3D 模型在不同形状之间平滑过渡,创造出花瓣绽放等生动的动画效果。
准备工作
首先,我们需要引入必要的 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(形态目标)是一种在计算机图形学中用于实现网格变形的技术。其基本原理是:
- 基础几何体: 定义一个基础的网格几何体
- 目标几何体: 定义一个或多个"目标"几何体,它们与基础几何体有相同的拓扑结构(相同的顶点数量和连接关系),但顶点位置不同
- 权重控制: 通过权重值(0到1之间)来控制目标几何体对基础几何体的影响程度
在代码中,我们使用 morphTargetInfluences 数组来控制每个形态目标的影响程度:
- 当
morphTargetInfluences[0] = 0时,模型呈现初始状态 - 当
morphTargetInfluences[0] = 1时,模型完全变成第一个目标状态 - 当
morphTargetInfluences[0] = 0.5时,模型是初始状态和目标状态的中间形态
应用场景
变形动画在 3D 应用中有广泛的应用:
- 角色面部表情: 实现人物的表情变化
- 物体形态变化: 如花朵绽放、物体变形等
- 动画过渡: 在不同模型状态之间平滑过渡
- 程序化生成: 根据参数动态改变模型形状
性能优化建议
- 合理使用: Morph Targets 会增加内存消耗,只在必要时使用
- 减少目标数量: 尽量减少形态目标的数量以提高性能
- 压缩模型: 使用 DRACO 等压缩技术减少模型文件大小
- 优化动画: 使用高效的动画库如 GSAP 来控制变形过程
总结
通过这个项目,我们学习了如何使用 Three.js 的 Morph Targets 技术:
- 如何加载多个具有相同拓扑结构的模型
- 如何将目标模型的几何体作为形态目标添加到基础模型
- 如何通过控制权重来实现平滑的变形动画
- 如何使用 GSAP 等动画库来管理复杂的动画序列
变形动画是一个强大而灵活的技术,能够为你的 3D 应用增添生动有趣的视觉效果,特别是在创建有机形态变化(如植物生长、花朵绽放等)方面特别有效。
React Fiber 架构全方位梳理
🚀 前端5分钟极速转全栈!orpc + cloudflare 前后端项目开发部署实践
根治监管报送“对不准”:从列级血缘到算子级血缘的数据治理新范式
Three.js 曲线应用详解
90% 的人读不懂 Vue 源码,是因为没做这些准备
SpringBoot+Uniapp实战开发全新仿抖音短视频App【2022升级版】
我把 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 —— 英雄联盟里那个小矮子发明家。因为这工具就像有个小机器人帮你干活一样。
🏗️ 架构设计:踩过的第一个坑
最初的设想
一开始我想得很简单:
- 监听 Slack 消息
- 调用 Claude CLI
- 把结果发回去
三步走,完事儿。
现实的暴击
实际做的时候才发现,事情没那么简单:
问题一: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 里:
或者直接发语音
蹲在马桶上,动动手指,代码就改好了。Vibe coding,永不断档!
如果不确定Claude code的改动是否正确,每次对话的thread里还有本次修改的diff
写在最后
工具的价值在于节省时间。 虽然开发这个工具花了我两个整天,但以后每次蹲坑时继续 vibe coding 省下的时间,迟早会赚回来的(大概)。
slack目前支持已经基本完善,但飞书我是盲调的(公司内网有防火墙,连不到飞书的socket,所以飞书的不保证可用,后续我还会继续调整)。后续我还会考虑支持上钉钉、企微、微信,让更多的IM工具都能实现马桶vibe coding。
项目地址:GitHub - chat-heimerdinger
如果觉得有用,欢迎 Star ⭐
有问题欢迎评论区交流,我会尽量回复(如果我不是在厕所里指挥 Claude 干活的话)。