---
title: "构建一个隐私优先的反馈小组件"
description: "一个轻量、注重隐私的小组件，用于收集你对 Nuxt 文档的反馈，基于 Drizzle、NuxtHub 数据库和 Motion Vue 构建。"
canonical_url: "https://nuxt.zhcndoc.com/blog/building-a-feedback-widget"
---
# 构建一个隐私优先的反馈小组件

> 一个轻量、注重隐私的小组件，用于收集你对 Nuxt 文档的反馈，基于 Drizzle、NuxtHub 数据库和 Motion Vue 构建。

文档是 Nuxt 开发者体验的核心。为了持续改进文档，我们需要一种简单有效的方式，能直接在每个页面收集用户反馈。以下是我们设计并实现反馈小组件的过程，灵感来自 Plausible 的隐私优先理念。

## 为什么需要反馈小组件？

当前，用户可以通过创建 GitHub issue 或直接联系我们来提供文档反馈。虽然这些渠道很有价值且依然重要，但它们要求用户离开当前上下文，需要经过多个步骤才能分享想法。

我们想要的是不同的方案：

- **上下文相关**：直接集成在每个文档页面中
- **无障碍的**：最多 2 次点击即可提交反馈
- **尊重隐私**：无个人追踪，设计上符合 GDPR

::video{poster="https://res.cloudinary.com/nuxt/video/upload/so_0/v1749746517/nuxt/nuxt-feedback_lh6zyg.jpg" controls .rounded-lg}
  :::source{src="https://res.cloudinary.com/nuxt/video/upload/v1749746517/nuxt/nuxt-feedback_lh6zyg.webm" type="video/webm"}
  :::

  :::source{src="https://res.cloudinary.com/nuxt/video/upload/v1749746517/nuxt/nuxt-feedback_lh6zyg.mp4" type="video/mp4"}
  :::

  :::source{src="https://res.cloudinary.com/nuxt/video/upload/v1749746517/nuxt/nuxt-feedback_lh6zyg.ogg" type="video/ogg"}
  :::
::

## 技术架构

我们的解决方案包含三个主要部分：

### 1. 结合 Motion 动画的前端

界面结合了 Vue 3 的 Composition API 与 [Motion for Vue](https://motion.dev/docs/vue)，打造引人入胜的用户体验。该小组件使用布局动画实现状态平滑过渡，并采用弹簧物理效果营造自然反馈。`useFeedback` 组合函数负责所有状态管理，并在用户切换页面时自动重置。

例如，以下是成功状态的动画：

```vue
<template>
  <!-- ... -->
  <motion.div
    v-if="isSubmitted"
    key="success"
    :initial="{ opacity: 0, scale: 0.95 }"
    :animate="{ opacity: 1, scale: 1 }"
    :transition="{ duration: 0.3 }"
    class="flex items-center gap-3 py-2"
    role="status"
    aria-live="polite"
    aria-label="反馈提交成功"
  >
    <motion.div
      :initial="{ scale: 0 }"
      :animate="{ scale: 1 }"
      :transition="{ delay: 0.1, type: 'spring', visualDuration: 0.4 }"
      class="text-xl"
      aria-hidden="true"
    >
      ✨
    </motion.div>
    <motion.div
      :initial="{ opacity: 0, x: 10 }"
      :animate="{ opacity: 1, x: 0 }"
      :transition="{ delay: 0.2, duration: 0.3 }"
    >
      <div class="text-sm font-medium text-highlighted">
        感谢您的反馈！
      </div>
      <div class="text-xs text-muted mt-1">
        您的意见帮助我们改进文档。
      </div>
    </motion.div>
  </motion.div>
  <!-- ... -->
</template>
```

反馈小组件的源码可在[此处](https://github.com/nuxt/nuxt.com/tree/main/app/components/Feedback.vue)查看。

### 2. 受 Plausible 启发的匿名化处理

难点在于如何在保护隐私的前提下检测重复（用户更改意见）。我们借鉴了 [Plausible](https://plausible.io/) 关于[无 Cookie 统计独立访客](https://plausible.io/data-policy)的思路。

```typescript
export async function generateHash(
  today: string,
  ip: string,
  domain: string,
  userAgent: string
): Promise<string> {
  const data = `${today}+${domain}+${ip}+${userAgent}`

  const buffer = await crypto.subtle.digest(
    'SHA-1',
    new TextEncoder().encode(data)
  )

  return [...new Uint8Array(buffer)]
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}
```

该方法通过结合以下信息生成每日唯一标识：

- **IP + User-Agent**：由每个 HTTP 请求自然发送
- **域名**：实现环境隔离
- **当前日期**：确保标识每日更换

**为何安全？**

- IP 和 User-Agent 从不存储于数据库
- 哈希值每日变化，防止长期追踪
- 几乎无法从哈希值反推原始数据
- 设计符合 GDPR（无持久个人数据）

### 3. 具有冲突处理的数据库持久化

首先，我们定义反馈表的架构，并在 `path` 和 `fingerprint` 列上添加唯一约束。

```typescript
export const feedback = sqliteTable('feedback', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  rating: text('rating').notNull(),
  feedback: text('feedback'),
  path: text('path').notNull(),
  title: text('title').notNull(),
  stem: text('stem').notNull(),
  country: text('country').notNull(),
  fingerprint: text('fingerprint').notNull(),
  createdAt: integer({ mode: 'timestamp' }).notNull(),
  updatedAt: integer({ mode: 'timestamp' }).notNull()
}, table => [uniqueIndex('path_fingerprint_idx').on(table.path, table.fingerprint)])
```

随后，服务器侧使用 [Drizzle](https://drizzle.zhcndoc.com/docs/get-started) 结合 `UPSERT` 策略：

```typescript
await drizzle.insert(tables.feedback).values({
  rating: data.rating,
  feedback: data.feedback || null,
  path: data.path,
  title: data.title,
  stem: data.stem,
  country: event.context.cf?.country || 'unknown',
  fingerprint,
  createdAt: new Date(),
  updatedAt: new Date()
}).onConflictDoUpdate({
  target: [tables.feedback.path, tables.feedback.fingerprint],
  set: {
    rating: data.rating,
    feedback: data.feedback || null,
    country,
    updatedAt: new Date()
  }
})
```

这种方法支持用户当天更改意见时更新数据，新反馈则创建，同时实现了每页每用户的自动去重。

服务器端源码可在[此处](https://github.com/nuxt/nuxt.com/tree/main/server)查看。

## 共享类型确保一致性

我们使用 Zod 进行运行时校验及类型生成：

```typescript
export const FEEDBACK_RATINGS = [
  'very-helpful',
  'helpful', 
  'not-helpful',
  'confusing'
] as const

export const feedbackSchema = z.object({
  rating: z.enum(FEEDBACK_RATINGS),
  feedback: z.string().optional(),
  path: z.string(),
  title: z.string(),
  stem: z.string()
})

export type FeedbackInput = z.infer<typeof feedbackSchema>
```

该做法确保前端、API 和数据库之间的类型一致。

## 下一步计划

该小组件现已在所有文档页面上线。我们的下一目标是在 [nuxt.com](http://nuxt.com) 内部构建一个管理界面，分析反馈模式，识别需要改进的页面。这样可基于真实用户反馈，不断提升文档质量。

完整源码可在 [GitHub](https://github.com/nuxt/nuxt.com) 上获取，欢迎参考和贡献！


## Sitemap

See the full [sitemap](https://nuxt.zhcndoc.com/sitemap.md) for all pages.
