Skip to content

Nuxt SSR实践:本地课程目录完整项目 · Nuxt课程目录的手写契约约定了什么至Nuxt本地API怎样限制输入和响应数据 ​

返回分篇目录

项目位于examples/web/p2-nuxt-ssr,以下源码直接包含同一目录中的真实文件,避免文档代码与项目分叉。示例未执行:未安装依赖、生成lock、调用Nuxt/nuxi、构建、启动、请求HTTP或打开浏览器。静态模板检查也不能证明框架集成正确。

Nuxt SSR实践:本地课程目录完整项目 ​

项目位于examples/web/p2-nuxt-ssr,以下源码直接包含同一目录中的真实文件,避免文档代码与项目分叉。示例未执行:未安装依赖、生成lock、调用Nuxt/nuxi、构建、启动、请求HTTP或打开浏览器。静态模板检查也不能证明框架集成正确。

Nuxt课程目录的手写契约约定了什么 ​

Nuxt课程目录的手写契约约定了什么?手写契约先确定三个只读接口:课程列表、课程详情与请求欢迎语;固定fixture使项目无需外部API和凭据。列表成功返回items,详情格式错误或不存在返回404,筛选无效返回400。错误仅承诺状态码,不依赖框架错误体的全部字段。页面统一显示公开消息,不能把API异常原文当产品文案。

这不是生成后的协议,也不是运行证明。没有POST意味着搜索表单只修改URL,不模拟保存。无认证意味着课程都公开,language仅演示请求作用域;未来增加私有课程必须在API实施真实鉴权,不能以页面守卫补上。contract.json在路由实现前手写,静态fixture是有限三条课程,没有分页或持久化承诺。

手写契约与固定数据
json
{
  "openapi": "3.1.0",
  "info": { "title": "本地 SSR 课程目录(手写契约,示例未执行)", "version": "1.0.0" },
  "paths": {
    "/api/courses": { "get": { "parameters": [
      { "name": "q", "in": "query", "schema": { "type": "string" }, "description": "去除首尾空白后最多40个UTF-16代码单元,再匹配标题,大小写不敏感;重复值拒绝" },
      { "name": "level", "in": "query", "schema": { "enum": ["all", "basic", "advanced"] }, "description": "省略为 all;重复值拒绝" }
    ], "responses": { "200": { "description": "最多三条公开固定课程", "content": { "application/json": { "schema": { "type": "object", "required": ["items"], "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Course" } } } } } } }, "400": { "description": "筛选输入无效" } } } },
    "/api/courses/{id}": { "get": { "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" } }], "responses": { "200": { "description": "公开课程", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Course" } } } }, "404": { "description": "格式无效或课程不存在" } } } },
    "/api/welcome": { "get": { "parameters": [{ "name": "Accept-Language", "in": "header", "schema": { "type": "string" }, "description": "仅取前两个字符,en 显示英文,其余使用中文;不是完整语言协商,也不是身份" }], "responses": { "200": { "description": "仅返回当前请求归一化的 language(en 或 zh)与 message;不回显原始头,不设 cookie" } } } }
  },
  "components": { "schemas": { "Course": { "type": "object", "additionalProperties": false, "required": ["id", "title", "level", "summary", "lessons"], "properties": { "id": { "type": "string" }, "title": { "type": "string" }, "level": { "enum": ["basic", "advanced"] }, "summary": { "type": "string" }, "lessons": { "type": "array", "items": { "type": "string" } } } } } },
  "x-boundaries": ["三个接口均为只读公开示例,不提供认证、持久化、写操作或任意 URL 代理", "全部 API 与 HTML 使用 no-store;未配置 CDN 缓存", "错误使用框架错误响应,仅状态码为本契约承诺;UI 不展示服务端异常详情"],
  "x-provenance": "先手写本文件,再实现本地路由;未运行生成器、协议工具或服务器"
}
json
[
  { "id": "vue-basics", "title": "Vue 响应式基础", "level": "basic", "summary": "从 ref 到组件输入输出。", "lessons": ["ref 与 computed", "props 与 emits"] },
  { "id": "router-state", "title": "Router 导航状态", "level": "advanced", "summary": "区分资源路径、筛选参数与请求生命周期。", "lessons": ["params 与 query", "旧响应与组件复用"] },
  { "id": "nuxt-ssr", "title": "Nuxt SSR 边界", "level": "advanced", "summary": "理解请求隔离、payload 与 hydration。", "lessons": ["服务端 HTML", "稳定数据键", "部署与缓存"] }
]

