# 05 · 代码片段库

本分册是**可直接复制**的片段库。每个片段包含：一句话说明 → 落点文件 → 完整代码 → 注意事项。

## 阅读约定

- **参考实现**（片段 1–10）：逐字来自参考主题 `xiaoying-mailbox`，类名 / 函数名 / 常量名已与源码核对一致，可直接照抄。
- **推荐实现（新增）**（片段 11–15）：参考主题中**没有**这些能力，以下给出与现有风格一致的推荐写法（只用 daisyUI / Tailwind 类名，不写自定义 CSS）。
- 所有片段默认放在**主题根目录**，代码中的路径均为主题内相对路径。
- 函数与全局量统一 `xy_mbx_` 前缀；文案为中文直出（与参考实现一致，不引入 i18n 包装）。
- 片段中出现的 Tailwind / daisyUI 类名必须存在于 `assets/css/tailwind.css`；新增或改动类名后必须重新执行 `npm run build:css`。

---

## 1. 面包屑（文章页）

**说明**：输出「首页 › 文章 › 首个分类 › 当前标题」四级面包屑，未设置文章列表页时自动省略「文章」这一层。
**落点文件**：`single.php`（文章标题之前）。

```php
<?php
// 面包屑：首页 › 文章列表页（未设置时省略）› 首个分类（无分类时省略）› 当前文章。
$xy_posts_page_id   = (int) get_option( 'page_for_posts' );
$xy_post_categories = get_the_category();
?>
<nav class="breadcrumbs text-sm" aria-label="面包屑">
	<ul>
		<li><a href="<?php echo esc_url( home_url( '/' ) ); ?>">首页</a></li>
		<?php if ( $xy_posts_page_id ) : ?>
			<li><a href="<?php echo esc_url( get_permalink( $xy_posts_page_id ) ); ?>">文章</a></li>
		<?php endif; ?>
		<?php if ( ! empty( $xy_post_categories ) ) : ?>
			<li><a href="<?php echo esc_url( get_category_link( $xy_post_categories[0] ) ); ?>"><?php echo esc_html( $xy_post_categories[0]->name ); ?></a></li>
		<?php endif; ?>
		<li><span aria-current="page"><?php the_title(); ?></span></li>
	</ul>
</nav>
```

**注意事项**

- 用 `get_the_category()` 取数组，只取 `[0]`；直接在循环外调用 `get_the_category_list()` 会输出全部分类，不适合面包屑。
- `aria-current="page"` 是 daisyUI `breadcrumbs` 标记当前项的约定，同时满足无障碍要求。
- 层级判断全部基于函数返回值，不要写死「一定有文章页」——后台「设置 → 阅读」未指定文章页时 `page_for_posts` 为 0。

---

## 2. 列表卡片骨架

**说明**：`home.php` / `archive.php` / `search.php` / `index.php` 共用的响应式网格卡片（封面或占位块、分类徽标、日期、评论数徽标、两行截断标题、摘要、阅读全文）。
**落点文件**：`template-parts/post-list.php`。

```php
<?php if ( have_posts() ) : ?>
	<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
		<?php
		while ( have_posts() ) :
			the_post();
			?>
			<article class="card bg-base-100 shadow-sm transition-shadow hover:shadow-md">
				<?php if ( has_post_thumbnail() ) : ?>
					<figure class="aspect-[16/9] overflow-hidden">
						<a class="block h-full w-full" href="<?php the_permalink(); ?>">
							<?php the_post_thumbnail( 'medium_large', array( 'class' => 'h-full w-full object-cover' ) ); ?>
						</a>
					</figure>
				<?php else : ?>
					<?php // 无特色图片时输出等高占位块，保证同一行卡片高度一致。 ?>
					<div class="flex aspect-[16/9] items-center justify-center bg-base-200 text-sm text-base-content/50">暂无封面</div>
				<?php endif; ?>
				<div class="card-body gap-2">
					<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/70">
						<?php
						// get_the_category() 返回当前文章的全部分类，逐个输出为 badge 链接以支持多分类。
						foreach ( get_the_category() as $xy_category ) :
							?>
							<a class="badge badge-sm badge-outline" href="<?php echo esc_url( get_category_link( $xy_category ) ); ?>"><?php echo esc_html( $xy_category->name ); ?></a>
							<?php
						endforeach;
						?>
						<time datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>"><?php echo esc_html( get_the_date( 'Y-m-d' ) ); ?></time>
						<?php
						// 评论数徽标（只算真实评论，且为 0 时不输出）。
						xy_mbx_comment_count_badge();
						?>
					</div>
					<h2 class="card-title text-lg">
						<a class="link link-hover line-clamp-2" href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
					</h2>
					<p class="text-sm text-base-content/70"><?php echo esc_html( wp_trim_words( get_the_excerpt(), 24, '…' ) ); ?></p>
					<a class="link link-primary text-sm" href="<?php the_permalink(); ?>">阅读全文 →</a>
				</div>
			</article>
			<?php
		endwhile;
		?>
	</div>
<?php endif; ?>
```

**注意事项**

- 卡片标题用 `h2`（页面 `h1` 已被列表页标题占用），保证每页只有一个 `h1`。
- 无图占位块与封面图都用 `aspect-[16/9]` 固定比例，卡片才能等高；封面的 `h-full w-full object-cover` 负责裁切不变形。
- 标题用 `line-clamp-2` 截两行；固定行数可避免高度参差。超长无空格串由 `theme.css` 的 `overflow-wrap: anywhere` 兜底。
- 摘要用 `wp_trim_words( get_the_excerpt(), 24, '…' )` 统一长度，不要直接输出 `the_excerpt()`。
- 本片段**依赖调用方已处于主循环上下文**（`have_posts()` / `the_post()` 可用），页面模板负责 `get_header()` 与容器。

---

## 3. 列表分页与空态

**说明**：有文章时输出分页；无文章时给出中文空态，搜索页空态带上搜索词。
**落点文件**：`template-parts/post-list.php`（接在片段 2 的卡片网格之后）。

```php
<?php
the_posts_pagination(
	array(
		'prev_text' => '上一页',
		'next_text' => '下一页',
		'mid_size'  => 1,
	)
);
?>
<?php elseif ( is_search() ) : ?>
	<p class="py-8 text-center text-base-content/60">
		<?php echo esc_html( '没有找到与「' . get_search_query() . '」相关的内容，试试其他关键词。' ); ?>
	</p>
<?php else : ?>
	<p class="py-8 text-center text-base-content/60">暂无内容。</p>
<?php endif; ?>
```

**注意事项**

- 分页外观来自 `assets/css/theme.css` 里的 `.pagination .nav-links` 与 `.page-numbers` 规则（对齐 daisyUI 按钮观感），不需要在模板里额外加类。
- 空态分支顺序固定：先 `is_search()`，再兜底「暂无内容」，否则搜索页会落到通用文案上。
- 搜索词必须 `esc_html()` 后再拼接输出。
- 分页依赖查询里的 `found_posts`；自定义查询若设了 `no_found_rows => true`（如片段 10 的相关文章）就**不要**再对它输出分页。

