跳到主要内容

用 Tavily MCP 替代智谱搜索,节省 70% 编码套餐额度

· 阅读需 4 分钟

在使用 智谱 GLM 编码套餐 进行日常开发时,发现一个容易被忽视的额度消耗问题:智谱内置的 MCP 联网搜索和网页读取工具,每次调用都消耗编码套餐额度。本文记录排查过程和用 Tavily MCP 替代的方案。

TL;DR

智谱 GLM 编码套餐内置 web-search-primeweb-reader 两个 MCP 服务,每次搜索/读取消耗对话额度。替换为 Tavily MCP 后,联网搜索使用独立免费额度(1000 次/月),不再挤占编码对话用量。

问题现象

在一次 WooCommerce 主题上架开发中,频繁使用联网搜索查文档、读网页。开发完成后查看额度:

智谱编码套餐额度使用情况

5 小时周期内已用 16%,主要消耗来自 MCP 工具调用而非编码对话本身。

查看 MCP 调用统计更直观:

MCP 工具调用量统计

网页搜索和网页读取占了 MCP 调用的大部分。这些调用完全可以不消耗编码套餐额度。

根因

智谱 GLM 编码套餐通过 open.bigmodel.cn 的 MCP 端点提供联网能力:

web-search-prime: https://open.bigmodel.cn/api/mcp/web_search_prime/mcp
web-reader: https://open.bigmodel.cn/api/mcp/web_reader/mcp
zread: https://open.bigmodel.cn/api/mcp/zread/mcp

这些是 HTTP 类型的 MCP 服务,每次调用经过智谱 API 网关,按编码套餐额度计费。而联网搜索本质上是通用能力,不应该消耗 AI 编码额度。

解决方案:用 Tavily MCP 替代

Tavily 提供独立的免费额度(1000 次/月),搜索和网页提取能力完整覆盖智谱的两个工具。

Step 1:移除智谱搜索/读取服务

claude mcp remove web-reader -s user
claude mcp remove web-search-prime -s user

Step 2:添加 Tavily MCP

claude mcp add tavily-search -s user -- npx -y tavily-mcp@latest

Step 3:配置 API Key

~/.claude.json 中补充环境变量:

{
"mcpServers": {
"tavily-search": {
"type": "stdio",
"command": "npx",
"args": ["-y", "tavily-mcp@latest"],
"env": {
"TAVILY_API_KEY": "tvly-your-key-here"
}
}
}
}

Tavily API Key 在 tavily.com 注册即可获取,免费额度 1000 次/月。

Step 4:清理残留配置

移除服务后,settings.json 中的权限引用也要清理:

// 删除 deny 中的智谱搜索引用
"deny": ["mcp__web-search-prime__web_search_prime"] // 删掉

// 删除 allow 中的 web-reader 引用
"mcp__web-reader__webReader" // 删掉

替换前后对比

维度智谱 web-search + web-readerTavily
计费方式消耗编码套餐额度独立免费额度 1000 次/月
搜索质量中文优化中英文均衡
网页提取独立工具内置 extract 功能
配置方式平台内置,无法关闭claude mcp add 一行命令
超出后挤占编码对话额度付费继续使用

注意事项

注意事项

  1. 智谱服务是平台内置的,即使移除本地配置,重启后可能自动恢复。需要在 settings.jsondeny 列表中明确屏蔽
  2. 保留 zread(GitHub 仓库阅读),这个工具消耗较少且功能独特
  3. 保留 doubao-vision(图像分析),Tavily 不覆盖这个场景

推荐

也在用智谱 GLM 编码套餐?

了解智谱 GLM 编码套餐


修复 FSE Block Theme 的风格预览单色块与首页白屏问题

· 阅读需 4 分钟

在为客户开发 WordPress FSE Block Theme 时,遇到风格预览显示异常和首页编辑白屏两个问题。记录根因与解法。

TL;DR

  1. Style variation 的 palette/gradients整体替换而非合并,只写 1 个颜色会丢失其余全部 -- 必须补全完整列表,只改需要变化的颜色。
  2. front-page.html 模板硬编码 pattern 会导致 Site Editor 编辑白屏,且用户无法在编辑器中调整布局 -- 应改为页面内容驱动。

坑一:Style Variation 风格预览只显示一个颜色块

问题现象

Site Editor > Browse Styles 中,默认风格显示 16 个颜色块,而第二个风格(Amber)只显示 1 个颜色块。切换到 Amber 风格后,所有引用 primarybasecontrast 等 CSS 变量的元素全部失去颜色。

根因

WordPress theme.json 的 style variation 机制中,settings.color.palettesettings.color.gradients 是**整体替换(replace)**父主题的对应声明,而非增量合并(merge)。

原始 styles/amber.json

{
"settings": {
"color": {
"palette": [
{ "slug": "accent", "color": "#b45309", "name": "Amber" }
]
}
}
}

这段配置的本意是"只把 accent 颜色换成琥珀色",但实际效果是"整个调色板只剩这一个颜色"。父主题 theme.json 中定义的 primarysecondarybasecontrastsurfaceneutral-50 ~ neutral-900 全部丢失。

gradients 同理,只声明 1 个就会替换掉父主题的 7 个渐变。

解决方案

在 variation 文件中补全完整的 palette 和 gradients 列表,只修改需要变化的值:

{
"$schema": "https://schemas.wp.org/trunk/theme.json",
"version": 3,
"title": "Amber",
"settings": {
"color": {
"palette": [
{ "slug": "primary", "color": "#0f172a", "name": "Primary" },
{ "slug": "secondary", "color": "#334155", "name": "Secondary" },
{ "slug": "accent", "color": "#b45309", "name": "Amber" },
{ "slug": "base", "color": "#ffffff", "name": "Base" },
{ "slug": "contrast", "color": "#f8fafc", "name": "Contrast" },
{ "slug": "surface", "color": "#0f172a", "name": "Surface" },
{ "slug": "neutral-50", "color": "#fafafa", "name": "Neutral 50" }
// ... 其余 neutral 色保持不变
],
"gradients": [
// 补全所有 7 个渐变,只修改 accent gradient 的目标色
]
}
}
}

