开发规范

本规范约束所有 Swift Web 项目(framework / starter / 由 sw create 生成的下游业务项目)的开发行为,覆盖从文件命名到 Git 协作的全部主题。

双重保障:

  • 多数硬性规则已内置 ESLint / Stylelint 配置,违反会在 pre-commit / CI 阶段直接报错;
  • 同时通过 @swift-web/standards 仓的 sync skill 将 AI 紧凑版规范注入下游 CLAUDE.md,让 Claude / Cursor / Copilot 在写代码前自动加载护栏。

本页是 人类阅读版(含大量代码示例 + 设计动机讲解);AI 紧凑版见 standards 仓 rules.md。本页覆盖主源的子集,完整规范以 standards rules/ 为准——主源近期新增的「全局 CSS 选择器约束」「Focus reflow-safe」「触摸目标与热区」「标准开发流程 SOP」「客户端安全(缓存 util / cookie)」「使用规范(usage-review 编号规则)」等主题本页暂未展开,后续按需补齐。

规则优先级

冲突时按以下顺序采纳:

顺位来源适用场景
1各仓 CLAUDE.md 顶部 P0 规则 / 工程闸门P0 护栏 / 归属 / release / 文档同步(最高)
2本规范(standards 仓 rules/)applier 共用基础规范 + 标准开发流程 SOP
3项目仓库自身的 CLAUDE.md 项目特有规范各仓特有目录结构、命名约定、测试规范、内部 API
4其他 AI 工具配置文件.cursorrules / .github/copilot-instructions.md 等

上层覆盖下层,冲突时主动告知用户矫正下层。


一、文件命名规范

文件名违反命名风格属 P0。AI 新建文件前必须先对照风格——命名错了,grep 找不到、Nuxt 自动导入路径也会跟着错。

1.1 Vue 组件 / 页面 / 布局

位置命名风格示例
app/components/PascalCaseLoginForm.vue / AdSwiper.vue / PaginationBar.vue
app/pages/kebab-caseabout-us.vue / product-detail.vue / [id].vue
app/layouts/全小写default.vue / empty.vue
✅ app/components/LoginForm.vue
✅ app/pages/product-detail.vue
✅ app/layouts/default.vue

❌ app/components/login-form.vue       (应 PascalCase)
❌ app/pages/ProductDetail.vue         (应 kebab-case)
❌ app/layouts/Default.vue             (应全小写)

为什么三种风格不统一? Nuxt 4 的约定:components/ 走 PascalCase 让模板里 <LoginForm /> 可读;pages/ 走 kebab-case 让 URL 与文件名一致;layouts/ 全小写是 Nuxt 默认的简约偏好。

1.2 TypeScript / JavaScript 文件

位置命名风格示例
app/composables/use + PascalCaseuseModal.ts / useToast.ts / useLoginCheck.ts
app/store/kebab-caseuser-info.ts / system-info.ts
app/plugins/kebab-caseanalytics.client.ts / error-handler.ts
app/middleware/kebab-case + .global 后缀login.global.ts / guest.global.ts
app/utils/kebab-caseaes-dynamic.ts / format-date.ts
server/api/kebab-case + HTTP 方法后缀health.get.ts / user-info.post.ts
app/api/由 generate-api 生成,禁止手改index.ts / modules/ / types/
✅ app/composables/useToast.ts          (use + PascalCase)
✅ app/store/user-info.ts               (kebab-case)
✅ app/middleware/login.global.ts       (kebab + .global)
✅ server/api/health.get.ts             (kebab + HTTP 方法)

❌ app/composables/use-toast.ts         (应 use + PascalCase)
❌ app/composables/UseToast.ts          (use 必须小写)
❌ app/store/userInfo.ts                (应 kebab-case)
❌ app/middleware/login.ts              (全局中间件必须带 .global)

1.3 速查对照表

对象类别风格示例
业务组件文件PascalCaseLoginForm.vue
核心组件文件Sw + PascalCase(framework)SwAsyncText.vue
页面文件kebab-caseproduct-detail.vue
布局文件全小写default.vue
Composable 文件use + PascalCaseuseToast.ts
Utils 文件kebab-caseaes-dynamic.ts
Store 文件kebab-caseuser-info.ts
Middleware 文件kebab-case + .globallogin.global.ts
Plugin 文件kebab-case + .client/.serveranalytics.client.ts
Server API 文件kebab-case + HTTP 方法health.get.ts

MR review 模板: 文件名 <file> 违反命名风格(应为 <style>),与目录约定不符。grep / 自动导入路径会受影响,请改名。


二、组件结构规范

2.1 三层组件分层

app/pages/                    路由页面:数据获取、布局组合、路由参数处理
    ↓ 使用
app/components/               业务组件:可复用的页面级 UI 逻辑
    ↓ 使用(Nuxt 自动导入)
@swift-web/framework          框架组件:Sw* 基础组件(SwImage、SwButton、SwModal …)

核心原则:

  • 页面文件 <template> 主体应是组件的组合,单页 ≤ 150 行
  • 复杂 UI 逻辑必须下沉到 app/components/,页面保持简洁
  • 业务组件可以直接使用框架 Sw* 组件,但业务组件不得被框架反向引用(违反"下层不依赖上层"的分层原则)

反例:

<!-- ❌ pages/product-detail.vue 单文件超 300 行,混杂数据获取 + 复杂 UI -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
const { data: comments } = await useAsyncData(() => api.comment.list({ productId: route.params.id }))
const isFavorited = ref(false)
const showShareDialog = ref(false)
const shareToWechat = () => { /* 30 行 */ }
const shareToWeibo = () => { /* 30 行 */ }
const handleAddToCart = () => { /* 50 行 */ }
// ...
</script>

<template>
  <div class="sw-product-detail">
    <!-- 80 行 hero 区 -->
    <!-- 60 行 评论列表 -->
    <!-- 40 行 分享弹窗 -->
    <!-- 50 行 加入购物车浮层 -->
  </div>
</template>

正例:

<!-- ✅ pages/product-detail.vue 拆为 4 个组件 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: product } = await useAsyncData(() => api.product.detail({ id: route.params.id }))
useSeo({
  title: product.value?.name,
  description: product.value?.summary,
  image: product.value?.cover,
})
</script>

<template>
  <div class="sw-product-detail">
    <ProductDetailHero :product="product" />
    <ProductDetailDescription :product="product" />
    <ProductDetailComments :product-id="route.params.id" />
    <ProductDetailActions :product="product" />
  </div>
</template>

2.2 组件文件头 JSDoc(强制)

app/components/ 下每个组件必须在 <script setup> 顶部写完整的 JSDoc 注释块(Props / 事件 / ≥1 个使用示例,复杂组件 ≥ 3 个)。

完整模板:

<script setup lang="ts">
/**
 * LoginForm — 用户登录表单,用于登录页和登录弹窗
 *
 * ── Props ────────────────────────────────────────────────────────
 * @prop mode        {'page' | 'dialog'}  必填。显示模式。
 *                   - page:页面模式,显示完整背景
 *                   - dialog:弹窗模式,无背景
 * @prop redirectUrl {string}             可选。登录成功后跳转地址。默认跳首页。
 *
 * ── 事件 ─────────────────────────────────────────────────────────
 * @event success    登录成功时触发,携带用户信息
 * @event cancel     用户点击取消时触发(仅 dialog 模式)
 *
 * ── 使用示例 ─────────────────────────────────────────────────────
 *
 * // 示例 1:页面模式
 * <LoginForm mode="page" />
 *
 * // 示例 2:弹窗模式 + 监听事件
 * <LoginForm
 *   mode="dialog"
 *   redirect-url="/mine"
 *   @success="onLoginSuccess"
 *   @cancel="closeDialog"
 * />
 *
 * // 示例 3:与 useModal 配合
 * const modal = useModal()
 * modal.open(LoginForm, { mode: 'dialog' })
 */

interface Props {
  mode: 'page' | 'dialog'
  redirectUrl?: string
}

const props = withDefaults(defineProps<Props>(), {
  redirectUrl: '/',
})

const emit = defineEmits<{
  success: [userInfo: UserInfo]
  cancel: []
}>()
</script>

注释语言规则:

  • 所有注释说明文字使用中文
  • Prop 名称、类型、事件名保留英文(代码标识符不译)

为什么必须写 JSDoc? Swift Web 重度依赖 AI 编码助手协作。组件文件头注释是 AI(Claude / Cursor)维护组件时第一手的语义信息,比依靠 grep / 调用点反推快得多。MR 评审也依赖这块说明判断变更是否合理。

MR review 模板: 业务组件缺 JSDoc 头注释(Props / 事件 / 使用示例),AI 维护和评审都依赖这块说明,请补上。

2.3 Props 设计规范

规则 1:可选 prop 必须显式提供默认值

// ✅ 正确:用 withDefaults 显式声明
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',     // 有意义的默认值
  disabled: false,
  label: undefined,       // 无合适默认值时显式写 undefined
})

// ❌ 错误:依赖 TS 默认 undefined 隐式
const props = defineProps<Props>()

规则 2:枚举类型用严格联合类型,禁用裸 string

// ✅ 正确:联合类型让调用方有 IDE 补全 + 类型保护
interface Props {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
}

// ❌ 错误:裸 string 任何字符串都过编译
interface Props {
  variant?: string
  size?: string
}

规则 3:加参数优先选项对象模式(fn({ a, b })),不用多参数(fn(a, b, c))

// ✅ 正确:选项对象,未来加参数不 break 调用方
interface UploadOptions {
  file: File
  onProgress?: (percent: number) => void
  retries?: number
}
function uploadFile(opts: UploadOptions): Promise<string> { /* ... */ }

uploadFile({ file, onProgress, retries: 3 })