---

## 4. 评论列表容器

**说明**：评论区外壳（`id="comments"`）、评论列表、嵌套列表排版、评论分页、空态与表单入口。
**落点文件**：`comments.php`（由 `single.php` 的 `comments_template()` 调用）。

```php
<?php
// 需要密码才能阅读的文章不显示评论区。
if ( post_password_required() ) {
	return;
}

$xy_real_count = xy_mbx_real_comment_count();
?>
<section id="comments" class="card mt-6 bg-base-100 shadow-sm">
	<div class="card-body">
		<h2 class="card-title text-lg">
			评论
			<?php if ( $xy_real_count > 0 ) : ?>
				<span class="badge badge-sm"><?php echo esc_html( number_format_i18n( $xy_real_count ) ); ?></span>
			<?php endif; ?>
		</h2>

		<?php if ( have_comments() ) : ?>
			<?php if ( 0 === $xy_real_count ) : ?>
				<?php // 只有引用通告（没有读者评论）时说清楚，避免与下方列表产生歧义。 ?>
				<p class="text-sm text-base-content/70">还没有评论，来说两句吧。以下为本文的引用通告：</p>
			<?php endif; ?>
			<ol class="m-0 list-none p-0 [&_ol.children]:mt-2 [&_ol.children]:list-none [&_ol.children]:border-l [&_ol.children]:border-base-300 [&_ol.children]:pl-4 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
				<?php
				wp_list_comments(
					array(
						'callback'   => 'xy_mbx_comment_item',
						'style'      => 'ol',
						'format'     => 'html5',
						'short_ping' => true,
					)
				);
				?>
			</ol>

			<?php
			the_comments_pagination(
				array(
					'prev_text' => '上一页',
					'next_text' => '下一页',
					'mid_size'  => 1,
				)
			);
			?>
		<?php else : ?>
			<p class="text-sm text-base-content/70">还没有评论，来说两句吧。</p>
		<?php endif; ?>

		<?php if ( ! comments_open() ) : ?>
			<p class="mt-2 text-sm text-base-content/70">评论功能已关闭。</p>
		<?php endif; ?>

		<?php comment_form(); ?>
	</div>
</section>
```

**注意事项**

- 嵌套层级由 WordPress 输出为 `ol.children`，这里**用任意变体类名排版**（`[&_ol.children]:border-l` 等），因此不需要写任何自定义 CSS；改这些类名后要重新构建样式。
- `'format' => 'html5'` 与 `add_theme_support('html5', ['comment-list','comment-form'])` 配套，缺一不可。
- 空态有两种：真的一条评论都没有 →「还没有评论，来说两句吧。」；只有引用通告 → 追加一句「以下为本文的引用通告：」，因为 `have_comments()` 为真但真实评论数为 0。
- 评论数用 `xy_mbx_real_comment_count()`（片段 7），不要用 `get_comments_number()`，否则引用通告会让数字虚高。
- 评论区只在文章详情页输出；页面模板不要调用 `comments_template()`。

---

## 5. 评论项渲染回调（含占位头像与引用通告分支）

**说明**：`wp_list_comments()` 的单条评论回调；含彩色首字占位头像、待审核徽标、回复链接，以及引用通告的紧凑行分支。
**落点文件**：`inc/comments.php`。

```php
<?php
/**
 * 按昵称取占位头像的配色类（同一昵称结果固定）。
 *
 * @param string $name 评论者昵称。
 * @return string daisyUI 背景与前景色类名。
 */
function xy_mbx_avatar_color_class( $name ) {
	$xy_palette = array(
		'bg-primary text-primary-content',
		'bg-secondary text-secondary-content',
		'bg-accent text-accent-content',
		'bg-info text-info-content',
		'bg-success text-success-content',
		'bg-neutral text-neutral-content',
	);

	$xy_index = abs( crc32( (string) $name ) ) % count( $xy_palette );

	return $xy_palette[ $xy_index ];
}

/**
 * 取昵称首字：中文取第一个字，英文取首字母并大写。
 *
 * @param string $name 评论者昵称。
 * @return string 单个字符。
 */
function xy_mbx_avatar_initial( $name ) {
	$xy_name = trim( wp_strip_all_tags( (string) $name ) );

	if ( '' === $xy_name ) {
		return '?';
	}

	return mb_strtoupper( mb_substr( $xy_name, 0, 1 ) );
}

/**
 * 单条评论的渲染回调（由 wp_list_comments() 调用）。
 *
 * 注意：结束标签 </li> 由 Walker_Comment 自动补全，本回调不要输出。
 *
 * @param WP_Comment $comment 评论对象。
 * @param array      $args    wp_list_comments() 参数。
 * @param int        $depth   当前嵌套层级。
 */
function xy_mbx_comment_item( $comment, $args, $depth ) {
	// 引用通告（pingback / trackback）不是读者评论：用紧凑行展示「引用通告 + 来源标题」，
	// 不显示头像与「回复」入口，也不参与评论数统计。
	if ( in_array( (string) $comment->comment_type, array( 'pingback', 'trackback' ), true ) ) {
		?>
		<li id="comment-<?php comment_ID(); ?>" <?php comment_class( 'mt-3' ); ?>>
			<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/70">
				<span class="badge badge-xs badge-ghost">引用通告</span>
				<a class="link link-hover" href="<?php echo esc_url( get_comment_author_url( $comment ) ); ?>" rel="nofollow noopener" target="_blank"><?php echo esc_html( get_comment_author( $comment ) ); ?></a>
				<time class="text-xs text-base-content/50" datetime="<?php echo esc_attr( get_comment_time( 'c' ) ); ?>"><?php echo esc_html( get_comment_date( 'Y-m-d', $comment ) ); ?></time>
			</div>
		</li>
		<?php
		return;
	}

	$xy_author = get_comment_author( $comment );
	?>
	<li id="comment-<?php comment_ID(); ?>" <?php comment_class( 'mt-4' ); ?>>
		<div class="flex items-start gap-3">
			<div class="avatar avatar-placeholder" aria-hidden="true">
				<div class="w-10 rounded-full <?php echo esc_attr( xy_mbx_avatar_color_class( $xy_author ) ); ?>">
					<span class="text-base"><?php echo esc_html( xy_mbx_avatar_initial( $xy_author ) ); ?></span>
				</div>
			</div>
			<div class="min-w-0 flex-1">
				<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
					<span class="font-semibold"><?php echo esc_html( $xy_author ); ?></span>
					<time class="text-base-content/60" datetime="<?php echo esc_attr( get_comment_time( 'c' ) ); ?>"><?php echo esc_html( get_comment_date( 'Y-m-d H:i' ) ); ?></time>
					<?php if ( '0' === (string) $comment->comment_approved ) : ?>
						<span class="badge badge-warning badge-sm">待审核</span>
					<?php endif; ?>
				</div>
				<div class="xy-comment-text mt-1 text-sm">
					<?php comment_text(); ?>
				</div>
				<?php
				comment_reply_link(
					array_merge(
						(array) $args,
						array(
							'depth'      => $depth,
							'max_depth'  => isset( $args['max_depth'] ) ? $args['max_depth'] : 5,
							'reply_text' => '回复',
							'before'     => '<div class="mt-1 text-sm">',
							'after'      => '</div>',
						)
					)
				);
				?>
			</div>
		</div>
	<?php
}
```