关键原则:variation 中声明的任何数组类型配置项(palette、gradients、fontSizes、spacingSizes 等)都是整体替换,必须包含完整内容。

坑二:Site Editor 编辑首页白屏

问题现象

在 Site Editor 中通过 Pages > Home 打开首页编辑,画布完全空白。但在 Templates > Front Page 中能看到完整布局。

根因

这是两个不同的编辑入口,对应不同的数据源:

入口编辑对象数据来源
Templates > Front Pagefront-page.html 模板主题文件
Pages > Homepage_on_front 页面内容数据库 wp_posts.post_content

原始 front-page.html 在模板层面硬编码了三个 pattern:

<!-- wp:pattern {"slug":"cclee-theme/hero-centered"} /-->
<!-- wp:pattern {"slug":"cclee-theme/features-grid"} /-->

<!-- wp:group {"tagName":"main","align":"full"} -->
<main class="wp-block-group alignfull">
<!-- wp:post-content /-->
</main>
<!-- /wp:group -->

<!-- wp:pattern {"slug":"cclee-theme/cta-banner"} /-->

post-content 块渲染 page_on_front 页面的数据库内容。该页面内容为空,所以 Pages > Home 画布空白 -- 这是 WordPress 预期行为。

问题在于:模板硬编码 pattern 导致用户无法在页面编辑器中调整首页布局顺序和内容。

解决方案

将首页布局从"模板硬编码"改为"页面内容驱动"。

1. 模板只保留骨架front-page.html):

<!-- wp:template-part {"slug":"header-transparent","tagName":"header"} /-->

<!-- wp:group {"tagName":"main","align":"full","layout":{"type":"constrained"}} -->
<main class="wp-block-group alignfull">
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer-columns","tagName":"footer"} /-->

2. 激活时自动注入默认内容functions.phpinc/setup.php):

add_action( 'after_switch_theme', function () {
$front_page_id = (int) get_option( 'page_on_front' );
if ( ! $front_page_id ) {
return;
}

$post = get_post( $front_page_id );
// 仅在内容为空时注入,不覆盖用户已有内容
if ( ! $post || ! empty( trim( $post->post_content ) ) ) {
return;
}

$default_content = '<!-- wp:pattern {"slug":"cclee-theme/hero-centered"} /-->' . "\n"
. '<!-- wp:pattern {"slug":"cclee-theme/features-grid"} /-->' . "\n"
. '<!-- wp:pattern {"slug":"cclee-theme/cta-banner"} /-->';

wp_update_post( [
'ID' => $front_page_id,
'post_content' => $default_content,
] );
} );

设计决策

  • 模板只负责 header / main(post-content)/ footer 骨架
  • 首页布局完全由页面内容驱动,用户可在页面编辑器中增删、排序 pattern
  • after_switch_theme 钩子保证主题首次激活时有默认内容
  • 内容为空判断确保不覆盖用户的自定义内容

对类似需求感兴趣?联系合作

用 Python FastMCP 搭建自定义 MCP 工具库,按需接入任意 AI 模型

· 阅读需 8 分钟

在为客户构建 AI Agent 系统时,我们发现不同任务对模型能力和成本的需求差异很大:图像分析用视觉模型、文本补全用轻量模型、内部数据查询用本地模型。MCP(Model Context Protocol)让每个能力变成独立的工具,AI 客户端按需调用。

TL;DR

用 Python FastMCP 30 分钟搭建自定义 MCP Server,按场景和成本接入任意 OpenAI 兼容 API。本文以豆包视觉模型为例演示完整流程,并提供文本生成、图像生成、语音合成等场景的扩展模板。

MCP 解决什么问题

MCP(Model Context Protocol)是 Anthropic 提出的开放协议,标准化了 AI 应用与外部工具的通信方式。类比 USB-C:不管什么设备,插上就能用。

传统方式下,每接入一个 AI 能力就要写一套集成代码。MCP 把这个过程标准化:

AI 客户端 (Claude Code / Cursor / Cherry Studio)
↓ MCP 协议 (stdio / SSE)
MCP Server (你的工具库)
↓ HTTP API
各类 AI 模型 / 内部 API

一个 MCP Server 可以暴露多个工具,每个工具背后可以是不同的模型或 API。

为什么选 Python 而非 Node.js

MCP 官方同时提供 Python 和 TypeScript SDK。两者功能等价,选择依据:

维度Python (FastMCP)Node.js (@modelcontextprotocol/sdk)
AI 生态httpx/OpenAI SDK 原生支持需要额外依赖
代码量装饰器一行定义工具需手动注册 schema
二进制处理base64/PIL 原生简洁Buffer API 稍繁琐
数据科学pandas/numpy 随手可用需额外工具链
部署环境venv 隔离,无 Node 版本问题Node 版本兼容性常见坑

核心原因:MCP Server 的本质是「调 API + 处理数据」。Python 在 HTTP 调用、图像/文件处理、数据转换上的代码更简洁直观,依赖更少。Node.js 更适合已有 TypeScript 项目的团队。

30 分钟搭建:图像分析工具

环境准备

mkdir -p ~/.claude/mcp-servers/doubao-vision
cd ~/.claude/mcp-servers/doubao-vision
python3 -m venv venv
source venv/bin/activate
pip install mcp httpx

Server 代码

"""Doubao Vision MCP Server - OpenAI-compatible image analysis via Volcengine Ark."""

import base64
import os
from pathlib import Path

import httpx
from mcp.server.fastmcp import FastMCP