// ❌ 错误:位置参数,加第 4 个参数所有调用点都要改
function uploadFile(file: File, onProgress?: (p: number) => void, retries?: number): Promise<string> { /* ... */ }

规则 4:透传属性用 v-bind="$attrs"

让调用方能透传原生 HTML 属性(disabled / aria-* / data-* 等):

<template>
  <!-- ✅ 业务封装的按钮组件 -->
  <button v-bind="$attrs" class="sw-primary-btn" @click="onClick">
    <slot />
  </button>
</template>

<script setup lang="ts">
defineOptions({ inheritAttrs: false })
</script>

调用方就能直接:

<PrimaryBtn :disabled="loading" aria-label="提交订单" data-testid="submit">
  提交
</PrimaryBtn>

三、CSS 规范

完整 Token 清单 / Tailwind 同源 / 响应式工具类等参考见 样式与 Token。本节是 CSS 编码规范(AI 速查 + MR 评审清单)。

3.1 根 class 强约束(强制)

Vue 组件 / 页面的根 DOM 元素,若写了 class,必须以 .sw-<组件名 kebab> 开头(与 framework Sw* 前缀同源);完全没写 class 的根自动豁免(如 <NuxtLayout> / <ClientOnly> / <DialogRoot> 等无 DOM 输出的包装组件)。

<!-- ✅ app/components/LoginForm.vue → 根 class .sw-login-form -->
<template>
  <form class="sw-login-form">
    <input class="email" />
    <input class="password" />
    <button class="submit">登录</button>
  </form>
</template>

<!-- ✅ app/pages/about-us.vue → 根 class .sw-about-us -->
<template>
  <div class="sw-about-us">
    <h1>关于我们</h1>
  </div>
</template>

<!-- ✅ 根是包装组件且不写 class —— 自动豁免 -->
<template>
  <ClientOnly>
    <div class="sw-inner">...</div>
  </ClientOnly>
</template>

<!-- ❌ 写了 class 但不带 sw- 前缀 -->
<template>
  <form class="login-form">...</form>
  <form class="my-login">...</form>
</template>

4 种边缘情形:

  1. 多根组件(Portal / Teleport 产生兄弟根):每个根独立判定——写了 class 就必须带 sw-<组件名>-<部位> 复合前缀,没写则放过
    <template>
      <Teleport to="body">
        <div class="sw-toast-overlay">
          <div class="sw-toast-content">{{ message }}</div>
        </div>
      </Teleport>
    </template>
    
  2. Pass-through wrapper(无自身 DOM):class 附到实际渲染的 DOM 上
    <!-- 包装 SwImage 但自己不渲染 wrapper -->
    <template>
      <SwImage class="sw-product-cover" v-bind="$attrs" />
    </template>
    
  3. 纯逻辑组件(零 DOM 产出,例如只 emit 全局事件):自然 no-op,无需任何标注;如需明确表达可选加 @no-root-class 文件头注释
    <script setup lang="ts">
    /**
     * GlobalEventBus — 纯逻辑组件,零 DOM 产出
     * @no-root-class(可选标注,明确表达"本组件不适用根 class 规则")
     */
    onMounted(() => { /* ... */ })
    </script>
    <template></template>
    
  4. 条件渲染多分支(v-if 切换根元素):每个分支独立按规则判定——写了 class 才查 sw- 前缀
    <template>
      <article v-if="isArticle" class="sw-content-article">
        <slot />
      </article>
      <section v-else class="sw-content-section">
        <slot />
      </section>
    </template>
    

设计动机(AI 友好):

  • 根 class 与文件名严格映射 → grep / Claude / Cursor 能从样式或 DOM 精确反查到组件文件
  • 与 framework .sw- 前缀统一 → 全栈 class 命名一致,AI 不必区分"内置组件 vs 业务组件"两套命名风格
  • 强制前缀消除"该不该 .sw-"的猜测,AI 写新组件时直接套用
  • "无 class 自动豁免"避免对包装组件(NuxtLayout / ClientOnly 等)的误报

💡 .sw- class 前缀不能替代 Vue multi-word-component-names 规则。前者防 CSS class 冲突,后者防组件名与 HTML 原生标签冲突。两者并存不冗余。

Lint / Format 兜底:

  • @swift-web/standards/eslint 已发布 root-class-sw-prefix 规则(v2 语义:写了 class 才检查前缀),默认 severity warn;CI 想硬阻断可用 eslint --max-warnings=0 自选升级
  • @swift-web/standards/prettier + @swift-web/standards/stylelint 集中 base 配置 (各仓 prettier.config.ts / stylelint.config.ts 用 import base + spread override 消费,统一升级 + 项目自定义两全)
  • MR Review 同时依靠人工 + AI 评审做二道防线

3.2 子元素 class(扁平 kebab)

scoped 样式天然隔离子元素,直接用简单 kebab,不加 sw- 前缀:

<template>
  <form class="sw-login-form">
    <div class="header">登录</div>
    <div class="body">
      <input class="email" />
      <input class="password" />
    </div>
    <div class="footer">
      <button class="submit">登录</button>
      <button class="cancel">取消</button>
    </div>
  </form>
</template>

<style scoped>
.sw-login-form .header { font-size: 1.25rem; }
.sw-login-form .body { padding: 1rem; }
.sw-login-form .submit { background: var(--primary); }
</style>

3.3 状态修饰符(is- / has- 前缀)

状态类用 is- 或 has- 前缀,与子元素类(名词形态)区分:

/* ✅ 正确:状态用形容词形态 + is/has 前缀 */
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .btn.is-loading { opacity: 0.6; cursor: wait; }
.sw-login-form .btn.is-disabled { opacity: 0.4; pointer-events: none; }
.sw-product-card.is-selected { box-shadow: 0 0 0 2px var(--ring); }

/* ❌ 错误:状态用名词形态,与子元素冲突 */
.sw-login-form .error { /* 是子元素还是状态? */ }
.sw-login-form .loading { /* 同上 */ }

语义对照表:

类别形态含义示例
子元素名词,plain kebab"是什么东西".header / .error-msg / .submit
状态修饰形容词,is-<x> / has-<x>"处于什么状态".is-loading / .is-selected / .has-error

3.4 禁止写法

/* ❌ BEM 双下划线(与 framework 风格不一致) */
.sw-login-form__header { }
.sw-login-form__submit--disabled { }

/* ❌ 子 class 带 sw- 前缀(scoped 下冗余) */
.sw-login-form .sw-header { }
.sw-login-form .sw-body { }

/* ❌ 根 class 不带 sw- 前缀(违反根 class 强约束) */
.login-form { }
.app-navbar { }

/* ❌ 子 class 堆叠组件根前缀(多此一举) */
.sw-login-form-header-item-label { }

/* ❌ 状态用名词形态(与子元素歧义) */
.sw-btn .loading { } /* 应改 .is-loading */
.sw-form .error { }  /* 应改 .has-error */

3.5 嵌套策略

  • 浅层嵌套(1-2 层)推荐用于语义分组
  • 不强制 CSS 嵌套结构与 DOM 层级严格对齐(scoped 已隔离,扁平完全可接受)
  • 深层嵌套(≥3 层)不推荐,AI 编辑时容易搞错作用域边界
/* ✅ 推荐:浅层嵌套作语义分组 */
.sw-login-form {
  display: flex;
  flex-direction: column;

  .field {
    margin-bottom: 1rem;
    &.has-error { border-color: var(--destructive); }
  }

  .submit {
    background: var(--primary);
  }
}

/* ✅ 也合规:完全扁平(scoped 自动隔离) */
.sw-login-form { display: flex; flex-direction: column; }
.sw-login-form .field { margin-bottom: 1rem; }
.sw-login-form .field.has-error { border-color: var(--destructive); }
.sw-login-form .submit { background: var(--primary); }

/* ❌ 不推荐:深嵌套 + 严格 DOM 层级对齐(4 层) */
.sw-card {
  .header {
    .row {
      .col {
        .title { color: red; }
      }
    }
  }
}

3.6 CSS Token(Shadcn v4 语义命名)

  • 所有颜色、圆角、阴影、布局尺寸必须用 framework 注入的 Shadcn v4 语义 token
  • 不写 fallback:framework tokens.css 的 :root 默认值已兜底
  • 硬编码色值零容忍(除 transparent / currentColor / 渐变 stop / 媒体语境例外)
/* ✅ 正确:语义 token,无 fallback */
.sw-ad-swiper {
  background: var(--background);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--foreground);
}

.sw-error-banner {
  background: var(--destructive);
  color: var(--destructive-foreground);
}