Nuxt课程目录怎样串起列表筛选和详情页面 ​

Nuxt课程目录怎样串起列表筛选和详情页面?首页表单把q与level提交到URL,列表按计算值请求本地接口;详情NuxtLink指向slug页面,useAsyncData使用包含id的键。列表把加载、错误、空结果分开:只有成功的items=[]才显示没有课程;失败可显式重试,不自动把网络错误变成空列表。

应用外壳负责欢迎语和compact偏好,课程页面负责资源数据。详情使用路径页面key重建,避免切换slug后旧页面错误继续留在新地址;数据key仍体现资源身份。错误页对404和其他故障给出不同公开消息,返回按钮clearError并导航到列表。表单、label、status/alert与focus样式给出基础可访问性结构,但没有声称经过屏幕阅读器或浏览器验收。

应用、页面与状态composable
vue
<script setup>
const config = useRuntimeConfig()
const compact = useReadingPreference()
const { data: welcome, error } = await useFetch('/api/welcome', { key: 'catalog:welcome:v1' })
useHead({ title: config.public.catalogTitle, htmlAttrs: { lang: 'zh-CN' } })
</script>

<template>
  <main :class="{ compact }">
    <header>
      <NuxtLink to="/">{{ config.public.catalogTitle }}</NuxtLink>
      <p v-if="error" role="alert">欢迎语暂不可用。</p>
      <p v-else-if="welcome">{{ welcome.message }}({{ welcome.language }})</p>
      <label><input v-model="compact" type="checkbox">紧凑展示(当前应用会话)</label>
    </header>
    <NuxtPage />
    <footer>公开固定课程,无登录、写入或外部 API。示例未执行。</footer>
  </main>
</template>
vue
<script setup>
const route = useRoute()
const router = useRouter()
const scalar = value => typeof value === 'string' ? value : ''
const q = computed(() => scalar(route.query.q).trim().slice(0, 40))
const level = computed(() => ['basic', 'advanced'].includes(scalar(route.query.level)) ? scalar(route.query.level) : 'all')
const draft = ref(q.value)
const draftLevel = ref(level.value)
watch([q, level], () => { draft.value = q.value; draftLevel.value = level.value })
const key = computed(() => `catalog:list:${JSON.stringify([q.value, level.value])}`)
const { data, status, error, refresh } = await useFetch('/api/courses', {
  key,
  query: { q, level }
})
async function search() {
  await router.push({ path: '/', query: { q: draft.value.trim().slice(0, 40), level: draftLevel.value } })
}
</script>

<template>
  <section>
    <h1>课程列表</h1>
    <form @submit.prevent="search">
      <label>标题<input v-model="draft" maxlength="40" name="q"></label>
      <label>级别<select v-model="draftLevel" name="level"><option value="all">全部</option><option value="basic">基础</option><option value="advanced">进阶</option></select></label>
      <button type="submit">应用筛选</button>
    </form>
    <p v-if="status === 'pending'" role="status">正在读取课程……</p>
    <div v-else-if="error" role="alert">课程读取失败。<button @click="refresh()">重试读取</button></div>
    <template v-else-if="data">
      <p>当前筛选:{{ q || '不限标题' }} / {{ level }}</p>
      <p v-if="data.items.length === 0">没有匹配课程,请修改筛选。</p>
      <ul v-else>
        <li v-for="course in data.items" :key="course.id">
          <NuxtLink :to="`/courses/${course.id}`">{{ course.title }}</NuxtLink>
          <p>{{ course.summary }}</p>
        </li>
      </ul>
    </template>
  </section>
</template>
vue
<script setup>
definePageMeta({ key: route => route.path })
const route = useRoute()
const id = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
if (!/^[a-z][a-z0-9-]{0,39}$/.test(id.value)) {
  throw createError({ statusCode: 404, statusMessage: 'Course not found' })
}
const requestFetch = useRequestFetch()
const key = computed(() => `catalog:detail:${id.value}`)
const { data: course, error } = await useAsyncData(key, () =>
  requestFetch(`/api/courses/${encodeURIComponent(id.value)}`))