# Config - 通过环境变量注入,不硬编码
BASE_URL = os.getenv("DOUBAO_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
API_KEY = os.getenv("DOUBAO_API_KEY", "")
MODEL = os.getenv("DOUBAO_MODEL", "doubao-1-5-vision-pro-32k-250115")

mcp = FastMCP("doubao-vision")


def _build_image_content(image_source: str) -> dict:
"""Build image content part from URL or local file path."""
if image_source.startswith(("http://", "https://")):
return {"type": "image_url", "image_url": {"url": image_source}}
# Local file: encode to base64 data URI
path = Path(image_source)
if not path.exists():
raise FileNotFoundError(f"Image not found: {image_source}")
mime_map = {
".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".gif": "image/gif",
".webp": "image/webp",
}
mime = mime_map.get(path.suffix.lower(), "image/png")
data = base64.b64encode(path.read_bytes()).decode()
return {
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{data}"},
}


@mcp.tool()
def analyze_image(image_source: str, prompt: str) -> str:
"""Analyze an image using Doubao Vision Pro model.

Supports remote URLs and local file paths.

Args:
image_source: URL or local file path to the image.
prompt: What to analyze or describe about the image.
"""
try:
image_content = _build_image_content(image_source)
except FileNotFoundError as e:
return f"Error: {e}"

messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
image_content,
],
}
]

resp = httpx.post(
f"{BASE_URL}/chat/completions",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
json={
"model": MODEL,
"messages": messages,
"max_tokens": 4096,
},
timeout=60,
)

if resp.status_code != 200:
return f"API error {resp.status_code}: {resp.text}"

data = resp.json()
return data["choices"][0]["message"]["content"]


if __name__ == "__main__":
mcp.run(transport="stdio")

核心设计:

  • _build_image_content:统一处理远程 URL 和本地文件(自动 base64 编码)
  • @mcp.tool() 装饰器:函数签名和 docstring 自动生成 MCP 工具描述,AI 客户端自动发现参数
  • 环境变量配置:API Key 不硬编码,通过 .mcp.jsonenv 注入

注册到客户端

~/.claude/.mcp.json(全局)或项目 .mcp.json 中添加:

{
"mcpServers": {
"doubao-vision": {
"command": "/path/to/venv/bin/python",
"args": ["/path/to/server.py"],
"env": {
"DOUBAO_API_KEY": "your-api-key",
"DOUBAO_MODEL": "doubao-1-5-vision-pro-32k-250115",
"DOUBAO_BASE_URL": "https://ark.cn-beijing.volces.com/api/v3"
}
}
}
}

按需扩展:多场景工具库

一个 MCP Server 可以注册多个工具,每个工具用不同模型。以下是各场景模板:

文本生成 / 补全

@mcp.tool()
def generate_text(prompt: str, model: str = "deepseek-chat") -> str:
"""Generate text using specified model. Supports deepseek-chat, qwen-turbo, etc."""
# 复用同一个 OpenAI 兼容接口,切换 base_url 和 model
providers = {
"deepseek-chat": {"base": "https://api.deepseek.com/v1", "key": os.getenv("DEEPSEEK_API_KEY")},
"qwen-turbo": {"base": "https://dashscope.aliyuncs.com/compatible-mode/v1", "key": os.getenv("QWEN_API_KEY")},
}
cfg = providers.get(model)
if not cfg:
return f"Unknown model: {model}"
# 调用 OpenAI 兼容接口...

图像生成

@mcp.tool()
def generate_image(prompt: str, size: str = "1024x1024") -> str:
"""Generate image from text prompt using Stable Diffusion WebUI API."""
resp = httpx.post(
"http://localhost:7860/sdapi/v1/txt2img",
json={"prompt": prompt, "width": 1024, "height": 1024},
timeout=120,
)
data = resp.json()
# 保存 base64 图片到文件并返回路径
img_bytes = base64.b64decode(data["images"][0])
path = f"/tmp/mcp-gen-{hash(prompt)}.png"
Path(path).write_bytes(img_bytes)
return f"Image saved to: {path}"

语音合成 (TTS)

@mcp.tool()
def text_to_speech(text: str, voice: str = "default") -> str:
"""Convert text to speech audio file."""
resp = httpx.post(
"https://openspeech.bytedance.com/api/v1/tts",
headers={"Authorization": f"Bearer;{os.getenv('TTS_API_KEY')}"},
json={"text": text, "voice": voice, "format": "mp3"},
timeout=30,
)
path = f"/tmp/mcp-tts-{hash(text)}.mp3"
Path(path).write_bytes(resp.content)
return f"Audio saved to: {path}"

内部 API 包装

@mcp.tool()
def query_database(sql: str) -> str:
"""Execute read-only SQL query on internal database. SELECT only."""
if not sql.strip().upper().startswith("SELECT"):
return "Error: Only SELECT queries are allowed."
resp = httpx.post(
"http://internal-api.company.com/query",
json={"sql": sql},
headers={"Authorization": f"Bearer {os.getenv('INTERNAL_API_KEY')}"},
timeout=10,
)
return resp.json()["result"]

成本优化:多模型路由

不同任务用不同成本的模型,是自建工具库的核心价值:

任务类型推荐模型成本级别
简单分类/提取Qwen-Turbo / Ollama 本地极低
文案/翻译DeepSeek-Chat
图像分析豆包 Vision Pro
复杂推理Claude / GPT-4

在 MCP Server 中实现路由:

@mcp.tool()
def smart_query(task: str, content: str) -> str:
"""Route task to optimal model based on complexity."""
route = {
"classify": ("qwen-turbo", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
"analyze_image": ("doubao-1-5-vision-pro-32k-250115", "https://ark.cn-beijing.volces.com/api/v3"),
"deep_reason": ("deepseek-reasoner", "https://api.deepseek.com/v1"),
}
model, base = route.get(task, route["classify"])
# 统一调用 OpenAI 兼容接口...

注意事项

  1. API Key 安全:通过环境变量注入,禁止硬编码在代码中,更不要提交到 git
  2. 超时控制:图像生成等慢任务设 120s+,简单文本任务 10-30s
  3. 错误处理:返回清晰的错误信息,AI 客户端会将其展示给用户
  4. 沙箱限制:包装内部 API 时,限制操作范围(如 SQL 只允许 SELECT)
  5. 并发考虑:httpx 同步调用即可,MCP 协议本身是串行的

扩展性:不只是工具,是 AI 基础设施

上述示例只覆盖了单个 MCP Server 的场景。实际生产中,扩展性远不止于此:

多 Server 组合

不同能力拆成独立 Server,按需加载:

{
"mcpServers": {
"doubao-vision": { "command": "python", "args": ["vision.py"] },
"deepseek-text": { "command": "python", "args": ["text.py"] },
"internal-api": { "command": "python", "args": ["api.py"] }
}
}

好处:一个 Server 挂了不影响其他;不同团队成员维护不同 Server。

共享配置抽取

多个 Server 复用同一套 provider 配置,用 Python module 共享:

# providers.py - 统一管理所有 API 配置
PROVIDERS = {
"doubao": {
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
"api_key_env": "DOUBAO_API_KEY",
},
"deepseek": {
"base_url": "https://api.deepseek.com/v1",
"api_key_env": "DEEPSEEK_API_KEY",
},
}

支持 MCP Resources 和 Prompts

MCP 不只有 Tools,还有 Resources(静态数据)和 Prompts(预设提示词):

# Resources: 让 AI 读取你的内部文档
@mcp.resource("docs://api-spec")
def get_api_spec() -> str:
return Path("openapi.yaml").read_text()

# Prompts: 预设常用分析模板
@mcp.prompt()
def code_review_prompt(code: str) -> str:
return f"Review this code for security issues and performance:\n\n{code}"

从本地到远程部署

stdio 传输适合本地开发。生产环境可切换到 SSE 传输,部署为 HTTP 服务:

# 本地开发
mcp.run(transport="stdio")

# 生产部署(远程 AI 客户端通过 HTTP 访问)
mcp.run(transport="sse", host="0.0.0.0", port=8080)

进阶方向

方向说明
缓存层相同请求缓存结果,降低 API 调用成本
限流/配额按用户或工具限制调用频率
审计日志记录每次工具调用的输入输出,用于调试和合规
流式响应对长文本生成使用 SSE streaming,减少用户等待
工具组合一个工具的输出作为另一个的输入,构建 pipeline

对类似需求感兴趣?联系合作

修复通用 hover 选择器穿透嵌套 Group 导致的卡片悬浮错乱

· 阅读需 3 分钟

在为客户开发 WordPress FSE Block Theme 时发现:博客列表页的卡片悬浮时文字区域发生偏移,但卡片外框不动,视觉上非常违和。记录根因与解法。

TL;DR

通用卡片 hover 选择器 .wp-block-columns .wp-block-column > .wp-block-group:hover 会匹配到卡片内部嵌套的文字 group,导致悬浮时内层文字偏移而外层卡片不动。修复方式:用 .wp-block-post-template 前缀重置内层 group 的 hover 效果。

问题现象

博客列表页使用卡片式布局(外层 border group 包裹图片 + 文字 group)。鼠标悬浮卡片时:

  • 内部的标题和摘要文字产生 translateY(-4px) 位移
  • 外层卡片的 border 和 shadow 没有任何变化
  • 视觉效果像文字"飘出"了卡片

根因

FSE 中卡片 pattern 的 HTML 结构是嵌套的:

<!-- 外层卡片 group (有 border) -->
<div class="wp-block-group has-border-color ...">
<img ... /> <!-- 特色图片 -->

<!-- 内层文字 group -->
<div class="wp-block-group" style="padding:...">
<h2>文章标题</h2>
<p>摘要文字...</p>
</div>
</div>

主题中定义了一个通用 hover 特效:

.wp-block-columns .wp-block-column > .wp-block-group:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
}

这个选择器的本意是让整张卡片悬浮上移。但在博客列表页中,post-template 块内部的卡片也被 wp-block-columns 包裹,导致选择器同时命中了内层文字 group(它也是 .wp-block-column > .wp-block-group)。

由于内层 group 没有 border 和 shadow,只表现为文字位移,而外层卡片没有位移效果。

解决方案

分两步处理:重置内层 + 给外层单独加 hover

1. 重置内层 group 的 hover

.wp-block-post-template 前缀提高优先级,把内层 group 的所有 hover 效果清零:

/* Reset hover for inner groups inside post template cards */
.wp-block-post-template .wp-block-columns .wp-block-column > .wp-block-group {
transition: none;
}
.wp-block-post-template .wp-block-columns .wp-block-column > .wp-block-group:hover {
transform: none;
box-shadow: none;
}

2. 给外层卡片加独立的 hover

外层卡片有 .has-border-color 类,用它做精确匹配:

/* Blog card (outer bordered group) -- hover lift + shadow */
.wp-block-post-template .wp-block-group.has-border-color {
transition:
transform 0.3s ease-out,
box-shadow 0.3s ease-out;
}
.wp-block-post-template .wp-block-group.has-border-color:hover {
transform: translateY(-4px);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.08);
}

为什么不直接缩小通用选择器范围?

通用选择器被多个 pattern 共享(features-grid、pricing、testimonial 等),这些 pattern 的 group 只有一层,不存在嵌套穿透问题。缩小通用选择器反而会破坏其他 pattern 的 hover 效果。

重置内层比限制外层更可靠,因为:

  • 不影响其他使用通用 hover 的 pattern
  • 精确针对有嵌套问题的场景
  • CSS 层面完全隔离,不需要修改任何 HTML/PHP

经验总结

在 FSE Block Theme 中添加 hover 特效前,务必:

  1. 检查 pattern 的实际 HTML 嵌套结构
  2. 确认选择器只命中目标元素(外层容器),不会穿透到内部 group
  3. 如果多个 pattern 共享同一选择器,优先用"重置内层"而非"限制外层"

对类似需求感兴趣?联系合作

修复 CSS ::before 伪元素装饰纹理覆盖按钮的问题

· 阅读需 3 分钟

TL;DR

