文章·  

構建一個隱私優先的反饋小元件

一個輕量級、注重隱私的反饋小元件,用於收集您對 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供您參考和貢獻!