**注意事项**

- 头像**不请求 Gravatar**，用昵称哈希在 `xy_mbx_avatar_color_class()` 的调色板里取色 + 首字生成占位头像；同一昵称永远同色（`crc32` 取模），国内访问无外链依赖。
- 引用通告分支只输出一行：`badge badge-xs badge-ghost`「引用通告」+ 来源链接（`rel="nofollow noopener" target="_blank"`），**无头像、无回复入口**，并保持 `comment_type` 判断在回调最前面。
- 「回复」链接依赖 `comment-reply` 脚本（`functions.php` 中按 `is_singular() && comments_open() && get_option('thread_comments')` 入队），并依赖 `comment_reply_link()` 的 `max_depth`。
- `comment_class()` 必须留在 `<li>` 上，WordPress 靠它输出层级与状态类；`</li>` 由 walker 自动补，回调里不要写。
- `xy-comment-text` 是给评论正文预留的钩子类名（参考实现中未在 `theme.css` 定义规则），不要指望它有样式。

---

## 6. 评论表单字段与按钮的 daisyUI 样式

**说明**：通过 `comment_form_defaults` 过滤器把表单字段、按钮换成 daisyUI 输入框 / 按钮，并回填已留言访客的昵称、邮箱、网址。
**落点文件**：`inc/comments.php`。

```php
<?php
/**
 * 评论表单的字段与按钮样式（daisyUI）。
 *
 * @param array $defaults comment_form() 的默认参数。
 * @return array 调整后的参数。
 */
function xy_mbx_comment_form_defaults( $defaults ) {
	$defaults['title_reply']        = '发表评论';
	$defaults['title_reply_before'] = '<h3 id="reply-title" class="mt-6 text-lg font-semibold">';
	$defaults['title_reply_after']  = '</h3>';

	$defaults['comment_notes_before'] = '<p class="label">邮箱不会公开显示，带 * 的为必填项。</p>';

	$defaults['class_form']   = 'mt-3 space-y-3';
	$defaults['class_submit'] = 'btn btn-primary btn-sm';

	$defaults['comment_field'] = '<label class="label" for="comment">评论内容 *</label>'
		. '<textarea id="comment" name="comment" class="textarea w-full" rows="4" required aria-required="true"></textarea>';

	// 已登录或曾留言过的访客，回填其昵称/邮箱/网址。
	$xy_commenter = wp_get_current_commenter();

	$defaults['fields'] = array(
		'author' => '<label class="label" for="author">昵称 *</label>'
			. '<input id="author" name="author" type="text" class="input w-full" value="' . esc_attr( $xy_commenter['comment_author'] ) . '" required aria-required="true" />',
		'email'  => '<label class="label" for="email">邮箱 *</label>'
			. '<input id="email" name="email" type="email" class="input w-full" value="' . esc_attr( $xy_commenter['comment_author_email'] ) . '" required aria-required="true" />',
		'url'    => '<label class="label" for="url">网站</label>'
			. '<input id="url" name="url" type="url" class="input w-full" value="' . esc_attr( $xy_commenter['comment_author_url'] ) . '" />',
	);

	return $defaults;
}
add_filter( 'comment_form_defaults', 'xy_mbx_comment_form_defaults' );
```

**注意事项**

- 表单控件 id / name（`author`、`email`、`url`、`comment`）不能改，WordPress 靠它们接收数据。
- `title_reply_before` / `title_reply_after` 用于给「发表评论」标题挂类名；h3 层级满足「每页一个 `h1`」的约定。
- 输入框用 `input w-full` / `textarea w-full`（不带 `input-bordered` 等旧版类，daisyUI 5 已内置边框）；提交按钮 `btn btn-primary btn-sm`。
- 不要在这里 `echo` 表单，`comment_form()` 才是唯一入口。

---

## 7. 评论数徽标

**说明**：只统计真实评论（排除引用通告），仅在数量大于 0 时输出；可选渲染成跳 `#comments` 的链接。
**落点文件**：`inc/comments.php`；调用点 `single.php`（传 `true`）与 `template-parts/post-list.php`（默认）。

```php
<?php
/**
 * 取「真实评论」数量：排除 pingback / trackback 引用通告。
 *
 * 说明：get_comments_number() 会把引用通告也算作评论，导致评论数偏大（文章互相内链会
 * 产生自引用 pingback），因此前台展示统一用本函数。
 *
 * @param int|null $post_id 文章 ID，默认当前文章。
 * @return int 已批准的真实评论数。
 */
function xy_mbx_real_comment_count( $post_id = null ) {
	$xy_post_id = $post_id ? (int) $post_id : (int) get_the_ID();

	if ( $xy_post_id < 1 ) {
		return 0;
	}

	return (int) get_comments(
		array(
			'post_id' => $xy_post_id,
			'type'    => 'comment',
			'status'  => 'approve',
			'count'   => true,
		)
	);
}

/**
 * 输出评论数徽标（仅在真实评论数大于 0 时输出）。
 *
 * @param int|null $post_id            文章 ID，默认当前文章。
 * @param bool     $link_to_comments   是否渲染为跳到评论区的链接（详情页用）。
 */
function xy_mbx_comment_count_badge( $post_id = null, $link_to_comments = false ) {
	$xy_count = xy_mbx_real_comment_count( $post_id );

	if ( $xy_count < 1 ) {
		return;
	}

	$xy_label = esc_html( number_format_i18n( $xy_count ) );
	$xy_icon  = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3.5 w-3.5" aria-hidden="true"><path fill-rule="evenodd" d="M18 10c0 3.866-3.582 7-8 7a8.84 8.84 0 0 1-2.347-.306c-.584.296-1.925.864-4.181 1.234-.2.032-.352-.176-.273-.362.354-.836.674-1.95.77-2.966C2.564 13.698 2 11.937 2 10c0-3.866 3.582-7 8-7s8 3.134 8 7Z" clip-rule="evenodd" /></svg>';

	if ( $link_to_comments ) {
		echo '<a class="badge badge-sm badge-ghost gap-1" href="#comments" aria-label="查看评论">' . $xy_icon . ' ' . $xy_label . '</a>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 图标为本函数内固定字符串，文字已转义。
		return;
	}

	echo '<span class="badge badge-sm badge-ghost gap-1">' . $xy_icon . ' ' . $xy_label . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 同上。
}
```

**注意事项**