/* ❌ 带 fallback(多此一举,:root 已兜底) */
color: var(--primary, #3b82f6);

/* ❌ 已彻底移除的旧 token 命名(独立色值,已不存在) */
background: var(--color-bg-dark);
border-color: var(--border-color);
padding: var(--spacing-section);

/* ❌ 手写 CSS 直接引 Tailwind @theme 桥接变量 —— 改引语义 token var(--primary) */
color: var(--color-primary);

/* ❌ 硬编码色值 */
background: #f5f5f5;
color: #1a1a1a;
border: 1px solid #e5e5e5;

常用 token 速查(完整清单见 样式与 Token):

类别变量
底色--background / --foreground
主色--primary / --primary-foreground
弱化--muted / --muted-foreground
边框--border / --input / --ring
语义--destructive / --success / --warning / --info
尺寸--radius / --shadow-sm / --shadow-md / --shadow-lg
布局--nav-height / --container-padding / --content-max-width

MR review 模板: 此处硬编码色值 / 引了已移除的旧 token / 写了 fallback,请改用 var(--xxx) 语义 token(清单见 样式与 Token)。

3.7 Hover 交互统一用 filter

废弃单独的 hover 色 token(--primary-hover 等),用 filter: brightness() 一行搞定 + dark mode 自动对称:

/* ✅ 正确:filter brightness 一行搞定,dark mode 自动 */
.submit {
  background: var(--primary);
  color: var(--primary-foreground);
  transition: filter 0.15s ease;
}
.submit:hover  { filter: brightness(0.92); }
.submit:active { filter: brightness(0.85); }

.danger-btn {
  background: var(--destructive);
  color: var(--destructive-foreground);
  transition: filter 0.15s ease;
}
.danger-btn:hover { filter: brightness(0.92); }

/* ❌ 错误:单独维护 hover 色 token */
.submit { background: var(--primary); }
.submit:hover { background: var(--primary-hover); } /* 已废弃 --primary-hover */

为什么用 filter? brightness 滤镜在浅色主题(变暗)和深色主题(变亮)都自然对称——背景色明度本身决定 hover 朝哪个方向走,无需双套 token。

3.8 CSS 单位约束

核心约束表(rem 为主、px 用于像素精确细节,与 standards css-tokens 规则一致):

属性类别单位
font-size / letter-spacing / text-indentrem(a11y 友好)
line-height无单位数(如 1.5),禁止 px / rem
padding / margin / gap / width / height / top/right/bottom/leftrem
border-radius ≥ 0.5rem(8px)rem
border-radius < 8px 小圆角px 例外(半像素渲染稳定)
border-width / box-shadow / text-shadow offsetpx
媒体查询 @media 断点px
物理固定元素(小 icon / 分割线等)px
em禁用(SwIcon 的 1em 是唯一例外)

正例:

.sw-product-card {
  padding: 1.5rem;                   /* ✅ 间距用 rem */
  font-size: 1rem;                   /* ✅ 字体用 rem */
  line-height: 1.6;                  /* ✅ 行高无单位 */
  border-radius: 0.75rem;            /* ✅ 大圆角用 rem */
  border: 1px solid var(--border);   /* ✅ 边框宽度用 px */
  box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); /* ✅ 阴影 offset 用 px */
}

.tag {
  border-radius: 4px;                /* ✅ 小圆角(< 8px)用 px 例外 */
}

@media (max-width: 768px) {          /* ✅ 媒体查询用 px */
  .sw-product-card { padding: 1rem; }
}

反例:

.sw-product-card {
  padding: 24px;                     /* ❌ 间距应 rem */
  font-size: 16px;                   /* ❌ 字体应 rem */
  line-height: 26px;                 /* ❌ 行高应无单位(如 1.6) */
  margin: 0.5em 1em;                 /* ❌ 禁用 em(嵌套复合难追踪) */
}

@media (max-width: 48em) {           /* ❌ 媒体查询应 px */
  .sw-product-card { padding: 1rem; }
}

理由:rem 主、a11y 友好(用户调大浏览器字号时布局跟随);px 例外用于细节像素精确(边框 / 阴影)和边界稳定(媒体查询);em 禁用因嵌套复合难追踪。

stylelint declaration-property-unit-allowed-list 已配置,pre-commit + CI 均 error 级。

3.9 响应式断点

与 framework responsive.css 保持一致:

断点范围
移动端(mobile)< 768px
平板端(tablet)768px – 1023px
桌面端(desktop)≥ 1024px
/* ✅ 正确:与 framework 断点一致 */
.sw-product-list { display: grid; grid-template-columns: 1fr; }

@media (min-width: 768px) {
  .sw-product-list { grid-template-columns: 1fr 1fr; }
}
@media (min-width: 1024px) {
  .sw-product-list { grid-template-columns: 1fr 1fr 1fr; }
}

四、TypeScript 规范

4.1 禁止 any / 隐式 any

需要灵活类型时使用 unknown + 类型断言:

// ✅ 正确:unknown 强制调用方收窄类型
function parseResponse(data: unknown): UserInfo {
  // 实际项目中应该先校验结构
  return data as UserInfo
}

interface ApiEnvelope {
  code: number
  data: unknown
}
function unwrap<T>(envelope: ApiEnvelope): T {
  if (envelope.code !== 0) throw new Error(`API ${envelope.code}`)
  return envelope.data as T
}

// ❌ 错误:any 完全放弃类型检查
function parseResponse(data: any): any {
  return data
}

// ❌ 错误:函数参数 / 返回值无类型(隐式 any)
function format(date) {
  return date.toLocaleString()
}

MR review 模板: 禁止 any,用 unknown + 类型断言;接口字段补全。

4.2 用 as const 固定字面量类型

常量数组 / 对象用 as const 锁字面量类型,再 [number] 推导联合类型,避免后续手写联合漂移:

// ✅ 正确:as const + [number] 自动推导联合类型
const VARIANTS = ['primary', 'secondary', 'ghost'] as const
type Variant = (typeof VARIANTS)[number]
// Variant = 'primary' | 'secondary' | 'ghost'

const RENDER_MODES = ['ssr', 'csr', 'isr', 'ssg'] as const
type RenderMode = (typeof RENDER_MODES)[number]

// ✅ 对象 as const 锁结构
const STATUS_LABELS = {
  pending: '待支付',
  paid: '已支付',
  cancelled: '已取消',
} as const
type Status = keyof typeof STATUS_LABELS

// ❌ 错误:丢失字面量信息(推断为 string[])
const VARIANTS = ['primary', 'secondary', 'ghost']
// typeof VARIANTS = string[],无法精确推导

// ❌ 错误:手写联合类型(与数组同步漂移风险)
type Variant = 'primary' | 'secondary' | 'ghost'
const VARIANTS: Variant[] = ['primary', 'secondary'] // ←漏 'ghost',编译还能过

4.3 变量命名约定

// ── 普通变量 / 函数:camelCase ──
const pageTitle = ref('')
const userInfo = useUserStore()
function fetchUserInfo() {}
async function uploadFile(opts: UploadOptions): Promise<string> {}

// ── 布尔值:is- / has- / can- / should- 开头 ──
const isLoading = ref(false)
const hasError = computed(() => !!errorMsg.value)
const canSubmit = computed(() => form.valid && !isLoading.value)
const shouldShowDialog = ref(false)

// ── 模块级真常量:SCREAMING_SNAKE_CASE ──
const MAX_RETRY = 3
const DEFAULT_PAGE_SIZE = 20
const API_TIMEOUT_MS = 10_000

// ── 类型 / 接口:PascalCase,不加 I 前缀 ──
interface UserInfo { id: number; name: string }
type ApiResponse<T> = { data: T; code: number }
// ❌ interface IUserInfo(不要加 I 前缀)

// ── 模块内部辅助函数:_ 前缀 ──
function _deriveKey(requestId: string) {}
function _validateInternal(payload: unknown) {}

4.4 模板 ref 命名

模板引用以 Ref 结尾,便于一眼区分普通 reactive ref:

// ✅ 正确:Ref 后缀
const inputRef     = ref<HTMLInputElement>()
const containerRef = ref<HTMLDivElement>()
const listRef      = ref<HTMLUListElement>()
const swImageRef   = ref<InstanceType<typeof SwImage>>()

// 模板里
// <input ref="inputRef" />
// <SwImage ref="swImageRef" />

// ❌ 错误:与普通 ref 视觉混淆
const input = ref<HTMLInputElement>()
const container = ref<HTMLDivElement>()

4.5 错误处理约定

抛错:

// ✅ Nuxt / Nitro 路径用 createError(框架理解的 statusCode)
throw createError({
  statusCode: 404,
  statusMessage: '商品不存在',
  data: { productId: id },
})

// ✅ 纯 logic 路径用 Error 子类
throw new Error('解析失败:缺少必填字段 phone')

// ❌ 禁止:throw 字符串字面量(丢失 stack trace)
throw '登录失败'

catch 子句中 e 是 unknown:

// ✅ 正确:先判类型再访问 .message
try {
  await someApi()
} catch (e) {
  if (e instanceof Error) {
    console.error(e.message, e.stack)
  } else {
    console.error('未知错误', e)
  }
}

// ✅ 业务错误用自定义 Error 类
export class LoginExpiredError extends Error {
  constructor() {
    super('登录已过期')
    this.name = 'LoginExpiredError'
  }
}

// catch 处理点
try { await fetchProfile() }
catch (e) {
  if (e instanceof LoginExpiredError) {
    await navigateTo('/login')
    return
  }
  throw e
}

// ❌ 禁止:吞错误(不 console.warn 不 throw)
try { await someApi() } catch {}

// ❌ 错误:直接访问 e.message(TS 报 ts-2571)
catch (e) {
  console.error(e.message) // e 是 unknown 类型
}

4.6 类型导出位置

  • 跨模块共享的类型放在专门的 types.ts 或目标模块的 index.ts
  • 仅本模块使用的类型紧邻使用处定义,不要为"通用化"提前外移
// ✅ 跨模块共享:app/types/user.ts
export interface UserInfo { id: number; name: string; phone: string }
export type UserRole = 'admin' | 'member' | 'guest'

// ✅ 仅本模块使用:紧邻定义
// app/composables/useLoginForm.ts
interface LoginFormState {  // 不外移
  phone: string
  code: string
  agreed: boolean
}
export function useLoginForm() {
  const state = reactive<LoginFormState>({ phone: '', code: '', agreed: false })
  return { state }
}

// ❌ 反例:把仅本模块用的类型放进 types/,污染全局
// app/types/login-form-state.ts ← 没有第二个模块用,外移即过早抽象

五、i18n 键命名规范

5.1 核心规则:中文原文作 key

// ✅ 推荐:直接用中文原文
$t('登录')
$t('请输入手机号')
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })

// ❌ 反对:另起英文键名
$t('login.button')
$t('order.total.amount')
$t('common.welcome.message')

