# 前端实战陷阱清单

> **最后更新**: 2026-05-16
> **下次复查触发条件**: Tailwind 升级（v4→v5）/ shadcn 大版本升级 / 项目设计 token 重命名 / 新增表单字段类型 / 现有项目接入 HTTPS 上线（清单 §6 不再适用）/ 季度复查
> **沉淀来源**: 2026-04 → 2026-05 累计 7 条 `.learnings/` 前端实战踩坑
> **适用范围**: Next.js 16 + Tailwind v4 + shadcn + Lark 设计系统下的前端开发

---

## 0. 为什么需要这份文档

前端踩坑跟后端类型不一样——多数是**框架默认值变更 + Browser API 安全上下文 + CSS 级联**这类"看代码没毛病、运行起来错"的隐性 bug。本文档把项目踩过的 7 类陷阱集中沉淀，写新页面 / 新组件前**先翻一遍**避开。

---

## 1. Tailwind v4 默认 `border` 颜色变 `currentColor` → 黑边

### 现象

写 `<tr className="border-t hover:bg-gray-50">`，**每行渲染出来一道黑色粗线**——跟项目其他模块（浅灰 `#f2f3f5`）的 Lark 风格不一致。

### 根因

**项目用 Tailwind v4**（`frontend/package.json`：`"tailwindcss": "^4"`）。v3 → v4 一处**破坏性默认值变更**：

| 版本 | `border-t`（不带颜色） | 实际效果 |
|---|---|---|
| **v3** | `border-color: rgb(229 231 235)` (gray-200) | 浅灰，看着正常 |
| **v4** | `border-color: currentColor`（继承当前文字色） | 文字一般 `text-gray-900` → **黑色边框** |

迁移指南：https://tailwindcss.com/docs/upgrade-guide#default-border-color

### 修法（项目惯例）

**项目设计 token 在** `frontend/src/app/globals.css` `:root` 段：

```css
:root {
  --lark-blue: #3370FF;            /* Lark 主色，不是 Tailwind blue-600 */
  --lark-blue-hover: #5087FF;
  --lark-blue-light: #EBF2FF;
  --bg-secondary: #F7F8FA;         /* 卡片背景 */
  --border-color: #E3E5E8;         /* 默认边框 */
  /* ... 见 globals.css 完整清单 */
}
```

**反模式（禁止）**：

```tsx
<tr className="border-t">  {/* ❌ v4 黑边 */}
```

**正确（任一）**：

```tsx
<tr className="border-t border-[#e3e5e8]">          {/* 显式颜色 */}
<tr className="border-t" style={{ borderColor: 'var(--border-color)' }}>  {/* token 变量 */}
```

参考已落地用例：`frontend/src/app/(modules)/organization/roles/system-roles/page.tsx` 的表格写法。

---

## 2. shadcn Table 样式覆盖（列表页可复用模板）

### 坑

1. `TableRow` 默认带 `border-b hover:bg-muted/50`，浅底卡片里显沉；要整排去掉必须 `!important` 覆盖
2. 给 `<Table>` 一次性配置所有单元格样式比改组件源码更合适——用 Tailwind 任意选择器 `[&_th]: [&_td]:` 在 `className` 上一把子设
3. 列数多 + 中文内容时，**所有 cell 加 `whitespace-nowrap`**，否则 CJK 按字符任意换行撑高行高；让 shadcn `Table` 内部自带的 `overflow-auto` 容器做水平滚动
4. 外层 `overflow-hidden` 仅用于 `rounded-lg` 裁边角，**不会干扰内部水平滚动**——shadcn `Table` 实际是 `<div class="relative w-full overflow-auto"><table>`，自己形成滚动区域

### 可复用列表页模板

```tsx
<Table className="
  [&_th]:h-11 [&_th]:px-3 [&_th]:whitespace-nowrap [&_th]:text-xs [&_th]:font-semibold
  [&_td]:py-2.5 [&_td]:px-3 [&_td]:whitespace-nowrap
  [&_tbody_tr]:!border-0
  [&_tbody_tr:nth-child(even)]:bg-[#fafbfc]
">
  <TableHeader className="bg-[#f7f8fa] [&_tr]:border-b [&_tr]:border-[#ebedf0]">
    {/* ... */}
  </TableHeader>
  {/* ... */}
</Table>
```

中文长列用 `max-w-[140px] truncate` 截断：

```tsx
<TableCell>
  <span className="block max-w-[140px] truncate">{longChineseName}</span>
</TableCell>
```

### 何时用 / 何时不用

| 场景 | 用本模板 |
|---|---|
| 列数 ≥ 8 + 单元格含中文 + 卡片视觉 | ✅ |
| 列数 ≤ 4 / 全英文 / 简单后台 | ❌ shadcn 默认就够 |
| 行可点击进详情 | ✅ 加 `hover:bg-[#f5f6f7]` |

---

## 3. Tailwind `divide-y` 不继承父元素 inline `borderColor`

### 现象

```tsx
<dl className="divide-y" style={{ borderColor: colors.bgTertiary }}>
  {items.map((c) => <div>...</div>)}
</dl>
```