- 数量为 0 时函数**直接返回、不输出任何内容**，因此调用处不需要写 `if`。
- 首页 / 归档列表的卡片调用 `xy_mbx_comment_count_badge()`（不传参，走当前文章）；文章详情页调用 `xy_mbx_comment_count_badge( null, true )` 输出跳 `#comments` 的链接。
- 图标是函数内固定字符串（非用户输入），文字已 `esc_html()`，故用 `phpcs:ignore` 标注而非二次转义。
- 数字用 `number_format_i18n()` 输出，跟随站点数字格式。

---

## 8. 侧栏容器 + sticky 写法

**说明**：详情页右栏容器与内部 sticky 滚动容器；后续新增侧栏模块只需往里追加 `get_template_part()`。
**落点文件**：`single.php`（两栏布局的右栏）。

```php
<div class="flex flex-col gap-6 lg:grid lg:grid-cols-[minmax(0,1fr)_20rem] lg:gap-8">
	<?php // 左栏第 1 行：正文与上下篇导航。 ?>
	<div class="min-w-0 lg:col-start-1 lg:row-start-1">
		<?php // 正文卡片、上下篇导航 ?>
	</div>

	<?php
	// 右栏：跨两行（正文行 + 评论区行），因此其内部可以 sticky 悬浮整个正文区；
	// 侧栏模块直接加在下面这个 sticky 容器里即可（相关文章 / 目录 / 订阅 等）。
	?>
	<aside class="lg:col-start-2 lg:row-span-2 lg:row-start-1" aria-label="侧栏">
		<div class="space-y-4 lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
			<?php get_template_part( 'template-parts/related-posts' ); ?>
			<?php // 后续追加模块：get_template_part( 'template-parts/toc' ); 等 ?>
		</div>
	</aside>

	<?php // 左栏第 2 行：评论区（评论列表 + 发表评论表单），模板为 comments.php。 ?>
	<div class="lg:col-start-1 lg:row-start-2">
		<?php comments_template(); ?>
	</div>
</div>
```

**注意事项**

- 外层 `lg:grid-cols-[minmax(0,1fr)_20rem]` 里 `minmax(0,1fr)` 的 `min-0` 必须保留，否则正文里的长代码块 / 宽表格会把左栏顶宽、右栏被挤扁；左栏容器同时要带 `min-w-0`。
- 右栏 `lg:row-span-2` 跨「正文行 + 评论区行」，`lg:row-start-1` 从第一行开始；评论区必须是左栏第 2 行（`lg:col-start-1 lg:row-start-2`）才能形成两栏错位布局。
- 窄屏顺序由 DOM 决定：正文 → 侧栏模块 → 评论区，无需额外 `order` 类。
- sticky 容器带 `lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto`：侧栏模块多于视口高度时自身滚动，不会把 sticky 拉坏。
- 模块间距由容器的 `space-y-4` 统一负责，模块自身**不要**再加 `mt-*`。

---

## 9. 侧栏卡片外壳（品牌渐变描边）

**说明**：1px 品牌渐变描边（外层 `p-px` + 内层圆角减 1px）+ 标题渐变竖条，是侧栏所有模块的统一外观。
**落点文件**：`template-parts/related-posts.php`（可复用到任意侧栏模块）。

```php
<?php
// 外层的 1px 渐变描边（品牌紫蓝 → 青蓝）+ 内层卡片，形成「特色边框」而无需自定义 CSS。
?>
<div class="rounded-box bg-linear-to-br from-primary/60 via-secondary/30 to-info/60 p-px shadow-sm">
	<section class="card rounded-[calc(var(--radius-box)-1px)] bg-base-100" aria-labelledby="xy-related-heading">
		<div class="card-body gap-4">
			<h2 id="xy-related-heading" class="card-title text-base">
				<span class="inline-block h-4 w-1 rounded-full bg-linear-to-b from-primary to-info" aria-hidden="true"></span>
				相关文章
			</h2>
			<?php // 模块内容：窄侧栏推荐「每行一条」的紧凑列表。 ?>
		</div>
	</section>
</div>
```

**注意事项**

- 描边靠「外层渐变背景 + `p-px` + 内层不透明卡片」实现：内层必须是 `bg-base-100`（不透），否则渐变会透上来。
- 内层圆角写 `rounded-[calc(var(--radius-box)-1px)]`：比外层 `rounded-box` 少 1px，描边在四角才均匀（daisyUI 主题变量，勿写死具体尺寸）。
- 阴影只加在外层（`shadow-sm`），内层不加，否则边缘会出现双层阴影。
- 每个模块的标题 `id` 不能重复（如 `xy-related-heading`），并用 `aria-labelledby` 关联到 `<section>`。
- 标题竖条是纯装饰，必须带 `aria-hidden="true"`；标题字号在侧栏统一降到 `text-base`。

---

## 10. 相关文章取数（同分类优先 + 最新补齐）

**说明**：详情页侧栏内链模块，同分类优先、不足用最新文章补齐、始终排除当前文章，取不到任何结果时整块不输出。
**落点文件**：`template-parts/related-posts.php`。

```php
<?php
// 本模块展示的相关文章数量。
$xy_related_limit = 4;

// 1. 同分类优先：同分类不足时下面再补齐，这里先按数量上限取。
$xy_related_posts        = array();
$xy_related_category_ids = wp_list_pluck( get_the_category(), 'term_id' );

if ( ! empty( $xy_related_category_ids ) ) {
	$xy_related_category_query = new WP_Query(
		array(
			'category__in'        => $xy_related_category_ids,
			'posts_per_page'      => $xy_related_limit,
			'post__not_in'        => array( get_the_ID() ),
			'no_found_rows'       => true,
			'ignore_sticky_posts' => true,
		)
	);
	$xy_related_posts = $xy_related_category_query->posts;
}

// 2. 补齐：排除当前文章与已收录文章，用最新文章补足。
$xy_related_exclude_ids = array_merge( array( get_the_ID() ), wp_list_pluck( $xy_related_posts, 'ID' ) );
$xy_related_missing     = $xy_related_limit - count( $xy_related_posts );

if ( $xy_related_missing > 0 ) {
	$xy_related_fill_query = new WP_Query(
		array(
			'posts_per_page'      => $xy_related_missing,
			'post__not_in'        => $xy_related_exclude_ids,
			'no_found_rows'       => true,
			'ignore_sticky_posts' => true,
		)
	);
	$xy_related_posts = array_merge( $xy_related_posts, $xy_related_fill_query->posts );
}

// 无相关文章时整块不输出。
if ( empty( $xy_related_posts ) ) {
	return;
}
?>
<div class="rounded-box bg-linear-to-br from-primary/60 via-secondary/30 to-info/60 p-px shadow-sm">
	<section class="card rounded-[calc(var(--radius-box)-1px)] bg-base-100" aria-labelledby="xy-related-heading">
		<div class="card-body gap-4">
			<h2 id="xy-related-heading" class="card-title text-base">
				<span class="inline-block h-4 w-1 rounded-full bg-linear-to-b from-primary to-info" aria-hidden="true"></span>
				相关文章
			</h2>
			<ul class="flex flex-col gap-3">
				<?php
				foreach ( $xy_related_posts as $xy_related_post ) :
					// setup_postdata() 本身不写全局 $post，需按官方用法同步赋值，模板标签才会取自当前相关文章。
					$GLOBALS['post'] = $xy_related_post;
					setup_postdata( $xy_related_post );
					?>
					<li>
						<a class="group flex items-start gap-3 rounded-box border-l-2 border-base-300/70 py-1.5 pr-1 pl-3 transition-colors hover:border-primary hover:bg-base-200/60" href="<?php the_permalink(); ?>">
							<figure class="aspect-[16/9] w-24 shrink-0 overflow-hidden rounded-box bg-base-200 ring-1 ring-base-300/70">
								<?php if ( has_post_thumbnail() ) : ?>
									<?php the_post_thumbnail( 'medium', array( 'class' => 'h-full w-full object-cover', 'loading' => 'lazy' ) ); ?>
								<?php endif; ?>
							</figure>
							<div class="min-w-0 flex-1">
								<h3 class="text-sm leading-snug font-medium line-clamp-2 group-hover:text-primary"><?php the_title(); ?></h3>
								<time class="text-xs text-base-content/60" datetime="<?php echo esc_attr( get_the_date( 'c' ) ); ?>"><?php echo esc_html( get_the_date( 'Y-m-d' ) ); ?></time>
							</div>
						</a>
					</li>
					<?php
				endforeach;
				?>
			</ul>
		</div>
	</section>
</div>
<?php
// 本片段在 setup_postdata() 后必须还原全局 $post，避免影响调用方的主循环。
wp_reset_postdata();
```