::before 伪元素实现容器背景装饰纹理(圆点/网格)时,必须同时设置 opacitypointer-events: nonez-index: -1。缺少 opacity 导致纹理 100% 不透明,缺少 z-index 导致纹理覆盖按钮等子元素。

问题现象

FSE 主题的 CTA 横幅区域使用 ::before 伪元素渲染装饰性圆点纹理。预期效果是微妙的背景点缀,实际效果是圆点以完全不透明状态覆盖在按钮表面:

/* 问题代码 — 纹理完全不透明且覆盖子元素 */
.has-dots-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 20px 20px;
pointer-events: none;
/* 缺少 opacity — 纹理 100% 不透明 */
/* 缺少 z-index — 纹理覆盖子内容 */
}

按钮表面出现密集的白色圆点,CTA 区域的渐变背景上也布满了明显的网格线。

根因

::before 伪元素实现装饰纹理时有三个关键属性,缺一不可:

1. opacity — 控制纹理透明度

radial-gradient 生成的是实心圆点,currentColor 继承文本颜色。在深色背景上,白色实心圆点会非常醒目。缺少 opacity 时默认值为 1,纹理完全不透明。

2. z-index: -1 — 将纹理推到子元素后方

::before 设置了 position: absolute,在默认层叠上下文中,定位元素的绘制顺序晚于正常流元素。当容器是 position: relative 时,z-index: -1 将伪元素推到容器背景之后、子内容之前,确保按钮等子元素显示在纹理之上。

3. pointer-events: none — 防止拦截点击

这是最容易被记住的一个,因为它直接影响交互。没有它,纹理层会拦截点击事件。

同一个项目中存在正确实现的对照代码,说明是复制时遗漏了属性:

/* 正确实现 — 独立元素方式 */
.cclee-dots-pattern {
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 24px 24px;
opacity: 0.08; /* 有 */
pointer-events: none;
/* 独立元素由 HTML 层级控制层叠,无需 z-index */
}

解决方案

::before 伪元素补上 opacityz-index: -1

/* 修复后 */
.has-dots-pattern {
position: relative;
}
.has-dots-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.08; /* 添加:8% 不透明度 */
pointer-events: none;
z-index: -1; /* 添加:退到子内容后方 */
}

网格纹理同理:

.has-grid-pattern {
position: relative;
}
.has-grid-pattern::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(currentColor 1px, transparent 1px),
linear-gradient(90deg, currentColor 1px, transparent 1px);
background-size: 40px 40px;
opacity: 0.05; /* 网格更淡,5% 不透明度 */
pointer-events: none;
z-index: -1;
}

装饰纹理伪元素的完整检查清单

属性作用缺失后果
opacity: 0.05~0.1控制纹理透明度纹理完全不透明,喧宾夺主
z-index: -1层叠在子元素后方纹理覆盖按钮等子内容
pointer-events: none不拦截鼠标事件点击穿透失败

三个属性必须同时存在,这是 ::before 装饰纹理的固定模式。


对类似需求感兴趣?联系合作

修复 WooCommerce FSE Cart Block 空车白屏与商品无图塌陷

· 阅读需 4 分钟

在为客户开发 WooCommerce FSE Block Theme 时遇到这两个问题:Cart Block 空车时页面白屏、商品无特色图片时卡片高度塌陷。记录根因与解法。

TL;DR

  1. Cart Block 必须显式声明 filled-cart-blockempty-cart-block 子块,否则空车时无任何内容输出。
  2. 商品无特色图片时,FSE 的 post-featured-image 块渲染为空字符串,导致卡片高度塌陷。通过 post_thumbnail_html filter 补上 WooCommerce 占位图。

问题一:Cart Block 空车白屏

问题现象

购物车页面在有商品时正常显示,清空购物车后整个页面内容区变成空白 -- 没有提示文案,没有"继续购物"按钮,用户无法自助返回。

根因

WooCommerce Cart Block (wp:woocommerce/cart) 的设计要求开发者显式声明两个子块:

  • wp:woocommerce/filled-cart-block -- 有商品时显示
  • wp:woocommerce/empty-cart-block -- 空车时显示

如果只写了 Cart Block 本体而没有嵌套这两个子块,WooCommerce 在空车状态下不知道该渲染什么,输出为空。

这个问题在经典主题中不存在,因为经典主题使用 PHP 模板 cart.php,其中已经内置了空车处理逻辑。但 FSE 的 HTML 模板是声明式的,必须完整声明所有状态。

解决方案

cart.html 模板的正确结构:

<!-- wp:woocommerce/cart {"className":"cclee-cart"} -->
<div class="wp-block-woocommerce-cart alignwide is-loading">

<!-- wp:woocommerce/filled-cart-block -->
<div class="wp-block-woocommerce-filled-cart-block">
<!-- 有商品时的完整布局:商品列表 + 汇总 -->
<!-- wp:columns -->
<div class="wp-block-columns">
<!-- wp:column {"width":"65%"} -->
<div class="wp-block-column" style="flex-basis:65%">
<!-- wp:woocommerce/cart-items-block -->
<div class="wp-block-woocommerce-cart-items-block"></div>
<!-- /wp:woocommerce/cart-items-block -->
</div>
<!-- /wp:column -->
<!-- wp:column {"width":"35%"} -->
<div class="wp-block-column" style="flex-basis:35%">
<!-- wp:woocommerce/cart-totals-block -->
<div class="wp-block-woocommerce-cart-totals-block">
<!-- wp:woocommerce/cart-order-summary-block /-->
<!-- wp:woocommerce/proceed-to-checkout-block /-->
</div>
<!-- /wp:woocommerce/cart-totals-block -->
</div>
<!-- /wp:column -->
</div>
<!-- /wp:columns -->
</div>
<!-- /wp:woocommerce/filled-cart-block -->