为什么不用英文键名:

  • 可读性:业务代码里直接看见原文,无需在模板和 locales 文件之间反复跳转确认含义
  • AI 友好:Claude / Cursor 可直接判断键的语义,无需依赖键名的"约定式描述"
  • 降低废键风险:英文键名易堆积废键,中文原文键改文案 = 改 key(自然显式迁移)
  • 多语言扩展无障碍:中文 locale 是 identity 映射(key === value),其他 locale 文件按 key 翻译即可

5.2 长文案处理

key 长度限制:≤ 50 个字符。超过时改用简短中文描述作为 key,全文放在翻译文件里:

// ✅ 长文案:用简短描述作 key
$t('首页SEO描述')
$t('关于页SEO描述')
$t('用户协议第一段')

// ❌ 禁止:把长句直接作为 key
$t('一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化、主题等一切所需。')

5.3 例外场景(允许英文 / 短码 key)

场景key 示例理由
路由 / pathnameroutes.home与 URL 强绑定,中文不合适
状态枚举映射status.pending / status.paid后端返回英文枚举,需双向映射
极长段落(> 200 字符)legal.terms.section1中文键太长影响代码可读性
富文本带 HTML 标签tooltip.html.welcome原文含标签影响代码格式化

5.4 占位符约定

变量插值用花括号命名占位符,不用位置参数:

// ✅ 正确:命名占位符
$t('订单总计 {amount} 元', { amount: 99 })
$t('欢迎 {username},今天是 {date}', { username, date })

// ❌ 错误:位置参数(与中文原文 key 风格不搭)
$t('订单总计 {0} 元', [99])
$t('Welcome {0}, today is {1}', [username, date])

5.5 翻译文件格式

平铺结构 + 按 key 字典序排序:

// ✅ i18n/locales/zh-CN.json
{
  "关于页SEO描述": "了解 Swift Web:一个基于 Nuxt 4 的现代化工程模板...",
  "立即开始": "立即开始",
  "登录": "登录",
  "首页": "首页",
  "首页SEO描述": "一个现代化的 Nuxt 4 脚手架模板,内置 lint、测试、国际化..."
}
// ✅ i18n/locales/en.json(key 仍是中文原文)
{
  "关于页SEO描述": "Learn about Swift Web: a Nuxt 4 project template...",
  "立即开始": "Get Started",
  "登录": "Sign In",
  "首页": "Home",
  "首页SEO描述": "A modern Nuxt 4 starter template with everything you need..."
}

三个语言文件(zh-CN / zh-TW / en)必须完整填写所有 key,缺译时用中文原文兜底。

5.6 复数 / 性别等高级语法

复数形式用 vue-i18n 的 $tc + | 分隔:

// 中文不区分单复数,三段都写中文即可
$tc('{n} 条评论 | {n} 条评论 | {n} 条评论', count, { n: count })

// 其他 locale 按规则展开
// en.json: { "{n} 条评论 | {n} 条评论 | {n} 条评论": "no comments | one comment | {n} comments" }

六、渲染策略声明

💡 快速选型先看:渲染模式快速指南(5 分钟决策矩阵 + 详情页决策树 + 反模式速查)。本章 6.x 各小节是规范条款(强制要求);理论深挖见 渲染模式选用指南(理论篇)。

framework 定位口号:ISR 主推 + SSR 守付费 + CSR 给用户态 + 混合页 hydrate。详细业务画像与决策矩阵见 快速指南。

6.1 强制规范

每个 app/pages/**/*.vue 必须在 <script setup> 顶层调用 defineRouteRules(),ESLint 会检查,缺失则报错。

未声明渲染策略的页面每次请求都会触发完整服务端渲染,是服务器高负载的主要原因。合理使用 ISR 可将多数页面降为静态 HTML 直出,响应速度提升数倍。

