文章·  

构建一个隐私优先的反馈小组件

一个轻量级、注重隐私的反馈小组件,用于收集您对 Nuxt 文档的反馈,使用 Drizzle、NuxtHub 数据库和 Motion Vue 构建。
Hugo Richard

Hugo Richard

@hugorcd__

Sébastien Chopin

Sébastien Chopin

@Atinux

文档是 Nuxt 开发者体验的核心。为了持续改进它,我们需要一种简单有效的方式,直接在每个页面上收集用户反馈。以下是我们如何设计和实现我们的反馈小组件,其灵感来源于 Plausible 的隐私优先方法。

为什么需要反馈小组件?

目前,用户可以通过创建 GitHub Issues 或直接联系我们来提供对我们文档的反馈。虽然这些渠道很有价值并且仍然很重要,但它们要求用户离开当前上下文,并采取几个步骤来分享他们的想法。

我们想要一些不同的东西

  • 上下文相关:直接集成到每个文档页面中
  • 无摩擦:最多两次点击即可提供反馈
  • 尊重隐私:不进行个人跟踪,设计上符合 GDPR

技术架构

我们的解决方案包含三个主要组件

1. 带有 Motion 动画的前端

界面结合了 Vue 3 的 Composition API 和Motion for Vue以创建引人入胜的用户体验。该小组件使用布局动画实现平滑的状态过渡,并使用弹簧物理效果实现自然的反馈。useFeedback 可组合函数处理所有状态管理,并在用户在页面之间导航时自动重置。

例如,这是成功状态动画

<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="Feedback submitted successfully"
  >
    <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">
        Thank you for your feedback!
      </div>
      <div class="text-xs text-muted mt-1">
        Your input helps us improve the documentation.
      </div>
    </motion.div>
  </motion.div>
  <!-- ... -->
</template>

您可以在此处找到反馈小组件的源代码此处.

2. 受 Plausible 启发的匿名化

挑战在于在保留隐私的同时检测重复项(用户改变主意)。我们从Plausible的方法中汲取灵感,以无需 Cookie 即可计算独立访问者.

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. 具有冲突处理的数据库持久化

首先,我们定义反馈表的 schema,并在 pathfingerprint 列上添加唯一约束。

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以及 UPSERT 策略

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()
  }
})

这种方法允许用户在当天改变主意时进行更新,为新反馈创建记录,并自动实现每页和每个用户的去重。

您可以在此处找到服务器端源代码此处.

用于一致性的共享类型

我们使用 Zod 进行运行时验证和类型生成

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 中构建一个管理界面,以分析反馈模式并识别需要改进的页面。这将帮助我们根据真实用户反馈持续提高文档质量。

完整的源代码可在GitHub供您参考和贡献!