if (error.value) {
  throw createError({
    statusCode: error.value.statusCode === 404 ? 404 : 503,
    statusMessage: error.value.statusCode === 404 ? 'Course not found' : 'Catalog unavailable'
  })
}
</script>

<template>
  <article v-if="course">
    <NuxtLink to="/">返回课程列表</NuxtLink>
    <h1>{{ course.title }}</h1>
    <p>级别:{{ course.level }}</p>
    <p>{{ course.summary }}</p>
    <ol><li v-for="lesson in course.lessons" :key="lesson">{{ lesson }}</li></ol>
    <nav aria-label="其他课程"><NuxtLink to="/courses/vue-basics">基础课程</NuxtLink> · <NuxtLink to="/courses/nuxt-ssr">SSR 课程</NuxtLink></nav>
  </article>
</template>
vue
<script setup>
const props = defineProps({ error: { type: Object, required: true } })
const missing = computed(() => props.error.statusCode === 404)
</script>

<template>
  <main>
    <h1>{{ missing ? '课程或页面不存在' : '课程服务暂不可用' }}</h1>
    <p>{{ missing ? '请检查课程地址,或返回列表。' : '请稍后从列表重新读取。' }}</p>
    <button @click="clearError({ redirect: '/' })">返回课程列表</button>
  </main>
</template>
javascript
export function useReadingPreference() {
  // Called within setup: Nuxt owns one initial state per SSR request.
  return useState('catalog:compact:v1', () => false)
}

Nuxt本地API怎样限制输入和响应数据 ​

Nuxt本地API怎样限制输入和响应数据?本地API只从冻结的公开fixture选择课程,拒绝非字符串筛选、超过40字符的q和未知level;详情先验证slug再查询。请求头只用于生成固定欢迎语,响应不暴露整个header或服务器配置。冻结嵌套lessons避免未来误写共享数据,但冻结并不等于权限系统,真正的保密依然来自输出范围。

模块层只存公开不可变数据;handler里的language和query属于当前event。所有接口加no-store,整体HTML也有相同header,避免示例被误读成安全共享缓存方案。没有调用任意URL,没有写入和重试副作用。移植到真实后端时需要补上数据库、权限与数据校验,而非把fixture包装成假生产服务。文件约定依据见Nuxt server目录。

服务端实现
javascript
import fixture from '../../fixtures/catalog.json'

// Only public immutable fixture data may be shared across requests.
const courses = Object.freeze(fixture.map(course => Object.freeze({
  ...course,
  lessons: Object.freeze([...course.lessons])
})))

export function listCourses(q, level) {
  return courses.filter(course =>
    (level === 'all' || course.level === level) &&
    course.title.toLowerCase().includes(q.toLowerCase()))
}

export function findCourse(id) {
  return courses.find(course => course.id === id)
}
javascript
import { listCourses } from '../../data/catalog.js'

export default defineEventHandler(event => {
  setHeader(event, 'cache-control', 'no-store')
  const query = getQuery(event)
  const q = query.q === undefined ? '' : query.q
  const level = query.level === undefined ? 'all' : query.level
  if (typeof q !== 'string' || q.trim().length > 40 ||
      typeof level !== 'string' || !['all', 'basic', 'advanced'].includes(level)) {
    throw createError({ statusCode: 400, statusMessage: 'Invalid filter' })
  }
  return { items: listCourses(q.trim(), level) }
})
javascript
import { findCourse } from '../../data/catalog.js'

export default defineEventHandler(event => {
  setHeader(event, 'cache-control', 'no-store')
  const id = getRouterParam(event, 'id') || ''
  const course = /^[a-z][a-z0-9-]{0,39}$/.test(id) ? findCourse(id) : undefined
  if (!course) throw createError({ statusCode: 404, statusMessage: 'Course not found' })
  return course
})
javascript
export default defineEventHandler(event => {
  setHeader(event, 'cache-control', 'no-store')
  const language = (getHeader(event, 'accept-language') || '').slice(0, 2).toLowerCase() === 'en' ? 'en' : 'zh'
  return { language, message: language === 'en' ? 'Welcome to the catalog' : '欢迎阅读课程目录' }
})

继续阅读