额外强约束(由 @swift-web/framework/eslint 自动启用):

  • 付费内容主体(swift-web/no-isr-on-paid-paths,默认 9 段:/read/**、/play/**、/chapter/**、/video/**、/watch/**、/audio/**、/episode/**、/player/**、/viewer/**):禁用 isr / swr / prerender;只能 ssr: true 或 ssr: false。理由:防 DOM 篡改绕过付费(参考 WSJ 实践)
  • 用户态 / 实时交互(swift-web/no-isr-on-user-state-paths,默认 14 段:/mine/**、/orders/**、/settings/**、/chat/**、/live/**、/profile/**、/account/**、/wallet/**、/vip/**、/subscription(s)/**、/dashboard/**、/message(s)/**):必须 ssr: false

以上为默认段名,applier 可通过两条规则的 segments options 覆盖。

  • 可缓存页禁读 query(swift-web/no-query-in-cacheable-ssr):isr / prerender / swr / cache 页的 SSR 执行路径不得读 route.query。HTML 缓存在反代层是 path-only key(忽略全部 query),读了会让 ?order=hot 命中 ?order=new 的缓存 ⇒ 内容错配。修法与 path 段的边界要求见 渲染模式选用指南
  • 禁写无效 routeRules(swift-web/no-ineffective-route-rules):defineRouteRules 里不写 swr / cache / static(缓存策略统一只写 isr,framework 会在构建期翻译成 node-server 真正认识的 cache),也不写空对象 {}

6.2 策略速查 + 决策树

策略写法适用场景
ISR(增量静态再生){ isr: 3600 }内容定期更新的页面(列表 / 首页)
ISR(详情 / 静态页){ isr: 3600 }更新不频繁的详情页、静态页(区间上限;isr: true 已移除,fw#414)
SSG(预渲染){ prerender: true }内容固定不变(关于页、协议页)
CSR(纯客户端){ ssr: false }必须登录才能访问的个人页面
SSR(实时渲染){ ssr: true } 或 {}内容必须实时(慎用)

决策树:

业务特征推荐策略关键理由
0-2 参数稳定(分类 / 分页 / 标签)RESTful URL 路径化 + ISR缓存 key 规范化 + SEO 友好 + canonical 单一
3+ 筛选维度(搜索 / 高维筛选)CSR (ssr: false)缓存爆炸 + duplicate content 风险 + SEO 收益低
详情页(内容稳定)ISR 长 maxAge / on-demand revalidate命中率高,SEO 必须
列表 / 内容首页ISR 短 maxAge平衡新鲜度与命中率
用户态页(个人中心 / 后台 / 订单)CSR (ssr: false)含敏感数据,禁 ISR
错误页 / 404SSR + useSeo({ robots: 'noindex,nofollow' })防烙缓存 + 防 SEO 泄露
极少变动(about / terms / 协议)prerender: true(SSG)构建时一次生成,零运行时开销,静态最优;动态参数页面禁用

详细决策与坑点见 Nuxt 渲染模式选用指南。

6.3 ISR 工作原理

第 1 次请求  → 服务端渲染 → 缓存 HTML → 返回给用户
缓存期内请求 → 直接返回缓存 HTML(零 Node.js 开销)
缓存到期后   → 下一个请求仍返回旧缓存(用户不等待)
              同时后台重新渲染 → 更新缓存
  • isr: 3600:区间上限,一小时后台重验一次,详情页 / 静态页推荐(isr: true 已移除,fw#414)
  • isr: 600 / isr: 1800:首页 / 列表推荐(新内容 10–30 分钟内可见;isr 只接受 60–3600 的整数)

6.4 各策略代码示例

<!-- ✅ 内容详情页:更新不频繁,ISR 永久缓存 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- ✅ 首页 / 列表页:每小时刷新 -->
<script setup lang="ts">
defineRouteRules({ isr: 3600 })
</script>
<!-- ✅ 关于页 / 隐私协议:内容固定,构建时生成 -->
<script setup lang="ts">
defineRouteRules({ prerender: true })
</script>
<!-- ✅ 个人中心 / 收藏 / 历史:必须登录,纯客户端渲染 -->
<script setup lang="ts">
defineRouteRules({ ssr: false })
</script>
<!-- ✅ 极少数需要实时数据的页面(搜索结果、订单列表) -->
<script setup lang="ts">
defineRouteRules({ ssr: true })
</script>

注意:defineRouteRules 是编译期宏,参数必须是字面量对象,不能使用变量。批量配置同类路由可在 nuxt.config.ts 的 routeRules 中集中声明,页面内的声明优先级更高。

6.5 最严重风险:用户态泄漏(先看这一节)

ISR 把首访者看到的 HTML 烙进缓存供所有人复用。如果首访者是登录用户,他的私人数据会广播给后续所有访问者——这是安全/隐私事故,比缓存爆炸或数据陈旧严重得多。

风险严重性排序:用户态泄漏 > 缓存指纹错配(性能)> 缓存内容污染(功能)。

触发泄漏的代码模式(ISR / prerender 页面里出现任一即高危):

  • useFetch 默认带 cookie 转发后端 → 后端读 cookie 返回个性化数据
  • 调用 private API(后端标注 x-sw-visibility: private;OpenAPI 源见 app/api/visibility.json,YAPI 源靠约定 + review)
  • useRequestHeaders(['cookie', 'authorization']) 读用户态 header
  • event.context.user(server middleware 注入的用户对象)
  • 私有页面(个人中心 / 订单 / 后台)忘标 ssr: false

四道防线(按优先级):

  1. 用户态页面 ssr: false(最简单)
  2. Public / Private API 分离(判据是后端标注,不是前端目录)+ 混合页面强制拆分(见第八章)
  3. Nitro cache.varies 限定场景
  4. 把变量提到 URL 路径

自检铁律:HTML 输出对所有未登录用户必须完全相同;任何"因用户而变"的内容(登录态 / 会员价 / 收藏 / 订单 / 进度)一律 CSR。

6.6 ISR 必踩坑速查

A 缓存指纹错配:

  • POST body 不参与缓存 key — 翻页/筛选用 GET 或 RESTful 路径
  • query 顺序不规范化 — 高维 query 缓存爆炸
  • 上下文敏感参数(i18n / 登录态 / UA / 多租户 host / geo)不在 URL → 必须放 URL 或 cache.varies

B 缓存内容污染:

  • 错误响应被烙进缓存 — useFetch 失败时必须 throw createError(...)
  • ISR 页面里 set-cookie 不进缓存 → 个性化副作用必须移到 client

C 选型反模式:

  • 详情页用 SSR → 改 ISR
  • 个人中心用 ISR → 改 CSR
  • 高维筛选用 ISR → 改 CSR

D SSR 执行环境(详见第十一章):

  • runtime 顶层访问 window / document → SSR 500
  • 跨组件状态用 ref → 串数据;用 useState 替代

F Nitro 默认值:

  • swr: true 默认开启
  • maxAge: 1 秒默认
  • cache key = path + searchParams,cookie/header 进 key 必须显式 varies

6.7 写代码自检清单

  • 页面有 defineRouteRules({ ... }) 显式声明
  • ISR 页面的 SSR 执行路径没调 private API(onMounted / 事件处理器里调是合法的)
  • ISR 页面没解构 useRoute().query.* ≥ 3 个
  • ISR 页面用 GET 而非 POST 翻页
  • 错误页 useSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true })
  • runtime 模块没顶层 window / document / localStorage
  • 跨组件共享状态用 useState 而非 ref

6.8 标准页面模板结构

完整可参考的页面骨架:

<script setup lang="ts">
// 1. 渲染策略(必填,ESLint 强制检查)
defineRouteRules({ isr: 3600 })

// 2. 页面元数据
definePageMeta({ layout: 'default' })

// 3. 路由与国际化
const route = useRoute()
const { t } = useI18n()

// 4. 数据获取(必须在 setup 顶层 await,让 SSR 拿到数据)
const { data: product, status, error } = await useAsyncData(
  `product-${route.params.id}`,
  () => api.product.detail({ id: route.params.id }),
)

// 5. SEO 信息(用 useSeo,含 og / twitter / canonical / robots)
useSeo({
  title: product.value?.seo?.title ?? product.value?.name,
  description: product.value?.seo?.description ?? product.value?.summary,
  image: product.value?.cover,
  type: 'article',
  article: {
    author: product.value?.author,
    datePublished: product.value?.publishedAt,
    dateModified: product.value?.updatedAt,
  },
})

// 6. 错误兜底:错误页一定要 throw createError,避免错误页被 ISR 缓存
if (error.value) {
  throw createError({
    statusCode: error.value.statusCode ?? 500,
    statusMessage: error.value.statusMessage ?? '商品加载失败',
  })
}
</script>

<template>
  <div class="sw-product-detail">
    <ProductDetailSkeleton v-if="status === 'pending'" />
    <ProductDetailContent v-else :product="product" />
  </div>
</template>

<style scoped>
.sw-product-detail {
  padding: 1.5rem;
  background: var(--background);
}
</style>

七、SEO 规范

7.1 useSeo() 取代 useSeoMeta()

framework 提供 useSeo() composable,取代裸用 useSeoMeta()。useSeo() 自带 og / twitter / canonical / JSON-LD / robots / dev 期长度警告等多项能力,是当前推荐 API。

硬规则:每个 app/pages/**/*.vue 和 app/error.vue 必须调用 useSeo(),ESLint swift-web/require-useSeo-in-pages 兜底(缺失即报错)。

// ✅ 正确:useSeo
useSeo({
  title: '商品详情',
  description: '高品质优选商品,限时折扣',
  image: '/og/product.jpg',
  type: 'article',
  article: {
    author: '张三',
    datePublished: '2026-01-01',
    dateModified: '2026-05-08',
  },
})

// ❌ 反例:裸用 useSeoMeta
useSeoMeta({
  title: '商品详情',
  description: '高品质优选商品,限时折扣',
  ogTitle: '商品详情',
  ogDescription: '高品质优选商品,限时折扣',
  ogImage: '/og/product.jpg',
  twitterCard: 'summary_large_image',
  // ↑ og / twitter / canonical 都要手拼,重复且容易漏
})

MR review 模板: 此页用 useSeoMeta(),请改用 useSeo()。useSeo() 自带 og / twitter / canonical / JSON-LD / robots,无需手动拼。

7.2 私有页 / 中转页 noindex

私有页(login / register / mine / profile/* / admin / orders 等)必须配 useSeo({ robots: 'private' }):

// ✅ 私有页:noindex + 静音长度告警
useSeo({
  title: '登录',
  description: '登录 Swift Web',
  robots: 'private',          // 等价于 noindex,nofollow
  silenceLengthWarnings: true, // 私有页 title/desc 天然短,关闭长度警告
})

// ✅ 中转 / 重定向页:noindex
useSeo({
  title: '正在跳转',
  robots: 'noindex',
  silenceLengthWarnings: true,
})

// ✅ 错误页:noindex,nofollow
// app/error.vue
useSeo({
  title: '页面不存在',
  robots: 'noindex,nofollow',
  silenceLengthWarnings: true,
})

为什么私有 / noindex 页一定要 silenceLengthWarnings: true? 这类页 title/desc 天然短,dev console 长度告警无意义,会刷屏掩盖真实告警。

7.3 公开页 SEO 完整字段(详情页)

useSeo({
  // ── 基础 ──
  title: data.value?.seo?.title ?? data.value?.name,
  description: data.value?.seo?.description ?? data.value?.summary,
  keywords: data.value?.tags?.slice(0, 6).join('、'), // ≤ 10 个

  // ── 分享卡片图(必传,详情页用业务真实封面) ──
  image: data.value?.cover ?? '/og/default.jpg',

  // ── Article schema(详情页必填) ──
  type: 'article',
  article: {
    author: data.value?.author,
    datePublished: data.value?.publishedAt,
    dateModified: data.value?.updatedAt,
  },

  // canonical 默认取当前 URL,一般无需手填
})

7.4 字段长度建议

字段推荐长度备注
title10–70 字符偏短:搜索结果不显眼;偏长:被搜索引擎截断
description50–200 字符偏短:浪费 SEO 优化机会;偏长:被截断
keywords≤ 10 个 / 200 字符关键词堆砌反作用

更详细的最佳实践(Core Web Vitals、HTML 语义化、Schema.org、GEO 等)见 Swift Web SEO 最佳实践。


八、Public / Private API 分离

完整指南:渲染模式选用指南 §九。

8.1 语义红线(本章的全部要点)

ISR / prerender 页面的 SSR 执行路径上,不得调用 private API。

private = 响应因用户而异(登录态 / 用户身份 / 会员价 / 收藏状态 / 订单等)。 ISR 把首访者看到的 HTML 烙进缓存供所有人复用 —— 私有数据混进去 = 首访者的数据广播给所有访客,是安全 / 隐私事故,比缓存爆炸或数据陈旧严重得多。

「SSR 执行路径」指 <script setup> 同步顶层,以及 useAsyncData / useFetch 等 SSR 包装器的 直接回调。事件处理器 / onMounted / 普通函数体内的调用是 client 触发,不受此限 —— 这正是下面「混合页拆分」的合法形态。

历史约定已废弃:早期设想过 app/api/{public,private}/ 物理分目录 + useFooPrivateApi 命名前缀。codegen 落地后 app/api/ 的结构由生成器决定,该设想未被任何仓采用。 别再按它写代码或审 PR。

8.2 怎么知道哪个 API 是 private

事实源是后端标注,不是前端目录名:

后端源标注方式前端产物
OpenAPI(自研 server)端点带 x-sw-visibility: public | privategenerate-api 产出 app/api/visibility.json(<module>.<fn> → public | private),并在生成函数 JSDoc 带 @swVisibility
YAPI(公司旧后端)暂无标注位无 manifest —— 靠约定 + review 把关

visibility.json 只有 OpenAPI 源的项目才有。公司旧后端走 YAPI,不支持该标注 —— 那类项目下面 8.5 的 isr-no-private-api 机检会静默 no-op,只能靠约定 + review。 别以为装了 framework 就自动有这道机检。

app/api/ 的实际结构由 generate-api 决定(index.ts / modules/ / types/,OpenAPI 源 额外多一个 visibility.json),禁止手改。消费形态是 auto-import 的全局 api 对象 (api.movie.detail()),页面里没有 import 语句。

8.3 混合数据页面强制拆分

主体 public + 附加 private 的页面(详情页最常见)必须拆:

  • 主请求走 public API + ISR 缓存(SSR 路径)
  • 附加私有信息(is_favorited / 会员价 / 个人推荐 / 进度)单独在 onMounted 后 CSR 拉取并回填

8.4 反例 / 正例

// ❌ ISR 页在 SSR 路径调 private API —— 用户订单被烙进缓存,全员看到
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.user.orders())

// ❌ 主体 + 附加信息混在同一请求 —— is_favorited 跟着主体一起被缓存
const { data } = await useAsyncData(() =>
  api.movie.detail({ id, userId: useUserStore().info?.user_id }),
)
// ✅ SSR 路径只调 public API
defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData(() => api.movie.detail({ id }))

// ✅ 附加私有信息单独 CSR 回填(client 路径,lint 不报也确实安全)
const isFavorited = ref(false)
onMounted(async () => {
  isFavorited.value = await api.movie.hasFavorite({ id })
})

8.5 ESLint 强制(已落地)

framework 从路由路径段维度机检兜底「ISR 不碰用户态 / 付费内容」,两条规则归 @swift-web/framework/eslint、recommended 默认 error:

  • swift-web/no-isr-on-paid-paths:付费内容路径段(read / play / video / watch / audio 等,可 segments 覆盖)禁 isr / swr / prerender。
  • swift-web/no-isr-on-user-state-paths:用户态路径段(mine / orders / wallet / vip 等,可 segments 覆盖)必须 ssr: false。

按路由路径判定,与下面 isr-no-private-api 的按调用的 API 判定互补:后者精确但需要 visibility.json(仅 OpenAPI 源有),前两条粗粒度但任何源都生效。

  • swift-web/isr-no-private-api:8.1 那条语义红线的直接机检。读 app/api/visibility.json (路径可用 manifest 选项覆盖);同文件内声明了 isr / prerender,且 private API 调用出现在 SSR 执行路径时报错,client 触发路径不报。⚠️ manifest 不存在时静默 no-op (YAPI 源项目零影响,也就是说那类项目这道机检等于没有 —— 由下面的 L5 兜底)。

8.6 运行时护栏 L5:可缓存页的 SSR 路径不得携带用户态

这是 YAPI 源项目唯一生效的一层,也是所有项目的最后防线。 它不依赖任何后端标注。

它守的事故形态:ISR 页的首个访问者恰好已登录 → 他的 token 随 SSR 请求发到后端 → 后端返个性化数据(is_favorited / 会员价 / 进度)→ 烙进共享缓存广播给所有访客。 本地 dev 完全发现不了(dev 不走缓存)。

判据不是「调的是不是 private 接口」,而是「这次渲染会不会因用户而变」 —— 同一个 /movie/detail,带不带 token 后端就可能返不同内容。而「带没带身份」是个精确、通用、 零后端依赖的信号,这正是它能覆盖 YAPI 存量项目的原因。

SSR 期发请求
  ├─ 没有 token?                 → 整段跳过(未登录访客,零开销)
  ├─ 本页可缓存吗?               → 查 routeRules,isr / prerender / cache / swr 四键任一为真即算
  │                                 (取不到路由上下文时按「不判」处理,避免误伤 nitro 任务)
  └─ 是 ⇒ 分档处理
       ├─ dev + 真实登录用户(有 user_id) → 直接 throw,当场拦住
       ├─ dev + 仅设备匿名(无 user_id)   → console.warn,不打挂页面
       └─ dev 与 prod 都执行:剥离 token 后重发
            └─ 后端返 errorCode 2002("要登录")→ **显式 throw**,不走静默重登重试

为什么剥离是安全的:实测旧后端 7 个公开端点 token-less 全部返 status=y ⇒ 它是 按接口鉴权、不是网关强制,剥 token 不会把正常 ISR 页打挂,只有真正依赖身份的接口才会抱怨。

为什么 2002 必须自己 throw:旧后端私有端点无 token 时返 HTTP 200 + errorCode 2002, 不是 4xx。而 nitro 的「状态码 ≥400 不入缓存」保护只看 HTTP 状态码 —— 200 照缓存不误。 另外,剥离过身份的这次不能走重登重试(重试会把身份加回去,等于绕过护栏),故走单独分支直接抛。

已知边界:L5 只守 framework 的 request() 通道。applier 直接用 raw useFetch / $fetch 调后端时,Nuxt 默认会转发 cookie,L5 完全看不见。可缓存页的取数一律走 request() (或 generate-api 产出的 api.*),别绕开。

另有两条同属 recommended、同为 error 的 routeRules 规则:

  • swift-web/no-ineffective-route-rules:defineRouteRules 里禁写 swr / cache / static,也禁空对象 {}。前三个虽是 nitro 合法键,但整套规范、文档、决策树都围绕 isr 写——直写它们只会让人和 AI 都难判断,收益为零;空对象则是「声明了等于没声明」。
  • swift-web/no-query-in-cacheable-ssr:可缓存页(isr / prerender / swr / cache)的 SSR 路径禁读 route.query。这是反代层 path-only cache key 的前置条件——读了会让不同参数共享同一份缓存、内容错配。详见 渲染模式选用指南。
    判定刻意保守(宁漏报不误报):只报三种确定在 SSR 期求值的位置——<script setup> 顶层直接读、computed(() => route.query.x) 回调内、SSR 数据包装器(useAsyncData / useFetch / callOnce 等)的直接回调内。任何普通函数体内(onMounted / 事件处理器 / watch 回调)一律放过——那正是本规则推荐的 CSR 回填形态。

    ⚠️ computed 是主战场:applier 的实际写法就是 const order = computed(() => String(route.query.order ?? '')),别以为「惰性求值所以不算 SSR」——被 SSR 路径消费时它就在 SSR 期跑。


九、Framework 组件优先(避免重复造轮子)

PR 引入了与框架已有组件 / utility 功能重叠的自实现,必须改用 framework 提供的版本。

业务自实现迹象框架已有应改用
原生 <img> / 自封装 imageSwImage(自带 ratio + 懒加载 + avif/webp srcset)
原生 <button> / 自封装按钮SwButton(标准按钮,配 防 double-submit)
原生 <a> / 路由跳转SwLink(默认一律 SwLink;<NuxtLink> 仅限 custom v-slot 具名例外)
自实现 modal / dialogSwModal
自实现 toast / messageuseToast()
自实现弹窗确认useModal()
主题切换useTheme()
AES 加解密decryptBinary / decryptBase64 / encryptDynamic
MD5 / 文件 hashmd5 / md5File
IndexedDB 操作dbPut / dbGet / dbClear
本地缓存(带加密)useLocal / useSession
HTTP 请求request / createApiFetch(带统一 AES + 错误处理)
上传文件分片useUploader / createUploader
二维码生成 / 扫描SwQrcodeGenerator / SwQrcodeScanner
视频 / 音频播放器SwVideoPlayer / SwAudioPlayer / SwVideoPreview
富文本编辑SwEditor
表单字段封装SwFormField
文件上传SwUploadImage / SwUploadVideo
全局状态useUserStore / useSystemStore(不要重新建 user / system 类 store)
SEO 元信息useSeo()(自带 og / twitter / canonical / JSON-LD / robots)

反例:

<!-- ❌ 自实现 image 缺 ratio + 懒加载 + 现代格式 -->
<template>
  <img :src="product.cover" :alt="product.name" />
</template>

正例:

<!-- ✅ 改用 SwImage -->
<template>
  <SwImage
    :src="product.cover"
    :alt="product.name"
    :ratio="16 / 9"
    loading="lazy"
  />
</template>

MR review 模板: 此处自实现 <img> 缺少 ratio + 懒加载 + avif/webp srcset,请改用 SwImage。详见 framework/CLAUDE.md 第六章 + components/sw-image。


十、SSR 安全

10.1 顶层禁访浏览器 API

<script setup> 顶层(或 framework src/runtime/)访问 document / window / localStorage / sessionStorage —— SSR 期会爆栈。

// ❌ 错误:顶层直接访问 window
<script setup lang="ts">
const userAgent = window.navigator.userAgent  // SSR 期 ReferenceError
const stored = localStorage.getItem('token')   // 同上
</script>

// ✅ 正确:onMounted 守卫(仅 client 执行)
<script setup lang="ts">
const userAgent = ref('')
const stored = ref<string | null>(null)
onMounted(() => {
  userAgent.value = window.navigator.userAgent
  stored.value = localStorage.getItem('token')
})
</script>

// ✅ 正确:import.meta.client 守卫
<script setup lang="ts">
let stored: string | null = null
if (import.meta.client) {
  stored = localStorage.getItem('token')
}
</script>

10.2 跨组件共享状态用 useState 而非 ref

// ❌ 错误:模块级 ref 跨请求会串数据
// app/composables/useCart.ts
const items = ref<CartItem[]>([])
export function useCart() { return { items } }

// ✅ 正确:useState 每个请求独立
// app/composables/useCart.ts
export function useCart() {
  const items = useState<CartItem[]>('cart-items', () => [])
  return { items }
}

为什么? Nuxt SSR 是单进程多请求,模块级 ref 是单例,多个用户的请求会共享同一个 ref 实例,造成串数据。useState 把状态绑到当前请求 context,每请求独立。

MR review 模板: 顶层访问 window 在 SSR 期会爆炸。包到 onMounted 或加 import.meta.client 守卫。


十一、Git 协作规范

11.1 分支命名

分支模型推荐并默认 GitLab feature-branch flow(不是唯一允许的做法,机械层只保护两条固定分支本身):dev/main(测试线,默认分支)与 main(生产线)都是 protected、push = No one(maintainer 也推不上),所有变更走短期分支 + MR。

适用对象 = applier(下游业务项目)。swift-web 内部 5 仓(framework / cli / starter / docs / standards)的实际开发是直接 commit + push dev、不走 MR。

一次任务的完整循环:

git switch dev/main && git pull --ff-only          # 1. 从最新 dev/main 出发(本地 dev/main 永远只 pull,不 commit)
git switch -c feat/dark-theme                      # 2. 短期分支,一个任务一条
# …开发、按 task 原子 commit…
git push -u origin feat/dark-theme                 # 3. 推分支,开 MR → dev/main
git switch dev/main                                # 4. 提完 MR 立刻切回(bot 挑刺要改时再切回去)
# MR 合并 → 远端分支自动删;本地:git pull --ff-only && git branch -D feat/dark-theme

发版:dev/main → main 一个 MR(不拆、不 squash);按当前 review 和检查门禁合并。CI 不部署,完整 E2E 本地执行并留证;实际发布由维护者/运维在 CI 之外手动执行。hotfix/ 同样先走 MR 进 dev/main,再走发布 MR。

长期分支 dev/vN 已退役(2026-08,standards#51)。它卖的是「不受保护、随便推」,可短期分支同样随便推;它多出来的只有「长命 + 共享」,而全部麻烦正来自这四个字——不能拿它当 MR 源(合并会删源分支)、cherry-pick 后与 dev/main 永久 SHA 分叉、git log 虚高、做完的活躺在分支上 CI 看不见,最后不得不写一个 sync-devmain 工具来抵消模型的固有缺陷。存量 dev/v1 的退役步骤见 11.10。

前缀用途示例
feat/新功能开发feat/sw-image-avif-srcset
fix/bug 修复fix/login-redirect-loop
perf/性能优化(不改功能)perf/image-srcset
style/只改格式 / 样式风格,不改逻辑style/prettier-reflow
build/构建 / 打包配置build/vite-7
refactor/重构(不新增功能、不修 bug)refactor/use-fetch-error-handling
chore/工程 / 依赖 / CI 之外的配置 / 规则同步chore/fw-0.140.0
docs/只改文档docs/deploy-runbook
test/只改测试test/routes-smoke-samples
ci/只改 CIci/e2e-gate-split
hotfix/紧急修复(仍 MR 进 dev/main,随即走发布 MR)hotfix/payment-callback-500
epic/短命集成分支:跨多个 MR 的版本 / 大功能(以前会开 dev/v2 的场景)——子分支 MR 进它、它定期 git merge origin/dev/main、做完后一个 MR 进 dev/main 并删除;有终点、不叫 dev/v*epic/v2-wallet

命名规则:

  • 小写字母 + 短横线,禁止下划线 / 驼峰 / 中文
  • 描述部分不超过 30 个字符
  • 描述精炼到名词短语(动词收敛到前缀),如:feat/dark-theme 而非 feat/add-dark-theme-support
  • 关联 issue 时分支名可带 issue 号:fix/123-login-redirect
  • 前缀是约定不是门禁:verify 首步只对不合约定的源分支打 ⚠️ 提示(含建议改名),不红、不拦合并;GitLab 原生生成的分支名(Revert / Cherry-pick 按钮的 revert-<sha> / cherry-pick-<sha>、issue 页「Create merge request」的 <iid>-<slug>)直接视为合规、原样保留

反例对照:

反例问题修正
feature_dark_theme用下划线 + 错误前缀feat/dark-theme
fix/LoginBug驼峰 + 描述模糊fix/login-redirect
feat/添加暗色主题切换功能中文 + 超长feat/dark-theme
dev/main 上直接 commitpre-commit 拒绝、服务端也推不上git switch -c feat/xxx + MR
dev/v2 长期分支长期分支工作流已退役(名字本身不禁止)从 origin/dev/main 切短期分支
release/0.6.0发版不再开分支dev/main → main MR + prod/* tag

11.2 Commit Message 格式

遵循 Conventional Commits 简化版:

<type>(<scope>): <中文描述>

[可选正文:解释 why,不解释 what(diff 会说话)]

[可选 footer:Closes #N / Refs #N / Co-Authored-By: ...]

格式规则:

  • 标题非空且 ≥ 5 字符——由 commit-msg hook 强制(@swift-web/standards 的 dist/commit-msg/cli.js,经 simple-git-hooks 注册;空 message、wip / fix / . 这类过短标题直接被拒绝 exit 1)
  • 描述必须言之有物:禁 update / 修改 / fix bug 等无信息量占位、禁与改动无关的描述(hook 无法机械拦截,靠 review 与 AI 协作自觉)
  • 标题 ≤ 72 字符(GitLab UI 一行展示边界)
  • 标题末尾不加句号
  • 正文 每行 ≤ 100 字符(避免水平滚动)
  • 标题与正文之间空一行
  • 描述部分用中文(团队主要语言),技术术语 / API 名 / 包名保留英文
  • type / scope / footer trailers 保留英文(工具链解析需要)

11.3 Type 枚举

Type用途
feat新增功能或页面
fix修复 bug
perf性能优化(不改变功能)
refactor重构(既不新增功能,也不修复 bug)
style代码格式调整(缩进 / 空格 / 分号),不改逻辑
test新增 / 修改测试
docs文档变更(含 CLAUDE.md / wiki / README)
chore构建 / 依赖 / 配置变更
ciCI/CD 变更
revert回滚

11.4 Scope 建议

scope 用 kebab-case,指向改动主体所在子模块 / 主题,不写 file path。

示例 scope对应模块
sw-imageframework SwImage 组件
use-modalframework useModal composable
requestframework HTTP 请求工具
nuxt-config项目 nuxt.config.ts
eslintESLint 配置
auth业务模块:登录 / 鉴权
order业务模块:订单
seo业务模块:SEO

scope 不明确时可省略:feat: 支持暗色主题。

11.5 提交示例(多个)

最小示例:

feat(产品页): 添加产品详情页路由与数据获取
fix(导航): 修复移动端菜单关闭后滚动锁定问题
perf(图片): 使用 SwImage 替换所有原生 img 标签
chore(依赖): 升级 @swift-web/framework 至 v0.7.x

完整示例(含正文 + footer):

feat(sw-image): 新增 avif srcset 支持

旧版只输出 webp,部分新浏览器优先 avif(体积更小 30%)。
本次扩充 SwImage 的 generator script,构建期同时输出 avif/webp/jpg 三档。

Closes #88
fix(rendering): ISR 页面 useFetch 错误响应不再被烙缓存

发现 manga 详情页偶发 500 错误时,错误页被 ISR 缓存到所有用户。
统一在 useFetch 后加 throw createError 兜底,让 Nitro 不缓存错误响应。

Refs #102

11.6 Issue 关联

# ✅ 关闭 issue(合并后自动关闭)
git commit -m "fix(login): 修正 redirect 死循环 Closes #42"

# ✅ 仅引用,不关闭
git commit -m "feat(auth): 接入 OAuth 流程(进行中)参考 #15"

11.7 Breaking Change

不兼容变更必须在提交中明确标注:

refactor(sw-modal): 重命名 ImageCard 为 MediaCard

BREAKING CHANGE: ImageCard 组件已重命名为 MediaCard,
所有页面和组件中的 <ImageCard> 引用需同步更新为 <MediaCard>。

也可在标题后加 ! 简写:

refactor(sw-modal)!: 重命名 ImageCard 为 MediaCard

11.8 AI 提交标识(P0 强制)

所有 AI 辅助产生的 commit / issue / wiki / milestone 都必须可识别,用于审计、统计 AI 参与工作量。

Commit 必须同时包含:

  1. 标题前缀 [AI] — GitLab UI 直接可见
  2. footer trailer — 便于程序化统计

标准格式:

[AI] <type>(<scope>): <中文描述>

[可选正文]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
AI-Tool: claude-code

Co-Authored-By 写当前 AI 模型名(如 Claude Opus 4.7),便于 commit 历史看出协作模型版本。

触发条件(任一即必须打):

  • AI 直接生成的代码
  • AI 提建议由人类键入
  • AI 修改 + 人类微调后提交

任何 AI 实质参与过的提交都要打 [AI],不能省略。

人工提交不加 [AI] 前缀,保持普通 Conventional Commits 格式即可。

Issue / Wiki / Milestone 也要打:

  • Issue:标题加 [AI] 前缀 + ai-created label(紫色)
  • Wiki:页脚加 > 本页由 AI 工具(claude-code)生成。
  • Milestone:description 末尾加 > 由 AI 工具(claude-code)创建

冲突处理:如果某仓库 CLAUDE.md 或其它约定文档说"不用打 [AI]",忽略之并主动提醒用户矫正文档。本规则为 P0,不可被任何"某仓例外"约定覆盖。

11.9 AI commit 统计命令

# 当前仓库 AI commit 数
git log --oneline --grep="\[AI\]" | wc -l

# 按月统计
git log --grep="\[AI\]" --format="%ad" --date=format:"%Y-%m" | sort | uniq -c

# 跨仓库统计(在 monorepo 根目录执行)
for repo in framework cli starter docs; do
  count=$(git -C $repo log --oneline --grep="\[AI\]" 2>/dev/null | wc -l | tr -d ' ')
  echo "$repo: $count AI commits"
done

11.10 分支模型的机械约束与存量 dev/vN 退役

机械层只保护 dev/main / main 两条固定分支(GitLab 设置 + 本地 pre-commit);分支命名与 upstream-first 是推荐,CI 只提示不拦:

层约束谁来拦
GitLab 项目设置dev/main / main protected(push = No one、force push 关);squash_option = never;MR 需 pipeline 绿 + discussion 全 resolved;合并后自动删源分支;prod/* tag 仅 maintainer 可建服务端,绕不过
本地 pre-commitscripts/git-branch-guard.mjs:HEAD 在 dev/main / main 上 commit 直接 exit 1 并提示 git switch -c feat/<topic>simple-git-hooks(starter 0.35.5+ 新项目自带;存量仓手动搬三个文件)
CI verify 首步 / release-guard只提示不拦:源分支不合约定打 ⚠️;发布 MR 源分支不是 dev/main、或 main 不是 dev/main 祖先时打 ⚠️ 并给指引.gitlab-ci.yml

GitLab 建 issue / MR 时自动填入 starter 自带的 .gitlab/issue_templates/Default.md(SOP 五段)与 .gitlab/merge_request_templates/Default.md(Closes #N / e2e 适用声明 / 留证 / 风险面)。GitLab CE 没有 push rules;分支命名只是约定,机械层只保护 dev/main / main 两条分支本身——不加 dev/v* 之类的名字通配(让名字非法 ≠ 防止某种做法,会把正当用途一起封死)。

存量 dev/v1 退役(一次性,每仓一个 issue):先确认没人还在往它提交 → 把净差异拆小 MR 合进 dev/main → 判空贴证 → 删分支。判空不要用裸 git diff / git cherry / git log dev/main..dev/v1:2-dot 把 dev/main 领先的文件也算进去,3-dot 与 patch-id 类判据被 cherry-pick 历史骗过。用临时合并:

git fetch origin dev/main dev/v1 -q
git worktree add --detach /tmp/retire-wt origin/dev/v1
git -C /tmp/retire-wt merge --no-edit origin/dev/main   # 把 dev/main 合进 dev/v1 的本地副本;冲突 hunk 先判方向(见下)
git -C /tmp/retire-wt diff origin/dev/main --stat       # 空 ⇒ dev/v1 没有任何 dev/main 缺的内容——贴到 issue 才算判空
git worktree remove --force /tmp/retire-wt
git push origin --delete dev/v1                         # 不可逆:只在贴证 + 拍板之后

发布与回退:发布 MR 与普通 MR 均按当前 review/检查门禁执行,合并不触发部署。CI 只做类型、lint、单测/覆盖率和构建校验;完整 E2E 本地运行,绑定最终源 SHA 留证。测试/生产均由维护者或运维在 CI 之外手动部署,记录环境、提交/产物、执行人和业务验收结果;应急回退到已核验的历史提交/产物,不 retry CI 部署 job。实际规则以 standards 主源和各仓当前配置为准。

合并里同内容两边自动收敛,剩下的才是真差异。冲突 hunk 不是「待合并的差异」,是「待判定的方向」:HEAD 侧 = dev/v1、origin/dev/main 侧 = 目标,先确认哪一侧更新——dev/v1 往往是陈旧的一侧(实测过:冲突文件全是 dev/main 已修 / 已 revert 的版本),照「都合进去」处理会把已 revert 的坏改动放回去;只有确认 dev/v1 侧确实更新的 hunk 才拆 MR。非空就继续拆 MR,不要把 dev/main 真的 push 回 dev/v1(那是长期分支时代的 re-converge,退役不需要)。


十二、其他常见 Nuxt 4 / Vue 3 最佳实践

12.1 数据获取

// ✅ 服务端拉数据用 useFetch / useAsyncData(享 SSR + hydration 优化)
const { data } = await useAsyncData('products', () => api.product.list())

// ❌ 直接 fetch(失去 SSR 数据预取 + hydration 同步)
const products = await api.product.list()   // 裸调,SSR 期不预取

12.2 列表渲染必带 :key

<!-- ✅ 用稳定 id 作 key -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

<!-- ❌ 用 index 作 key(数据顺序变动时 patch 错位) -->
<li v-for="(item, i) in items" :key="i">{{ item.name }}</li>

12.3 大列表虚拟化

1000+ 条目用 vue-virtual-scroller 或类似库;普通列表(< 200 条)无需。

12.4 v-html 必须先消毒

// ❌ 直接渲染用户输入(XSS 风险)
<div v-html="userInput" />

// ✅ 用 DOMPurify 消毒后渲染
import DOMPurify from 'dompurify'
const safeHtml = computed(() => DOMPurify.sanitize(userInput.value))
// <div v-html="safeHtml" />

12.5 setup 顶层并行 await

// ❌ 串行 await(性能反模式:A 完才请求 B)
const { data: a } = await useFetch('/api/a')
const { data: b } = await useFetch('/api/b')

// ✅ 并行 await
const [{ data: a }, { data: b }] = await Promise.all([
  useFetch('/api/a'),
  useFetch('/api/b'),
])

12.6 computed 内不做副作用

// ❌ computed 内 console.log / 改其他状态
const total = computed(() => {
  console.log('计算 total')   // 副作用
  fetchExtra()                 // 更严重:触发请求
  return items.value.reduce((s, i) => s + i.price, 0)
})

// ✅ 副作用用 watch / watchEffect
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))
watch(total, (n) => {
  if (n > 1000) showCouponHint()
})

12.7 HTML 语义化 / 可访问性

  • 链接跳转:用 @click + router.push / + navigateTo 做路由跳转 → 爬虫只跟 <a href>,onClick 跳转对搜索引擎完全不可达。默认改用 <SwLink>(<NuxtLink> 仅限 custom v-slot 具名例外)
  • 标题层级:每页只能一个 <h1>;<h2>/<h3> 不跳级;不要为了视觉用 <h3>(应改 class 控制字号)
  • 语义元素:用 <header> / <nav> / <main> / <article> / <section> / <footer> / <aside>,不要全用 <div>
  • 图片 alt:<SwImage> 必传 alt(描述图片内容/语义),装饰性图片用 alt="",不能省略 attribute
  • 表单 label:<input> 必有关联的 <label for=> 或 aria-label,placeholder 不能替代 label
  • anchor text:链接文字描述性(了解 SEO 优化技巧 优于 点这里)
  • 外链 rel:站外链接加 rel="nofollow";target="_blank" 必须配 rel="noopener noreferrer"(防 tab 劫持)
<!-- ❌ 错误:onClick 跳转,爬虫看不到 -->
<button @click="router.push('/products')">查看商品</button>

<!-- ✅ 正确:用 NuxtLink / SwLink -->
<SwLink to="/products">查看商品</SwLink>

<!-- ❌ 错误:placeholder 替代 label -->
<input placeholder="请输入手机号" />

<!-- ✅ 正确:label + aria-label -->
<label for="phone">手机号</label>
<input id="phone" placeholder="例:13800138000" />

十三、测试同步与门禁

改源码必须同步测试(P0 第 9 条),测试分单测与 e2e 两轨,各守一类问题:单测守逻辑分支与数据映射,e2e 守 SSR 产出、响应头、路由守卫、真浏览器行为——后者的失效形态是「dev / lint / 单测全绿、线上才炸」(整站 SSR 被关成空壳、详情页串号、ISR 空态文案发给爬虫都属此类)。

e2e 是否适用:按改动判,不按比例凑

判据一句话:这个改动的正确性能否在 happy-dom / mock 下被单测断言?不能 ⇒ e2e 适用。

e2e 适用(同 PR 必须带 e2e 用例)e2e 不适用(不勉强凑数)
页面级产出:路由 / SSR HTML 与响应头 / SEO 标签 / sitemap / robots纯函数 / 工具 / 类型 / 常量 / 配置映射
影响 SSR 首屏与空态;改接口返回形状无 UI 副作用的数据转换、构建脚本、文档
用户流程(登录拦截、表单、导航、付费墙);路由重定向 / 守卫;中间件、插件、server route纯视觉数值(token / 间距 / 颜色)——用截图留证
真浏览器才有的行为:滚动 / 触摸 / touch-action / IntersectionObserver / computed style

不适用时在 issue 或 MR 描述里显式写一行「e2e 不适用:<清单项>」——判定必须可见,禁止沉默跳过;mr-review 按 diff 路径核对(规则 TS-001)。护栏性 e2e 必须附变异验证(改坏→红→还原→绿)。

门禁在哪里跑

MR 的 verify job 只做类型、lint、单测/覆盖率和构建校验,不安装浏览器系统库、不运行完整 E2E、不部署。CI 留证限于实际执行的检查;完整 pnpm test:e2e 本地运行,在 MR 中记录最终源 SHA、环境/样本和用例结果,交叉复核仍独立执行。

覆盖率基线是逐仓 ratchet,只升不降

vitest.config.ts 的 coverage.thresholds 就是本仓基线,CI 低于即红。只门 lines(主)+ branches(副)。新项目默认 lines 80 / branches 65(随 starter 模板);存量项目接入时基线 = 自身实测向下取整,先止跌再按 +5 抬;要降必须在配置同处注释理由 + 关联 issue(规则 TS-002)。coverage.exclude 只排除 codegen 产物——判据是「是否生成代码」,不是目录名。

e2e 页面覆盖是完整性检查,不是百分比

tests/e2e/routes.ts 是路由冒烟清单,routes.smoke.spec.ts 逐条断 200 / 非空壳 / <title> / 可缓存页带 s-maxage / 水合无报错;pnpm test:e2e 前置跑 check-e2e-routes:静态页 100% 必列、动态页 ≥1 样本,缺页即红——新增页面不列清单就红(规则 TS-003)。动态路由样本:mock 仓用夹具 id;真后端内容站「列表第一条 → 详情」两跳,空库显式 test.skip;登录墙后的页面按「测试登录态注入」约定(环境变量测试账号 + tests/e2e/helpers/login.ts 取真实 token 写 framework cookie、复用 storageState)拿到登录态,无账号时标 auth: true 列出——可见的债,不是静默跳过。

十四、注释风格

13.1 何时应该写注释

  • why 类:解释这段代码为什么这么写(业务约束、踩过的坑、上游限制),diff 看不出来
  • 公共 API:导出函数 / 组件 / composable 的 JSDoc(参数、返回值、抛错条件)
  • 复杂正则 / bit 运算 / 算法:旁注一句意图,避免后续维护者反向推导
  • 临时方案 / 已知问题:用 // TODO(@user, #issue): ... / // FIXME: ... 标注,必须带可追溯线索(issue 号或负责人)

13.2 何时不要写注释

  • 翻译代码(// 自增 i 旁标 i++)—— 噪声
  • 注释掉的旧代码块 —— 用 git history,不要堆在仓库里
  • 为了"覆盖率指标"而写的空泛 JSDoc(@param data data 这种)

13.3 JSDoc 约定

  • 公共 API(导出函数、组件 props / emits、composable 返回值)必须写 JSDoc
  • 内部函数按需写(参数复杂 / 含副作用 / 易误用时写)
  • JSDoc 用中文,类型签名用 TypeScript(不重复 @param {string},TS 类型已经声明)
/**
 * 上传单个文件并返回服务端签发的 URL。
 *
 * 实现细节:分片大小 4MB,失败自动重试 3 次。
 *
 * @throws 网络故障或服务端拒绝时抛 UploadFailedError
 */
export async function uploadFile(file: File): Promise<string> {
  // ...
}
// ✅ TODO 必带追溯线索
// TODO(@djatm, #142): WebSocket 心跳间隔需要随后端配置动态调整,目前先固定 30s
const HEARTBEAT_INTERVAL = 30_000

// ❌ 错误:TODO 无负责人 / issue
// TODO: 这里要改

十五、相关链接