**注意事项**

- `post__not_in` 必须带 `get_the_ID()`，否则会出现「相关文章就是当前文章」。
- 两处查询都用 `no_found_rows => true`（不需要总数、省一次 `SQL_CALC_FOUND_ROWS`）与 `ignore_sticky_posts => true`（置顶文章不应霸占侧栏）。
- `setup_postdata()` 后必须把 `$GLOBALS['post']` 一起换掉（仅调用 `setup_postdata()` 不会更新全局 `$post`），结尾必须 `wp_reset_postdata()`，否则会污染调用方的主循环。
- 缩略图用 `medium` 尺寸 + `loading="lazy"`；无缩略图时 `<figure>` 留作灰色占位块（`bg-base-200` + `ring-1`），保持每行高度一致。
- 侧栏宽度只有 20rem，条目用「缩略图 + 标题 + 日期」两列结构，标题 `line-clamp-2` 截两行。
- `if ( empty( $xy_related_posts ) ) { return; }` 保证站点文章不足时整块不输出，不留空卡片。

---

## 11. 图集与灯箱【推荐实现（新增）】

**说明**：接管 WordPress 图集输出，改成「等宽方格缩略图 + daisyUI 锚点灯箱」；锚点式 modal 由 daisyUI 的 `.modal` + `:target` 支持，**不需要 JS，也不需要自定义 CSS**。
**落点文件**：`inc/gallery.php`（在 `functions.php` 里 `require_once` 加载）。

```php
<?php
/**
 * 图集与灯箱（推荐实现）：把 [gallery] 输出改成「等宽方格缩略图 + daisyUI 锚点灯箱」。
 *
 * 依赖：functions.php 已声明 add_theme_support( 'html5', array( 'gallery' ) )。
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 接管 [gallery] 短代码输出。
 *
 * @param string $output   默认输出（未写 ids 时沿用，避免行为分叉）。
 * @param array  $attr     短代码属性。
 * @param int    $instance 图集序号（自增，用于生成唯一弹窗 id）。
 * @return string 图集 HTML。
 */
function xy_mbx_gallery_lightbox( $output, $attr, $instance ) {
	$attr = shortcode_atts(
		array(
			'ids'  => '',
			'size' => 'medium',
		),
		$attr,
		'gallery'
	);

	$ids = array_filter( array_map( 'absint', explode( ',', (string) $attr['ids'] ) ) );

	// 未指定 ids 的图集（按当前文章附件自动收集）沿用 WordPress 默认输出。
	if ( empty( $ids ) ) {
		return $output;
	}

	$grid   = '<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">';
	$modals = '';

	foreach ( $ids as $attachment_id ) {
		$thumb = wp_get_attachment_image(
			$attachment_id,
			$attr['size'],
			false,
			array( 'class' => 'aspect-square w-full rounded-box object-cover', 'loading' => 'lazy' )
		);

		if ( '' === $thumb ) {
			continue;
		}

		$modal_id = 'xy-gallery-' . (int) $instance . '-' . (int) $attachment_id;
		$full_url = (string) wp_get_attachment_image_url( $attachment_id, 'large' );
		$alt      = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );

		// 缩略图指向弹窗锚点；关闭用 <a href="#">（daisyUI 锚点 modal 的标准用法）。
		$grid .= '<a class="block" href="#' . esc_attr( $modal_id ) . '">' . $thumb . '</a>';

		$modals .= '<div class="modal" role="dialog" id="' . esc_attr( $modal_id ) . '" aria-label="' . esc_attr( $alt ) . '">'
			. '<div class="modal-box max-w-4xl bg-base-100 p-2">'
			. '<img class="h-auto w-full rounded-box" src="' . esc_url( $full_url ) . '" alt="' . esc_attr( $alt ) . '" loading="lazy">'
			. '</div>'
			. '<a class="modal-backdrop" href="#">关闭</a>'
			. '</div>';
	}

	return $grid . '</div>' . $modals;
}
add_filter( 'post_gallery', 'xy_mbx_gallery_lightbox', 10, 3 );
```

**最小替代方案（不接管输出）**：若只想要网格排版、放大沿用 WordPress 默认的「点击进附件页」，则不加过滤器，只给正文容器补任意变体类名：

```php
<div class="xy-entry [&_.gallery]:grid [&_.gallery]:grid-cols-2 [&_.gallery]:gap-3 sm:[&_.gallery]:grid-cols-3 [&_.gallery-item]:m-0 [&_.gallery-icon_img]:w-full [&_.gallery-icon_img]:rounded-box [&_.gallery-icon_img]:object-cover"><?php the_content(); ?></div>
```

**注意事项**

- 只用 `[gallery ids="1,2,3"]` 形式接管；**未写 `ids` 的图集沿用 WordPress 默认输出**，避免两种渲染路径并存导致行为不一致（要接管自动收集，自行用 `get_children()` 取当前文章附件）。
- 弹窗用 `<div class="modal" role="dialog">` 而不是 `<dialog>`：`:target` 方案靠纯 CSS 生效，`<dialog>` 必须 `showModal()` 才能打开。
- daisyUI 锚点 modal 关闭时 `href="#"` 会把 URL 的 hash 清掉并回到页首——这是无 JS 方案的取舍；不可接受时改用 `<dialog class="modal">` + 一段约 5 行的 `showModal()` / `close()` 原生 JS。
- 缩略图用 `aspect-square` + `object-cover` 保证方格整齐；大图取 `large` 尺寸，不要直接输出原图。
- `$instance` 参与弹窗 id，同一页多个图集也不会撞 id。