预期：子元素间分隔线 = `colors.bgTertiary`（浅灰）。
实际：分隔线是 Tailwind 默认较深灰（接近黑）。

### 根因

`divide-y` 给**子元素**加 `border-top-width: calc(1px * ...)`，但**不**继承父 inline `style.borderColor`。它只看 Tailwind 的 `--tw-divide-{color}` CSS 变量；不显式 `divide-{color}` 时回退默认 border 颜色。

inline `borderColor` 设在父 `<dl>` 上完全不起作用——子元素 border 走自己的级联。

### 修法

```tsx
{/* 方案 A: 用 Tailwind divide 颜色 utility */}
<dl className="divide-y divide-[#eff0f1]">

{/* 方案 B（推荐）：放弃 divide-y，每个子元素手动 inline borderTop */}
<dl>
  {items.map((c, idx) => (
    <div style={idx > 0 ? { borderTop: `1px solid ${colors.bgTertiary}` } : undefined}>
      {/* ... */}
    </div>
  ))}
</dl>
```

方案 B 跟项目 token 体系（动态颜色）更兼容，方案 A 适合静态颜色。

---

## 4. `isomorphic-dompurify` + Turbopack 构建炸

### 现象

`'use client'` 组件用 `isomorphic-dompurify` sanitize HTML，`npm run build` 直接挂：

```
FATAL: An unexpected Turbopack error occurred.
TurbopackInternalError: NftJsonAsset: cannot handle filepath node:worker_threads
```

### 根因

`isomorphic-dompurify` 的 SSR 支持通过引入 `jsdom` 模拟 DOM。`jsdom` 依赖 Node 内置 `node:worker_threads`。**Turbopack（next 16.0.x）不处理 `node:` 协议导入**，构建直接 panic。

### 修法

`'use client'` 组件不需要"同构"——SSR 阶段不执行编辑器初始化。改用纯浏览器版 `dompurify`：

```bash
npm uninstall isomorphic-dompurify
npm install dompurify @types/dompurify
```

```ts
// 之前
import DOMPurify from 'isomorphic-dompurify';
// 之后（同样 API）
import DOMPurify from 'dompurify';
```

### 选择矩阵

| 场景 | 选择 |
|---|---|
| `'use client'` 组件 / 浏览器侧调用 | `dompurify`（纯浏览器版）|
| SSR / build-time sanitize / Node 脚本 | `sanitize-html` 或 server-side `dompurify` + `jsdom`（**注意 Turbopack 兼容**）|
| 需要"同构" + 已锁 Webpack（不 Turbopack）| `isomorphic-dompurify` 可用 |

---

## 5. 表单设计器新字段类型必改 4 处，少一处 schema 缺 `type`

### 现象

`frontend/src/features/forms/components/designer/types.ts` 的 `FieldType` 联合类型枚举 30+ 字段，但 `toJSONSchema()` 函数的 switch 只覆盖一部分。漏掉的字段（rating / serialNumber / address / region / cascade / signature / richtext）序列化时落入默认分支，**`fieldSchema.type` 始终 undefined**。前端 `FormFieldRenderer` 拿到 `type: undefined` 走 fallback 显示"不支持的字段类型: undefined"。

### 为什么是 trap

- TS 联合类型本身**不强制** switch 覆盖所有 case（除非用 `never` 兜底做 exhaustiveness check）
- 设计器里这些字段**有图标、能拖拽、能配置**——看起来"已实现"
- 后端 JSONB 不校验 schema，submit 照样落库
- 唯一暴露处是 renderer fallback 字符串

### 修复模式：新字段类型必改 4 处

实现一个新字段类型必须**4 层都改**：

1. **`toJSONSchema()` switch**（`types.ts ~815 行`）：emit `type` + 必要的 `format` / `x-*` 元数据
2. **`widgetMap`**（`types.ts ~1049 行`）：把 FieldType 映射到 `ui:widget` 字符串
3. **`parseFieldFromSchema()`**（`types.ts ~1242 行`）：反向识别 widget → FieldType，否则保存后再打开设计器会丢字段
4. **`FormFieldRenderer.tsx`**：根据 `type` + `widget` 加渲染分支

**漏第 3 步的症状**：设计器保存后再打开，原本 rating 字段变成 number、signature 变成 textarea。

### 防御建议

`toJSONSchema()` 的 default 分支加 exhaustiveness check：

```ts
default:
  // 触发 TS error 当未覆盖的 FieldType 加入
  const _exhaustive: never = field.type as never;
  console.warn(`[toJSONSchema] unhandled field type: ${field.type}`);
```

或维护 `Record<FieldType, ...>` 映射表，TS 强制覆盖。

---

## 6. `navigator.clipboard` 在非 HTTPS 环境是 `undefined`

### 现象

UAT 用 plain HTTP + 公网 IP（`http://43.166.182.155`）访问，点 Copy URL：

```
TypeError: Cannot read properties of undefined (reading 'writeText')
```

UI 无任何反馈。

### 根因

`navigator.clipboard` 是 [Secure Context Only API](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) —— 仅在以下环境**存在**：