<!-- wp:woocommerce/empty-cart-block -->
<div class="wp-block-woocommerce-empty-cart-block">
<!-- 空车提示 + 继续购物按钮 -->
<!-- wp:paragraph {"align":"center","textColor":"neutral-500"} -->
<p class="has-text-align-center has-neutral-500-color has-text-color">Your cart is currently empty.</p>
<!-- /wp:paragraph -->
<!-- wp:buttons {"layout":{"type":"flex","justifyContent":"center"}} -->
<div class="wp-block-buttons">
<!-- wp:button {"backgroundColor":"accent","textColor":"base"} -->
<div class="wp-block-button"><a href="/shop/" class="wp-block-button__link has-base-color has-accent-background-color has-text-color has-background wp-element-button">Browse Products</a></div>
<!-- /wp:button -->
</div>
<!-- /wp:buttons -->
</div>
<!-- /wp:woocommerce/empty-cart-block -->

</div>
<!-- /wp:woocommerce/cart -->

关键点filled-cart-blockempty-cart-block 必须是 wp:woocommerce/cart 的直接子块,WooCommerce 通过这两个子块实现有货/空车的条件渲染。

问题二:商品无特色图片时卡片塌陷

问题现象

在商品列表页(archive-product)中,没有设置特色图片的商品卡片只显示文字部分,没有图片区域。与有图卡片混排时,布局高度不统一,视觉上非常突兀。

根因

FSE 的 wp:post-featured-image 块在文章/商品没有缩略图时直接渲染为空字符串,不会输出占位图。经典主题中通常在 PHP 模板里手动处理这种情况(has_post_thumbnail() 判断),但 FSE HTML 模板无法嵌入这种条件逻辑。

解决方案

在主题的 functions.php 或 WooCommerce 集成文件中添加 filter:

/**
* Product placeholder image when no featured image is set.
*
* FSE post-featured-image block renders empty when no thumbnail,
* causing card height collapse. This filter injects the WooCommerce
* placeholder image for product post type.
*
* @param string $html The post thumbnail HTML.
* @param int $post_id The post ID.
* @return string
*/
add_filter( 'post_thumbnail_html', function ( $html, $post_id ) {
// Already has image or not a product -- skip.
if ( $html || get_post_type( $post_id ) !== 'product' ) {
return $html;
}

// Use WooCommerce's built-in placeholder.
$src = function_exists( 'wc_placeholder_img_src' )
? wc_placeholder_img_src()
: '';

if ( ! $src ) {
return '';
}

return sprintf(
'<img src="%s" alt="%s" loading="lazy" decoding="async" style="width:100%%;height:100%%;object-fit:cover;">',
esc_url( $src ),
esc_attr( get_the_title( $post_id ) )
);
}, 10, 2 );

注意

  • wc_placeholder_img_src() 依赖 WooCommerce 插件激活,用 function_exists() 做防护
  • object-fit: cover 确保占位图和正常特色图片的裁剪行为一致
  • 只针对 product 类型生效,不影响博客文章等其他 post type

对类似需求感兴趣?联系合作

WordPress FSE Block Validation Failed: JSON 引号缺失的隐蔽根因

· 阅读需 4 分钟

TL;DR

WordPress FSE 主题的 Pattern/Template 文件中,HTML 注释里的 JSON 属性如果某个字符串值缺少闭合引号 ",花括号数量仍然平衡,但 parse_blocks() 会静默将该块的 attrs 置为 null。Gutenberg 的 save 函数因此不输出 inline style,触发 Block validation failed。用 json.loads() 验证 JSON 合法性即可定位。

问题现象

WordPress Site Editor 打开 wishlist 模板时控制台报错:

Block validation: Block validation failed for `core/group`

Content generated by `save` function:
<div class="wp-block-group has-border-color has-neutral-200-border-color"></div>

Content retrieved from post body:
<div class="wp-block-group has-border-color has-neutral-200-border-color"
style="border-style:solid;border-width:1px;border-radius:var(--wp--custom--border--radius--lg);
padding-top:var(--wp--preset--spacing--40);...">

save 函数输出了正确的 CSS class,但完全丢失了 inline style,且内容为空。而文件中 HTML 明明包含 style 属性和子块。

根因

问题出在模板文件中 core/group 块的 HTML 注释 JSON 属性:

<!-- 原始(有误) -->
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var(--wp--preset--spacing--40)",
"right":"var(--wp--preset--spacing--40)",
"bottom":"var(--wp--preset--spacing--40)",
"left":"var(--wp--preset--spacing--40)"}}, <!-- 此处缺少闭合引号 -->
"border":{"radius":"var(--wp--custom--border--radius--lg)","width":"1px","style":"solid"}},
"borderColor":"neutral-200","layout":{"type":"constrained"}} -->

注意 "left" 的值 "var(--wp--preset--spacing--40)" 缺少闭合引号 ",实际写成了 "var(--wp--preset--spacing--40)

为什么花括号检查发现不了:

正确:{ "left": "value" }  → 引号成对,花括号平衡
错误:{ "left": "value } → 引号不成对,但花括号仍然平衡

} 在 JSON 解析器眼中是字符串内容的一部分(因为引号没闭合),所以花括号计数不变。parse_blocks() 解析失败后不会报错,而是将该块的 attrs 直接置为 null

// parse_blocks 返回结果
[
'blockName' => 'core/group',
'attrs' => null, // 整个属性对象被丢弃
'innerHTML' => '<div ...>', // 原始 HTML 仍在
]

Gutenberg 拿到 null attrs 调用 save() 函数,自然不会输出任何 inline style,与文件中的 HTML 不匹配,触发 Block validation failed。

这个问题的隐蔽性在于:

  • 不会白屏,页面仍能渲染(使用 innerHTML 作为 fallback)
  • 花括号数量平衡,肉眼审查容易遗漏
  • Site Editor 中表现为"块需要恢复"的提示,容易被忽略
  • 审计脚本通常只检查花括号平衡和属性对应,不验证 JSON 合法性

解决方案

1. 定位问题

用 Python 验证 JSON 合法性:

python3 -c "
import json
with open('templates/wishlist.html') as f:
content = f.read()
# 提取 JSON 注释
marker = 'wp:group {'
start = content.index(marker) + len(marker) - 1
end = content.index(' -->', start)
json_str = content[start:end]
try:
json.loads(json_str)
print('JSON OK')
except json.JSONDecodeError as e:
print(f'Error at position {e.pos}: {e.msg}')
print(f'Context: ...{json_str[max(0,e.pos-20):e.pos+20]}...')
"

输出会精确定位错误:

Error at position 196: Expecting ',' delimiter
Context: ...g--40)}},"border":{"...

2. 修复 JSON

"left" 的值后面补上缺失的闭合引号:

<!-- 修复后 -->
<!-- wp:group {"style":{"spacing":{"padding":{"top":"var(--wp--preset--spacing--40)",
"right":"var(--wp--preset--spacing--40)",
"bottom":"var(--wp--preset--spacing--40)",
"left":"var(--wp--preset--spacing--40)"}}, <!-- 闭合引号已补上 -->
"border":{"radius":"var(--wp--custom--border--radius--lg)","width":"1px","style":"solid"}},
"borderColor":"neutral-200","layout":{"type":"constrained"}} -->

3. 验证修复

# 用 WP-CLI 验证 parse_blocks 正确解析
docker exec wp_cli wp eval '
$blocks = parse_blocks(file_get_contents(get_stylesheet_directory() . "/templates/wishlist.html"));
// 导航到目标块,检查 attrs 非 null
echo $blocks[...]["attrs"]["style"]["border"]["radius"];
' --allow-root

4. 预防措施

在 CI 中加入 JSON 注释合法性检查:

import json, re, sys

def check_block_json(filepath):
with open(filepath) as f:
content = f.read()
# 匹配所有 <!-- wp:xxx {...} --> 注释中的 JSON
for m in re.finditer(r'<!-- wp:\w+ (\{.*?\}) -->', content):
try:
json.loads(m.group(1))
except json.JSONDecodeError as e:
print(f"{filepath}: JSON error at comment position {m.start()}: {e}")
sys.exit(1)

check_block_json(sys.argv[1])

对类似需求感兴趣?联系合作

修复 Gutenberg Gradient Class 命名变更导致的 Block 验证失败

· 阅读需 2 分钟

在为客户开发 WordPress FSE 主题时遇到此问题,记录根因与解法。

TL;DR

Gutenberg 升级后,gradient 的 CSS class 命名规则从 has-{slug}-gradient 变为 has-{slug}-gradient-background。手写 Pattern HTML 中的旧 class 与 Gutenberg 验证逻辑不匹配,导致 Site Editor 报错。解决方案是批量替换 class 名称。

问题现象

所有历史 Pattern 在 Site Editor 中显示错误:

Block contains unexpected or invalid content
  • 新建 Pattern 无此问题
  • 前台渲染正常
  • 点击 "Attempt Recovery" 可恢复显示

根因分析

通过 DevTools Console 查看 Block validation failed 日志,对比 Expected 与 Actual:

Expected(Gutenberg 生成)

<div class="wp-block-group has-accent-gradient-background has-background">

Actual(Pattern 中手写)

<div class="wp-block-group has-accent-gradient has-background">

Gutenberg 版本升级后,gradient class 命名规则变更:

旧命名新命名
has-{slug}-gradienthas-{slug}-gradient-background

Block 验证时,Gutenberg 会根据块注释中的 JSON 属性(如 "gradient":"accent-gradient")重新计算期望的 HTML,与实际 HTML 比对。class 不匹配即报验证失败。

解决方案

1. 确认影响范围

# 查找使用旧 class 的文件
grep -r "has-[a-z0-9-]*-gradient " patterns/ --include="*.php"

2. 批量替换

# macOS/Linux 通用
sed -i '' 's/has-\([a-z0-9-]*\)-gradient /has-\1-gradient-background /g' patterns/*.php

# Linux (GNU sed)
sed -i 's/has-\([a-z0-9-]*\)-gradient /has-\1-gradient-background /g' patterns/*.php

注意

  • 只替换 HTML 标签中的 class 属性
  • 块注释中的 "gradient":"accent-gradient" 声明不需要改动
  • 正则末尾有空格,避免误匹配 has-accent-gradient-background

3. 验证修复

  1. 清理 WordPress 缓存:wp cache flush
  2. 刷新 Site Editor,确认错误消失
  3. 抽查几个 Pattern,验证前后台显示正常

预防措施

  1. 优先使用块注释属性:通过 JSON 属性(如 "gradient":"accent-gradient")设置样式,让 Gutenberg 自动生成 class,避免手写
  2. 关注 Gutenberg 更新日志:升级前检查 Breaking Changes
  3. 开发环境先验证:升级后在开发环境测试所有 Pattern,确认无验证错误再部署

对类似需求感兴趣?联系合作

修复 FSE Group 块 layout 属性覆盖自定义 CSS 的问题

· 阅读需 3 分钟

TL;DR

WordPress FSE Group 块的 layout 属性会自动生成 is-layout-* CSS 类,这些类的样式优先级高于普通自定义 CSS,导致尺寸设置失效。解决方案:1) 块注释中使用 "layout":{"type":"default"} 避免生成额外布局类;2) CSS 中使用 !important 强制覆盖;3) 关键:添加 padding: 0 !important 清除 Group 块默认内边距。

问题现象

Timeline 组件的年份圆点应显示为 80px 正圆,实际却呈现为椭圆:

<!-- 块注释中的尺寸设置 -->
<!-- wp:group {"style":{"dimensions":{"width":"80px","height":"80px"}},"layout":{"type":"flex",...}} -->
/* 自定义 CSS */
.cclee-timeline-dot {
width: 80px;
height: 80px;
border-radius: 50%;
}

无论调整 CSS 还是块属性,圆点始终被拉伸变形。

根因

WordPress FSE 的 Group 块会根据 layout 属性自动添加布局相关的 CSS 类:

<div class="wp-block-group cclee-timeline-dot is-layout-flow">

这些 is-layout-* 类来自 WordPress 核心样式表,其样式规则会覆盖自定义 CSS。同时,Group 块存在默认 padding,会撑大元素导致尺寸计算偏差。

关键问题点:

  1. layout: {"type": "flex"} 生成 is-layout-flex 类,子元素受 flexbox 拉伸影响
  2. 块注释中的 style.dimensions 转为 inline style,但被布局类样式覆盖
  3. Group 块默认 padding 增加了元素实际尺寸

解决方案

1. 修改块注释,使用 default layout

<!-- wp:group {"className":"cclee-timeline-dot","style":{"border":{"radius":"50%"}},"backgroundColor":"accent","textColor":"base","layout":{"type":"default"}} -->
<div class="wp-block-group cclee-timeline-dot has-base-color has-accent-background-color has-text-color has-background" style="border-radius:50%">

移除 style.dimensions 和复杂的 flex layout,改用 "layout":{"type":"default"}

2. CSS 强制覆盖 + 清除默认 padding

/* Timeline: Fixed circle dot */
.wp-block-group.cclee-timeline-dot {
width: 80px !important;
height: 80px !important;
min-width: 80px !important;
min-height: 80px !important;
flex-shrink: 0 !important;
aspect-ratio: unset !important;
border-radius: 50% !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
align-self: center !important;
box-sizing: border-box !important;
text-align: center !important;
padding: 0 !important; /* 关键:清除默认 padding */
}

.wp-block-group.cclee-timeline-dot p {
margin: 0 !important;
white-space: nowrap !important;
line-height: 1 !important;
overflow: visible !important;
}

3. 使用 :has() 控制父容器

防止父级 Column 被 flexbox 拉伸:

.wp-block-columns .wp-block-column:has(.cclee-timeline-dot) {
flex-shrink: 0 !important;
flex-basis: 100px !important;
width: 100px !important;
}

关键发现

padding: 0 !important 是最终解决方案。Group 块的默认 padding 会撑大元素,即使设置了 width/height,实际渲染尺寸仍会超出预期。


对类似需求感兴趣?联系合作

修复 WordPress FSE 主题 Footer 文字不可见的 WCAG 对比度问题

· 阅读需 3 分钟

在为客户开发 WordPress FSE 企业主题时,发现 Footer 区块在多个 Style Variation 下文字几乎不可见。本文记录从 WCAG 对比度诊断到引入语义色、处理全局样式覆盖的完整修复过程。

TL;DR

问题:FSE 主题的 contrast 颜色 token 语义混乱,浅色主题中 contrast ≈ 浅灰 ≈ base(白色),导致 Footer 对比度仅 1.05:1。

解法

  1. 引入 surface 语义色,专用于深色区块背景
  2. 删除 wp_global_styles 中的覆盖样式
  3. 所有 Style Variations 同步添加 surface 定义

结果:对比度从 1.05:1 提升至 15.8:1(WCAG AAA 级)。

问题现象

Footer 区块使用 backgroundColor="contrast" + textColor="base"

<!-- wp:group {"backgroundColor":"contrast","textColor":"base"} -->
<div class="has-base-color has-contrast-background-color">
Footer 内容
</div>

在默认主题下,Footer 文字几乎不可见:

组合前景色背景色对比度WCAG
Footer 文字#ffffff (base)#f8fafc (contrast)1.05:1❌ 失败
Footer 链接#f59e0b (accent)#f8fafc (contrast)1.78:1❌ 失败

WCAG AA 标准要求普通文字对比度 ≥ 4.5:1,当前状态远不达标。

根因分析

1. contrast 语义混乱

contrast 的设计意图是"与 base 形成对比的背景色",但在不同主题模式下语义矛盾:

Variationbasecontrast期望 vs 实际
默认(浅色)#ffffff#f8fafc 浅灰期望深色,实际浅色
Tech(深色)#0f0f1a 深黑#1e1e2e 深紫期望浅色,实际深色

Footer Pattern 假设 contrast 是深色背景,但 5/6 的 Style Variations 中它是浅色。

2. 颜色语义缺乏明确用途定义

原设计系统只有 contrast 一个"对比色",没有区分:

  • 浅色对比区块(CTA Banner 等强调区域)
  • 深色对比区块(Footer、暗色 Hero 等)

解决方案

Step 1:引入 surface 语义色

theme.json 中新增 surface token,专用于深色区块背景:

{
"slug": "surface",
"color": "#0f172a",
"name": "Surface"
}

Step 2:更新所有 Style Variations

每个 variation 定义自己的 surface 色(通常等于 primary):

// styles/commerce.json
{ "slug": "surface", "color": "#1f2937", "name": "Surface" }

// styles/nature.json
{ "slug": "surface", "color": "#14532d", "name": "Surface" }

// styles/tech.json(深色主题)
{ "slug": "surface", "color": "#1e1e2e", "name": "Surface" }
<!-- wp:group {"backgroundColor":"surface","textColor":"base"} -->
<div class="has-base-color has-surface-background-color">
Footer 内容
</div>

Step 4:删除全局样式覆盖

修改 theme.json 后颜色仍不生效?检查全局样式:

# 检查是否存在全局样式
docker exec wp_cli wp post list --post_type=wp_global_styles --fields=ID,post_title --allow-root

# 删除全局样式
docker exec wp_cli wp post delete <ID> --force --allow-root
docker exec wp_cli wp cache flush --allow-root

原因wp_global_styles 中的 color.palette完全覆盖(非合并)theme.json 的调色板。

修复结果

Variationsurface + base 对比度WCAG 级别
默认15.8:1✅ AAA
Commerce13.1:1✅ AAA
Industrial12.6:1✅ AAA
Professional9.9:1✅ AAA
Nature10.8:1✅ AAA
Tech11.5:1✅ AAA

颜色语义总结

Token用途
primary品牌主色(Logo、主按钮)
secondary次要元素
accent行动召唤(CTA、链接)
base页面主背景
contrast浅色对比区块背景
surface深色区块背景(Footer、暗色 CTA) ← 新增

对类似需求感兴趣?联系合作