---

## 12. 视频嵌入【推荐实现（新增）】

**说明**：统一输出自适应播放器——自托管视频用原生 `<video>`，站外视频（YouTube / B 站）用 `aspect-video` 包裹 `<iframe>`。
**落点文件**：`inc/media.php`（在 `functions.php` 里 `require_once` 加载）；调用点：正文模板或自定义字段。

```php
<?php
/**
 * 视频嵌入（推荐实现）：统一输出自适应播放器。
 *
 * 用法：echo xy_mbx_video_embed( 'https://www.bilibili.com/video/BV1xx411c7mD' );
 *      自托管：xy_mbx_video_embed( 'https://example.com/video.mp4', '开场演示' );
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 把常见站外视频地址规范化为可嵌入地址；无法识别时原样返回（调用方自行决定是否当自托管处理）。
 *
 * @param string $url 原始视频地址。
 * @return string 可嵌入地址。
 */
function xy_mbx_video_embed_url( $url ) {
	$url = trim( (string) $url );

	if ( '' === $url ) {
		return '';
	}

	// YouTube：watch?v=ID 或 youtu.be/ID → embed 地址。
	if ( preg_match( '~(?:youtube\.com/watch\?v=|youtu\.be/)([A-Za-z0-9_-]{6,})~i', $url, $matches ) ) {
		return 'https://www.youtube.com/embed/' . $matches[1];
	}

	// B 站：/video/BVxxxx → 官方播放器（关弹幕、高清优先）。
	if ( preg_match( '~bilibili\.com/video/(BV[A-Za-z0-9]+)~i', $url, $matches ) ) {
		return 'https://player.bilibili.com/player.html?bvid=' . $matches[1] . '&high_quality=1&danmaku=0';
	}

	return $url;
}

/**
 * 输出自适应视频播放器。
 *
 * @param string $url   视频地址（站外页面地址或自托管文件地址）。
 * @param string $title 无障碍标题，同时作为站外 iframe 的 title。
 * @return string 可直接输出的 HTML；地址为空时返回空字符串。
 */
function xy_mbx_video_embed( $url, $title = '视频' ) {
	$embed = xy_mbx_video_embed_url( $url );

	if ( '' === $embed ) {
		return '';
	}

	// 自托管视频：浏览器原生控件播放，不依赖第三方。
	if ( preg_match( '~\.(mp4|webm|ogv|ogg|mov)(\?.*)?$~i', $embed ) ) {
		return '<figure class="my-4">'
			. '<video class="h-auto w-full rounded-box bg-base-200" controls preload="metadata" playsinline>'
			. '<source src="' . esc_url( $embed ) . '">'
			. '你的浏览器不支持视频播放，可<a href="' . esc_url( $embed ) . '">下载后观看</a>。'
			. '</video>'
			. '</figure>';
	}

	// 站外视频：aspect-video 锁 16:9，容器宽度自适应，窄屏不横向溢出。
	return '<figure class="my-4">'
		. '<div class="aspect-video w-full overflow-hidden rounded-box bg-base-200">'
		. '<iframe class="h-full w-full" src="' . esc_url( $embed ) . '" title="' . esc_attr( $title ) . '" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen" allowfullscreen></iframe>'
		. '</div>'
		. '</figure>';
}
```

**注意事项**

- 站外视频**默认优先**（省带宽与 CDN 成本）；自托管只留给短片或需要防盗链的场景，且视频文件不要进主题目录，放媒体库或对象存储。
- 响应式靠 `aspect-video` 容器（16:9）+ `h-full w-full`，**不要**给 iframe 写死 `width` / `height` 属性，否则窄屏溢出。
- `preload="metadata"` 只预取元信息，避免首屏把整个视频文件拖下来；`playsinline` 让 iOS 不强制全屏。
- 时长 / 分辨率建议存成文章自定义字段（`inc/meta.php`），列表卡片上单独展示，不要塞进标题文字里。
- 输出一律 `esc_url()` / `esc_attr()`；`<iframe>` 的 `allowfullscreen` 是布尔属性，不要写成 `allowfullscreen="true"`。

---

## 13. 音频播放器【推荐实现（新增）】

**说明**：自托管音频用原生 `<audio controls>`，配一行曲目信息；曲目列表用紧凑行，每行可点击。
**落点文件**：`inc/media.php`（播放器函数）+ `template-parts/track-list.php`（曲目列表）。

```php
<?php
/**
 * 音频播放器（推荐实现）：自托管音频 + 曲目信息。
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 输出单个音频播放器。
 *
 * @param string $url   音频文件地址（mp3 / ogg / m4a）。
 * @param string $title 曲名，可为空。
 * @param string $meta  附加说明（如「320kbps · 04:12」），可为空。
 * @return string 可直接输出的 HTML；地址为空时返回空字符串。
 */
function xy_mbx_audio_player( $url, $title = '', $meta = '' ) {
	$url = trim( (string) $url );

	if ( '' === $url ) {
		return '';
	}

	$info = '';

	if ( '' !== trim( (string) $title ) || '' !== trim( (string) $meta ) ) {
		$info = '<div class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">'
			. ( '' !== trim( (string) $title ) ? '<span class="font-medium">' . esc_html( $title ) . '</span>' : '' )
			. ( '' !== trim( (string) $meta ) ? '<span class="text-base-content/70">' . esc_html( $meta ) . '</span>' : '' )
			. '</div>';
	}

	return '<div class="rounded-box bg-base-200/60 p-3">'
		. '<audio class="w-full" controls preload="metadata">'
		. '<source src="' . esc_url( $url ) . '">'
		. '你的浏览器不支持音频播放，可<a href="' . esc_url( $url ) . '">下载音频</a>。'
		. '</audio>'
		. $info
		. '</div>';
}
```

曲目列表（`template-parts/track-list.php`，`$xy_tracks` 由调用方准备，每项含 `no` / `title` / `url` / `time`）：

```php
<?php
// $xy_tracks 形如：array( array( 'no' => 1, 'title' => '曲名', 'url' => '...', 'time' => '04:12' ), ... )
if ( empty( $xy_tracks ) ) {
	return;
}
?>
<ol class="m-0 flex list-none flex-col gap-1 p-0">
	<?php foreach ( $xy_tracks as $xy_track ) : ?>
		<li>
			<a class="group flex items-center gap-3 rounded-box px-3 py-2 transition-colors hover:bg-base-200/60" href="<?php echo esc_url( $xy_track['url'] ); ?>">
				<span class="badge badge-sm badge-ghost"><?php echo esc_html( str_pad( (string) $xy_track['no'], 2, '0', STR_PAD_LEFT ) ); ?></span>
				<span class="min-w-0 flex-1 truncate group-hover:text-primary"><?php echo esc_html( $xy_track['title'] ); ?></span>
				<time class="text-xs text-base-content/60"><?php echo esc_html( $xy_track['time'] ); ?></time>
			</a>
		</li>
	<?php endforeach; ?>
</ol>
```

**注意事项**