- `https://*`
- `http://localhost` / `http://127.0.0.1`
- `file://`

HTTP + 公网 IP / HTTP + 域名 → `navigator.clipboard` 直接 `undefined`，`.writeText(...)` 解引用就炸。

### 修法：三段降级的 `copyToClipboard`

```ts
async function copyToClipboard(text: string): Promise<boolean> {
  // 1. 优先 navigator.clipboard（HTTPS / localhost）
  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch {
      // 权限被拒等，fallthrough
    }
  }
  // 2. execCommand fallback（HTTP / 老浏览器仍可用）
  try {
    const textarea = document.createElement('textarea');
    textarea.value = text;
    textarea.style.position = 'fixed';
    textarea.style.left = '-999999px';
    document.body.appendChild(textarea);
    textarea.select();
    const ok = document.execCommand('copy');
    document.body.removeChild(textarea);
    return ok;
  } catch {
    return false;
  }
}
```

### 适用范围

任何 UAT / 内网 IP / 早期 PoC 阶段未上 HTTPS 的页面，**复制类按钮**必须用三段降级——不能假设 `navigator.clipboard` 存在。

---

## 7. Node http `Content-Type: text/plain` 缺 charset → 中文乱码

### 现象

```js
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(`🔄 PoC 演练 v10 RESTORED — 访问 ${n} 次\n`);
```

- `curl` 看是对的：`🔄 PoC 演练 v10 ...`
- **Chrome 浏览器**显示：`馃攧 PoC 婕旂粌 v10 ...`

### 根因

Node `res.writeHead` 不自动给 `text/plain` 加 charset。响应头是裸的 `Content-Type: text/plain`。

- curl 不做字符解码（按 byte 透传，终端 locale 渲染对了）
- **浏览器**遇到没声明 charset 的 text 响应会**猜**编码——中文环境下 Chrome 倾向先猜 GBK → UTF-8 字节被错按 GBK 解码 → 经典 mojibake

### 修法

```js
// ✅ 必显式声明 charset=utf-8
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
```

### 规则

**任何 text/* 响应必须显式声明 `; charset=utf-8`**：

- `text/plain; charset=utf-8`
- `text/html; charset=utf-8`
- `application/json` 无需（HTTP 规范默认 UTF-8）
- `application/xml; charset=utf-8`（XML 规范默认是 UTF-8 但浏览器猜测仍可能出错）

NestJS controller 走 `TransformInterceptor` 默认 `application/json` 无问题。**只有自写 Express middleware / 原生 Node http response / 静态文件服务** 才需要手动加。

---

## 8. 新页面 / 新组件前置检查清单

写新前端代码前自检：

- [ ] 用 Tailwind border / divide 类时，**显式指定颜色**（不要靠默认）
- [ ] 用 shadcn Table 处理 ≥ 8 列 / 含中文时，套 §2 模板
- [ ] 新字段类型必改 §5 的 4 处 + default 分支 exhaustiveness check
- [ ] 复制类按钮用三段降级 `copyToClipboard`（不直接 `navigator.clipboard.writeText`）
- [ ] 自写 Node http 响应 / 静态文件 / Express middleware 时，`Content-Type` 必含 `charset=utf-8`
- [ ] sanitize HTML 在 `'use client'` 组件用纯 `dompurify`，不用 `isomorphic-dompurify`
- [ ] 走 token 优先：`var(--lark-blue)` / `var(--border-color)` 等，不直接 hex（除非 token 不覆盖）
- [ ] 切换 i18n locale 切过 zh-CN / en-US，扫**周边历史区域**有无硬编码中文（不只是新加的 UI）

---

## 9. 相关 learning 与代码

- [`.learnings/2026-04-30-tailwind-v4-black-borders.md`](../../../.learnings/2026-04-30-tailwind-v4-black-borders.md)
- [`.learnings/2026-04-15-shadcn-table-style-override.md`](../../../.learnings/2026-04-15-shadcn-table-style-override.md)
- [`.learnings/2026-05-11-tailwind-divide-y-color.md`](../../../.learnings/2026-05-11-tailwind-divide-y-color.md)
- [`.learnings/2026-05-01-dompurify-turbopack-incompat.md`](../../../.learnings/2026-05-01-dompurify-turbopack-incompat.md)
- [`.learnings/2026-05-01-form-designer-tojsonschema-gap.md`](../../../.learnings/2026-05-01-form-designer-tojsonschema-gap.md)
- [`.learnings/2026-05-14-clipboard-http-fallback.md`](../../../.learnings/2026-05-14-clipboard-http-fallback.md)
- [`.learnings/2026-05-14-content-type-charset-utf8-required-for-cjk.md`](../../../.learnings/2026-05-14-content-type-charset-utf8-required-for-cjk.md)
- 项目设计 token: [`frontend/src/app/globals.css`](../../../frontend/src/app/globals.css) `:root` 段（`--lark-blue` / `--bg-secondary` / `--border-color` 等）
- 项目表格参考实现: `frontend/src/app/(modules)/organization/roles/system-roles/page.tsx`
