Ver Fonte

feat(feedback): 设置页接入 Sentry 官方 Feedback 表单

用 feedbackIntegration 替换自写表单和 Cloudflare 提交链路。
嵌入设置页并跟随应用主题,关闭截图。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi há 1 semana atrás
pai
commit
32a455cdd6

+ 242 - 84
src/components/settings/sections/feedback-section.tsx

@@ -1,97 +1,255 @@
-import { useState } from "react"
-import { Send } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Label } from "@/components/ui/label"
-import { Textarea } from "@/components/ui/textarea"
-import { submitFeedback, type FeedbackType } from "@/lib/feedback"
+import { useEffect, useRef, useState } from "react"
+import * as Sentry from "@sentry/react"
+import { useTranslation } from "react-i18next"
 
-export function FeedbackSection() {
-  const [type, setType] = useState<FeedbackType>("suggestion")
-  const [message, setMessage] = useState("")
-  const [contact, setContact] = useState("")
-  const [submitting, setSubmitting] = useState(false)
-  const [status, setStatus] = useState("")
-
-  const canSubmit = message.trim().length > 0 && !submitting
-
-  async function handleSubmit() {
-    if (!canSubmit) return
-    setSubmitting(true)
-    setStatus("")
-    try {
-      await submitFeedback({ type, message, contact })
-      setMessage("")
-      setContact("")
-      setStatus("反馈已提交,谢谢。")
-    } catch (err) {
-      const text = err instanceof Error ? err.message : String(err)
-      setStatus(text)
-    } finally {
-      setSubmitting(false)
+type OfficialFeedbackForm = Awaited<
+  ReturnType<NonNullable<ReturnType<typeof Sentry.getFeedback>>["createForm"]>
+>
+
+const FEEDBACK_HOST_ID = "sentry-feedback"
+
+const EMBED_STYLE = `
+.dialog,
+.dialog__position,
+.success__position {
+  position: static !important;
+  inset: auto !important;
+  z-index: auto !important;
+  display: block !important;
+  width: 100% !important;
+  height: auto !important;
+  max-height: none !important;
+  padding: 0 !important;
+  background: transparent !important;
+}
+.dialog__header {
+  display: none !important;
+}
+.dialog__title,
+.form__right {
+  width: 100% !important;
+}
+.dialog__content {
+  max-width: none !important;
+}
+.form__label__text {
+  font-size: 0.875rem;
+  font-weight: 500;
+  line-height: 1.25rem;
+}
+.form__input {
+  min-height: 32px;
+  font-size: 0.875rem;
+  font-weight: 400;
+  padding: 4px 10px;
+}
+.form__input--textarea {
+  min-height: 180px;
+  padding: 8px 10px;
+}
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  width: 100%;
+  height: 32px;
+  padding: 0 12px;
+  font-size: 0.875rem;
+  font-weight: 500;
+  line-height: 1;
+  text-align: center;
+}
+.btn--default {
+  display: none !important;
+}
+`
+
+function themeToken(name: string, fallback = ""): string {
+  const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
+  return value || fallback
+}
+
+function appColorScheme(): "light" | "dark" {
+  return document.documentElement.classList.contains("dark") ? "dark" : "light"
+}
+
+function releaseBodyScroll(): void {
+  document.body.style.overflow = ""
+}
+
+function applyAppTheme(host: HTMLElement): void {
+  const foreground = themeToken("--foreground")
+  const background = themeToken("--background")
+  const card = themeToken("--card", background)
+  const primary = themeToken("--primary")
+  const primaryForeground = themeToken("--primary-foreground")
+  const border = themeToken("--border")
+  const input = themeToken("--input", border)
+  const ring = themeToken("--ring", primary)
+  const destructive = themeToken("--destructive")
+  const mutedForeground = themeToken("--muted-foreground", foreground)
+  const radius = themeToken("--radius", "0.5rem")
+  const font = themeToken("--qmai-ui-font-family", "inherit")
+
+  host.style.setProperty("--font-family", font)
+  host.style.setProperty("--font-size", "14px")
+  host.style.setProperty("--foreground", foreground)
+  host.style.setProperty("--background", card)
+  host.style.setProperty("--accent-foreground", primaryForeground)
+  host.style.setProperty("--accent-background", primary)
+  host.style.setProperty("--error-color", destructive)
+  host.style.setProperty("--success-color", primary)
+  host.style.setProperty("--border", `1px solid ${border}`)
+  host.style.setProperty("--outline", `1px solid ${ring}`)
+  host.style.setProperty("--box-shadow", "none")
+  host.style.setProperty("--dialog-background", card)
+  host.style.setProperty("--dialog-border", `1px solid ${border}`)
+  host.style.setProperty("--dialog-border-radius", radius)
+  host.style.setProperty("--dialog-padding", "16px")
+  host.style.setProperty("--dialog-box-shadow", "none")
+  host.style.setProperty("--input-color", foreground)
+  host.style.setProperty("--input-border", `1px solid ${input}`)
+  host.style.setProperty("--input-border-radius", radius)
+  host.style.setProperty("--input-placeholder-color", mutedForeground)
+  host.style.setProperty("--button-primary-background", primary)
+  host.style.setProperty("--button-primary-hover-background", primary)
+  host.style.setProperty("--button-primary-color", primaryForeground)
+  host.style.setProperty("--button-primary-hover-color", primaryForeground)
+  host.style.setProperty("--button-primary-border", "1px solid transparent")
+  host.style.setProperty("--button-primary-border-radius", radius)
+}
+
+function embedOfficialForm(container: HTMLElement): HTMLElement | null {
+  const host = document.getElementById(FEEDBACK_HOST_ID)
+  if (!host) return null
+
+  host.style.setProperty("--form-width", "100%")
+  host.style.setProperty("--page-margin", "0px")
+  host.style.setProperty("--inset", "auto")
+  host.style.setProperty("--z-index", "1")
+  host.style.width = "100%"
+  host.style.display = "block"
+  host.style.position = "static"
+
+  Sentry.getFeedback()?.setTheme(appColorScheme())
+  applyAppTheme(host)
+
+  if (host.parentElement !== container) {
+    container.appendChild(host)
+  }
+
+  const shadow = host.shadowRoot
+  if (shadow) {
+    let style = shadow.querySelector("style[data-qmai-embed]")
+    if (!style) {
+      style = document.createElement("style")
+      style.setAttribute("data-qmai-embed", "")
+      shadow.appendChild(style)
     }
+    style.textContent = EMBED_STYLE
+  }
+
+  return host
+}
+
+function returnFeedbackHost(): void {
+  const host = document.getElementById(FEEDBACK_HOST_ID)
+  if (host && host.parentElement !== document.body) {
+    document.body.appendChild(host)
   }
+}
+
+export function FeedbackSection() {
+  const { t, i18n } = useTranslation()
+  const hostRef = useRef<HTMLDivElement>(null)
+  const formRef = useRef<OfficialFeedbackForm | null>(null)
+  const [unavailable, setUnavailable] = useState(false)
+
+  useEffect(() => {
+    const container = hostRef.current
+    let cancelled = false
+
+    void (async () => {
+      formRef.current?.removeFromDom()
+      formRef.current = null
+      if (!container) return
+
+      const feedback = Sentry.getFeedback()
+      if (!feedback) {
+        if (!cancelled) setUnavailable(true)
+        return
+      }
+
+      const form = await feedback.createForm({
+        colorScheme: appColorScheme(),
+        formTitle: t("settings.sections.feedback.formTitle"),
+        submitButtonLabel: t("settings.sections.feedback.submit"),
+        cancelButtonLabel: t("settings.sections.feedback.cancel"),
+        nameLabel: t("settings.sections.feedback.name"),
+        namePlaceholder: t("settings.sections.feedback.namePlaceholder"),
+        emailLabel: t("settings.sections.feedback.email"),
+        emailPlaceholder: t("settings.sections.feedback.emailPlaceholder"),
+        messageLabel: t("settings.sections.feedback.message"),
+        messagePlaceholder: t("settings.sections.feedback.messagePlaceholder"),
+        isRequiredLabel: t("settings.sections.feedback.required"),
+        successMessageText: t("settings.sections.feedback.success"),
+        tags: { source: "settings" },
+        onFormClose: () => {
+          formRef.current?.open()
+          releaseBodyScroll()
+          embedOfficialForm(container)
+        },
+        onFormSubmitted: () => {
+          formRef.current?.open()
+          releaseBodyScroll()
+          embedOfficialForm(container)
+        },
+      })
+
+      if (cancelled) {
+        form.removeFromDom()
+        return
+      }
+
+      formRef.current = form
+      setUnavailable(false)
+      form.appendToDom()
+      form.open()
+      releaseBodyScroll()
+      embedOfficialForm(container)
+    })()
+
+    const syncTheme = () => {
+      if (container) embedOfficialForm(container)
+    }
+    const observer = new MutationObserver(syncTheme)
+    observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
+
+    return () => {
+      cancelled = true
+      observer.disconnect()
+      formRef.current?.close()
+      formRef.current?.removeFromDom()
+      formRef.current = null
+      releaseBodyScroll()
+      returnFeedbackHost()
+    }
+  }, [i18n.language, t])
 
   return (
     <div className="space-y-6">
       <div>
-        <h2 className="text-xl font-semibold">反馈与建议</h2>
+        <h2 className="text-xl font-semibold">{t("settings.sections.feedback.title")}</h2>
         <p className="mt-1 text-sm text-muted-foreground">
-          反馈会发送到青幕 AI 写作的 Cloudflare D1 后台,用于排查问题和改进体验。
+          {t("settings.sections.feedback.description")}
         </p>
       </div>
-
-      <div className="space-y-4 rounded-md border border-border p-4">
-        <div className="space-y-2">
-          <Label htmlFor="feedback-type">反馈类型</Label>
-          <select
-            id="feedback-type"
-            value={type}
-            onChange={(event) => setType(event.target.value as FeedbackType)}
-            className="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
-          >
-            <option value="suggestion">功能建议</option>
-            <option value="bug">问题反馈</option>
-            <option value="other">其他</option>
-          </select>
-        </div>
-
-        <div className="space-y-2">
-          <Label htmlFor="feedback-message">反馈内容</Label>
-          <Textarea
-            id="feedback-message"
-            value={message}
-            onChange={(event) => setMessage(event.target.value)}
-            maxLength={3000}
-            rows={7}
-            placeholder="请尽量描述你遇到的问题、期望的效果,或者建议的使用场景。"
-          />
-          <p className="text-xs text-muted-foreground">
-            请勿提交 API Key、项目原文或隐私信息。
-          </p>
-        </div>
-
-        <div className="space-y-2">
-          <Label htmlFor="feedback-contact">联系方式(选填)</Label>
-          <Input
-            id="feedback-contact"
-            value={contact}
-            onChange={(event) => setContact(event.target.value)}
-            maxLength={200}
-            placeholder="邮箱、微信或其他联系方式"
-          />
-        </div>
-
-        <div className="flex items-center justify-between gap-3">
-          <p className={`text-sm ${status.includes("失败") || status.includes("请输入") ? "text-destructive" : "text-muted-foreground"}`}>
-            {status}
-          </p>
-          <Button type="button" onClick={() => void handleSubmit()} disabled={!canSubmit} className="gap-2">
-            <Send className="h-4 w-4" />
-            {submitting ? "提交中..." : "提交反馈"}
-          </Button>
-        </div>
-      </div>
+      {unavailable ? (
+        <p className="text-sm text-destructive">{t("settings.sections.feedback.unavailable")}</p>
+      ) : (
+        <div ref={hostRef} className="min-h-96 w-full" />
+      )}
     </div>
   )
 }

+ 17 - 0
src/i18n/en.json

@@ -766,6 +766,23 @@
         "description": "Open tutorials and novel-writing guides in your browser while configuring the app.",
         "open": "Open"
       },
+      "feedback": {
+        "title": "Feedback",
+        "description": "Use the official Sentry feedback form. Do not submit API keys, manuscript text, or private information.",
+        "open": "Open feedback form",
+        "required": "(required)",
+        "unavailable": "Feedback widget is not initialized",
+        "formTitle": "Feedback",
+        "submit": "Send feedback",
+        "cancel": "Cancel",
+        "name": "Name",
+        "namePlaceholder": "Your name (optional)",
+        "email": "Email",
+        "emailPlaceholder": "your.email@example.org",
+        "message": "Description",
+        "messagePlaceholder": "Describe the problem, expected result, or suggested use case.",
+        "success": "Thanks, your feedback was sent."
+      },
       "contactSupport": {
         "title": "Contact & Support",
         "description": "Use these channels to send feedback, discuss usage, or support ongoing maintenance.",

+ 17 - 0
src/i18n/zh.json

@@ -472,6 +472,23 @@
         "description": "常用教程和小说功能说明会在浏览器中打开,方便边看边配置。",
         "open": "打开"
       },
+      "feedback": {
+        "title": "反馈与建议",
+        "description": "使用 Sentry 官方反馈表单提交。请勿提交 API Key、项目原文或隐私信息。",
+        "open": "打开反馈表单",
+        "required": "(必填)",
+        "unavailable": "反馈组件未初始化",
+        "formTitle": "反馈与建议",
+        "submit": "提交反馈",
+        "cancel": "取消",
+        "name": "称呼",
+        "namePlaceholder": "你的称呼(选填)",
+        "email": "邮箱",
+        "emailPlaceholder": "your.email@example.org",
+        "message": "反馈内容",
+        "messagePlaceholder": "请描述你遇到的问题、期望的效果,或建议的使用场景。",
+        "success": "反馈已提交,谢谢。"
+      },
       "contactSupport": {
         "title": "联系与支持",
         "description": "如果需要反馈问题、交流使用方式,或愿意支持软件持续维护,可以通过下面方式联系。",

+ 10 - 1
src/instrument.ts

@@ -16,7 +16,16 @@ if (import.meta.env.MODE !== "test") {
     profilesSampleRate: 0,
     replaysSessionSampleRate: 0,
     replaysOnErrorSampleRate: 0,
-    integrations: [Sentry.browserTracingIntegration()],
+    integrations: [
+      Sentry.browserTracingIntegration(),
+      Sentry.feedbackIntegration({
+        autoInject: false,
+        enableScreenshot: false,
+        colorScheme: "system",
+        showBranding: false,
+        tags: { source: "settings" },
+      }),
+    ],
     tracesSampleRate: import.meta.env.PROD ? 0.2 : 1.0,
     // Do not attach sentry-trace headers to LLM / third-party fetches.
     tracePropagationTargets: [],

+ 0 - 62
src/lib/feedback.ts

@@ -1,62 +0,0 @@
-import { isTauri } from "@/lib/platform"
-
-const FEEDBACK_URL = "https://qmai-analytics.qmai.workers.dev/feedback"
-const MAX_MESSAGE_LENGTH = 3000
-const MAX_CONTACT_LENGTH = 200
-
-export type FeedbackType = "bug" | "suggestion" | "other"
-
-interface FeedbackInput {
-  type: FeedbackType
-  message: string
-  contact?: string
-}
-
-export async function submitFeedback(input: FeedbackInput): Promise<void> {
-  const message = input.message.trim()
-  const contact = input.contact?.trim() ?? ""
-
-  if (!message) throw new Error("\u8bf7\u8f93\u5165\u53cd\u9988\u5185\u5bb9")
-  if (message.length > MAX_MESSAGE_LENGTH) throw new Error(`\u53cd\u9988\u5185\u5bb9\u4e0d\u80fd\u8d85\u8fc7 ${MAX_MESSAGE_LENGTH} \u5b57`)
-  if (contact.length > MAX_CONTACT_LENGTH) throw new Error(`\u8054\u7cfb\u65b9\u5f0f\u4e0d\u80fd\u8d85\u8fc7 ${MAX_CONTACT_LENGTH} \u5b57`)
-
-  const body = JSON.stringify({
-    type: input.type,
-    message,
-    contact,
-    appVersion: __APP_VERSION__,
-    userAgent: typeof navigator === "undefined" ? "" : navigator.userAgent,
-  })
-
-  const request = {
-    method: "POST",
-    headers: { "Content-Type": "application/json" },
-    body,
-  }
-
-  const response = await sendFeedbackRequest(request)
-
-  if (!response.ok) {
-    throw new Error("\u53cd\u9988\u63d0\u4ea4\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5")
-  }
-}
-
-async function sendFeedbackRequest(request: RequestInit): Promise<Response> {
-  if (!isTauri()) return fetch(FEEDBACK_URL, request)
-
-  try {
-    return await sendWithTauri(request)
-  } catch (tauriError) {
-    try {
-      return await fetch(FEEDBACK_URL, request)
-    } catch {
-      const message = tauriError instanceof Error ? tauriError.message : String(tauriError)
-      throw new Error(`\u53cd\u9988\u63d0\u4ea4\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u7f51\u7edc\u540e\u91cd\u8bd5\uff1a${message}`)
-    }
-  }
-}
-
-async function sendWithTauri(request: RequestInit): Promise<Response> {
-  const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http")
-  return tauriFetch(FEEDBACK_URL, request)
-}