- 播放器用浏览器原生 `<audio>`，不引入任何第三方播放器库：无外链、无版权与隐私问题，样式用 `w-full` 撑满容器即可。
- 想要「列表内直接试听」时，把上面 `<a>` 行换成 `xy_mbx_audio_player( $xy_track['url'], $xy_track['title'], $xy_track['time'] )`——每首一个 `<audio>`，依然零 JS。
- 音频文件放媒体库或对象存储，**不要**打包进主题；`<source>` 只写一个地址时省略 `type` 也无妨，但写上 `type="audio/mpeg"` 可让浏览器少一次探测。
- 单页曲目多时不要都设 `preload="auto"`，否则首屏会并发拉取音频，`preload="metadata"` 是底线。
- 时长建议在入库时就写成文本（`04:12`），不要在前端用 JS 读取 `duration` 再渲染，避免骨架抖动。

---

## 14. 目录 TOC【推荐实现（新增）】

**说明**：在 `the_content` 过滤器中给正文 `h2` / `h3` 注入锚点 id，并缓存标题结构；侧栏模板读取结构渲染目录，链接与正文 id 天然一致。
**落点文件**：`inc/toc.php`（逻辑，在 `functions.php` 里 `require_once` 加载）+ `template-parts/toc.php`（侧栏模块）。

```php
<?php
/**
 * 目录 TOC（推荐实现）：给正文 h2/h3 注入锚点 id，并把标题结构交给侧栏模块渲染。
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 标题结构缓存（由 xy_mbx_add_heading_anchors() 写入，由 xy_mbx_get_toc_headings() 读取）。
 *
 * @var array
 */
$GLOBALS['xy_mbx_toc_headings'] = array();

/**
 * 给正文的 h2 / h3 注入锚点 id，并缓存标题结构。
 *
 * @param string $content 正文 HTML。
 * @return string 处理后的正文 HTML。
 */
function xy_mbx_add_heading_anchors( $content ) {
	// 只在文章详情页的主循环正文上生效，避免影响摘要、小工具与后台。
	if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
		return $content;
	}

	$headings = array();
	$used     = array();

	$content = preg_replace_callback(
		'/<h([23])([^>]*)>(.*?)<\/h\1>/is',
		function ( $matches ) use ( &$headings, &$used ) {
			$text = trim( wp_strip_all_tags( $matches[3] ) );

			if ( '' === $text ) {
				return $matches[0];
			}

			$slug = sanitize_title( $text );
			$slug = ( '' === $slug ) ? 'section' : $slug;

			// 同名标题追加序号，保证 id 在页内唯一。
			if ( isset( $used[ $slug ] ) ) {
				$used[ $slug ]++;
				$slug .= '-' . $used[ $slug ];
			} else {
				$used[ $slug ] = 1;
			}

			// 正文已手写 id 时保留原 id，目录链接指向手写值。
			$manual_id = '';
			if ( preg_match( '/\sid\s*=\s*"([^"]*)"/i', $matches[2], $id_match ) ) {
				$manual_id = $id_match[1];
			}

			$headings[] = array(
				'level' => (int) $matches[1],
				'text'  => $text,
				'slug'  => ( '' !== $manual_id ) ? $manual_id : $slug,
			);

			if ( '' !== $manual_id ) {
				return $matches[0];
			}

			return '<h' . $matches[1] . ' id="' . esc_attr( $slug ) . '"' . $matches[2] . '>' . $matches[3] . '</h' . $matches[1] . '>';
		},
		$content
	);

	$GLOBALS['xy_mbx_toc_headings'] = $headings;

	return $content;
}
add_filter( 'the_content', 'xy_mbx_add_heading_anchors', 12 );

/**
 * 读取当前正文的标题结构（由侧栏目录模块调用）。
 *
 * @return array 每项含 level / text / slug。
 */
function xy_mbx_get_toc_headings() {
	return isset( $GLOBALS['xy_mbx_toc_headings'] ) ? (array) $GLOBALS['xy_mbx_toc_headings'] : array();
}
```

侧栏模块 `template-parts/toc.php`：

```php
<?php
$xy_toc_headings = xy_mbx_get_toc_headings();

// 标题少于 2 个时目录没有意义，整块不输出。
if ( count( $xy_toc_headings ) < 2 ) {
	return;
}
?>
<div class="rounded-box bg-linear-to-br from-primary/60 via-secondary/30 to-info/60 p-px shadow-sm">
	<section class="card rounded-[calc(var(--radius-box)-1px)] bg-base-100" aria-labelledby="xy-toc-heading">
		<div class="card-body gap-3">
			<h2 id="xy-toc-heading" class="card-title text-base">
				<span class="inline-block h-4 w-1 rounded-full bg-linear-to-b from-primary to-info" aria-hidden="true"></span>
				目录
			</h2>
			<nav aria-label="文章目录">
				<ul class="flex flex-col gap-1 text-sm">
					<?php foreach ( $xy_toc_headings as $xy_toc_heading ) : ?>
						<li class="<?php echo ( 3 === (int) $xy_toc_heading['level'] ) ? 'pl-4' : ''; ?>">
							<a class="link link-hover text-base-content/80" href="#<?php echo esc_attr( $xy_toc_heading['slug'] ); ?>"><?php echo esc_html( $xy_toc_heading['text'] ); ?></a>
						</li>
					<?php endforeach; ?>
				</ul>
			</nav>
		</div>
	</section>
</div>
```

在 `single.php` 的 sticky 容器里调用（放在相关文章之前）：

```php
<?php get_template_part( 'template-parts/toc' ); ?>
<?php get_template_part( 'template-parts/related-posts' ); ?>
```

**注意事项**

- **放在侧栏最合适**：目录是典型的「可悬浮、与正文弱相关、可插拔」模块——右栏 `lg:sticky` 让它随阅读滚动始终可见，正文区则不必为它让出首屏空间。
- 执行顺序：`single.php` 中左栏（含 `the_content()`）先于 `<aside>` 渲染，所以侧栏读得到缓存；若把侧栏挪到正文之前，TOC 会变成空数组。
- `the_content` 过滤器加了 `is_admin() / is_singular() / in_the_loop() / is_main_query()` 四重判断，避免影响摘要、小工具与后台渲染。
- 手写 `id` 的标题保留原 id（目录指向它）；但两处手写同 id 无法自动去重，这属于编辑内容问题。
- 需要平滑滚动时，在 `header.php` 的 `<html>` 上加 Tailwind 的 `scroll-smooth` 类（记得重新构建样式），不要写自定义 CSS。
- 目录链接的 `href="#slug"` 与正文注入的 id 来自同一趟解析，不要分别实现两套 slug 规则。

---

## 15. 评分 / 点赞【推荐实现（新增）】

**说明**：两种最小实现——(A) 文章点赞只用本地存储，不落库；(B) 评论评分写入 `comment_meta`，随评论一起审核与展示。
**落点文件**：`inc/feedback.php`（PHP 部分）+ `assets/js/mailbox.js` 或独立脚本（前端部分）+ `single.php`（按钮标记）。

### A. 文章点赞（纯 localStorage，无后端）

```html
<button type="button" id="xy-like" class="btn btn-sm btn-outline gap-1" data-post-id="<?php echo esc_attr( (string) get_the_ID() ); ?>">
	点赞 <span id="xy-like-state" class="badge badge-sm badge-ghost">未点赞</span>
</button>
```

```js
// 本地点赞：只记录「本浏览器是否点过」，不做真实计数（见注意事项）。
( function () {
	var button = document.getElementById( 'xy-like' );
	var label  = document.getElementById( 'xy-like-state' );

	if ( ! button || ! label ) {
		return;
	}

	var key   = 'xy_liked_' + button.dataset.postId;
	var liked = false;

	try {
		liked = window.localStorage.getItem( key ) === '1';
	} catch ( error ) {
		// 隐私模式下 localStorage 不可用：退化为「会话内可点赞」，不报错。
	}

	label.textContent = liked ? '已点赞' : '未点赞';

	button.addEventListener( 'click', function () {
		if ( liked ) {
			return;
		}

		liked = true;
		label.textContent = '已点赞';

		try {
			window.localStorage.setItem( key, '1' );
		} catch ( error ) {
			// 写入失败不影响当前页面状态。
		}
	} );
}() );
```

### B. 评论评分（`comment_meta`，1–5 星）

```php
<?php
/**
 * 评论评分（推荐实现）：把 1–5 星存进 comment meta，列表里用 daisyUI rating 只读展示。
 */

// 防止直接访问该文件。
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * 在评论表单里追加评分字段（登录与未登录两种表单都要挂）。
 */
function xy_mbx_comment_rating_field() {
	?>
	<fieldset class="mt-2">
		<legend class="label">评分</legend>
		<div class="rating rating-sm">
			<?php for ( $i = 1; $i <= 5; $i++ ) : ?>
				<input type="radio" name="xy_rating" class="mask mask-star-2 bg-warning" value="<?php echo esc_attr( (string) $i ); ?>" <?php checked( 5, $i ); ?> aria-label="<?php echo esc_attr( $i . ' 星' ); ?>" />
			<?php endfor; ?>
		</div>
	</fieldset>
	<?php
}
add_action( 'comment_form_logged_in_after', 'xy_mbx_comment_rating_field' );
add_action( 'comment_form_after_fields', 'xy_mbx_comment_rating_field' );

/**
 * 保存评分到 comment meta（不在 1–5 范围内则忽略）。
 *
 * @param int $comment_id 评论 ID。
 */
function xy_mbx_save_comment_rating( $comment_id ) {
	$rating = isset( $_POST['xy_rating'] ) ? absint( wp_unslash( $_POST['xy_rating'] ) ) : 0;

	if ( $rating >= 1 && $rating <= 5 ) {
		update_comment_meta( (int) $comment_id, 'xy_rating', $rating );
	}
}
add_action( 'comment_post', 'xy_mbx_save_comment_rating' );

/**
 * 在评论项里输出只读星级（无评分时什么都不输出）。
 *
 * @param int $comment_id 评论 ID。
 */
function xy_mbx_render_comment_rating( $comment_id ) {
	$rating = (int) get_comment_meta( (int) $comment_id, 'xy_rating', true );

	if ( $rating < 1 ) {
		return;
	}

	echo '<div class="rating rating-xs mt-1" aria-label="评分 ' . esc_attr( (string) $rating ) . ' 星">';

	for ( $i = 1; $i <= 5; $i++ ) {
		echo '<input type="radio" class="mask mask-star-2 bg-warning" disabled' . checked( $i, $rating, false ) . ' aria-label="' . esc_attr( $i . ' 星' ) . '" />';
	}

	echo '</div>';
}
```

在评论回调（片段 5）里，把 `comment_text()` 之后追加一行调用：

```php
<div class="xy-comment-text mt-1 text-sm">
	<?php comment_text(); ?>
</div>
<?php xy_mbx_render_comment_rating( (int) get_comment_ID() ); ?>
```

**注意事项**

- **不引入第三方**（多说 / Disqus / 各类打分插件）的原因：外链资源影响首屏与国内可访问性、数据落在第三方、样式无法与 daisyUI 统一、隐私合规多一层风险。上面两种方案都在站点自己手里。
- 方案 A **只是「本浏览器是否点过」的标记，不是真实计数**；页面上的数字若想显示总点赞数就必须落库（`post_meta` + REST 接口 + 防刷），要做这个先明确需求，不要用本地存储假装计数。
- 方案 B 的评分随评论走：未通过审核的评论其评分也不会展示，天然复用审核流程；读取时用 `get_comment_meta()` 并在 `< 1` 时整块不输出。
- daisyUI `rating` 需要 `name` 一致的 radio 组；只读展示用 `disabled` + `checked`，注意 `checked( $i, $rating, false )` 返回字符串（第三个参数为 `false`），直接拼接即可。
- 评分入参必须 `absint()` 并做 1–5 范围校验，超范围直接忽略；输出星级容器用 `esc_attr()` 写 `aria-label`。

---

## 片段使用纪律

1. **能复用就不要重写**：同一件事在库里只有一份实现。列表卡片只在 `template-parts/post-list.php` 出现一次，由 `home.php` / `archive.php` / `search.php` / `index.php` 用 `get_template_part()` 调用，不要复制到各页面模板里再各自改。
2. **类名必须先构建**：片段里的 Tailwind / daisyUI 类名必须已存在于 `assets/css/tailwind.css`；新增或改动类名后执行 `npm run build:css`，并确认目标类名能搜到。否则样式不生效（详见 `02-样式与构建.md`）。
3. **`the_post_thumbnail()` 的尺寸选择**：列表卡片用 `medium_large`、详情页用 `large`、侧栏窄缩略图用 `medium`；一律带 `loading="lazy"`（首屏主图可去掉），并靠外层固定比例容器（`aspect-[16/9]`）+ `h-full w-full object-cover` 保证裁切不变形。
4. **所有输出都要转义**：文本 `esc_html()`、属性 `esc_attr()`、URL `esc_url()`；只有 WordPress 自身负责转义的函数（`the_title()`、`the_content()`、`comment_text()`、`the_permalink()`）才可直接输出。
5. **命名与加载**：函数与全局量统一 `xy_mbx_` 前缀避免与插件撞名；新增 `inc/*.php` 文件后要在 `functions.php` 用 `require_once XY_MBX_THEME_DIR . 'inc/xxx.php'` 加载；`inc/` 内每个文件都保留 `if ( ! defined( 'ABSPATH' ) ) { exit; }`。
6. **空态与降级**：数据为空、计数为 0、上游不可用时，整块不输出或给中文空态（如「暂无内容。」），不留半个空卡片。
7. **不写自定义 CSS**：新增组件先找 daisyUI / Tailwind 类名解决；`assets/css/theme.css` 只保留类名无法表达的必要规则（如 `.xy-entry` 正文排版、`.page-numbers` 分页外观）。
