Add terminal feedback workflow

This commit is contained in:
2569718930@qq.com
2026-06-01 21:10:37 +08:00
parent f1e577ea8a
commit 8fc9f3ae1a
20 changed files with 1258 additions and 2 deletions
@@ -1,6 +1,7 @@
"use client";
import clsx from "clsx";
import { Bug } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
import { useLatestPatch, useSseResyncVersion } from "@/hooks/use-sse-patches";
@@ -166,6 +167,7 @@ export function LiveTemperatureThresholdChart({
onSearchClick,
onMaximize,
onClose,
onReportIssue,
isMaximized = false,
disableClose = false,
isActive = !compact,
@@ -178,6 +180,7 @@ export function LiveTemperatureThresholdChart({
onSearchClick?: () => void;
onMaximize?: () => void;
onClose?: () => void;
onReportIssue?: (context: Record<string, unknown>) => void;
isMaximized?: boolean;
disableClose?: boolean;
isActive?: boolean;
@@ -780,6 +783,54 @@ export function LiveTemperatureThresholdChart({
setDetailRetryNonce((value) => value + 1);
}, []);
const handleReportIssue = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
onReportIssue?.({
source: "chart",
city,
display_city: row ? rowName(row) : "",
row_id: row?.id || "",
slot_index: slotIndex,
compact,
is_active: isActive,
is_maximized: isMaximized,
detail_error: detailError,
is_hourly_loading: isHourlyLoading,
showing_stale_detail: showingStaleDetail,
target_resolution: targetResolution,
view_mode: viewMode,
loaded_local_date: chartLocalDate || "",
has_runway_data: hasRunwayData,
series: chartSeries.map((item) => item.key),
visible_series: activeSeries.map((item) => item.key),
live_temp: liveTemp,
current_runway_temp: currentRunwayTemp,
observed_high_metar: observedHighMetar,
observed_high_runway: observedHighRunway,
});
}, [
activeSeries,
chartLocalDate,
chartSeries,
city,
compact,
currentRunwayTemp,
detailError,
hasRunwayData,
isActive,
isHourlyLoading,
isMaximized,
liveTemp,
observedHighMetar,
observedHighRunway,
onReportIssue,
row,
showingStaleDetail,
slotIndex,
targetResolution,
viewMode,
]);
const panelTitle = row ? (
<div className="flex items-center gap-1">
<button
@@ -842,8 +893,18 @@ export function LiveTemperatureThresholdChart({
))}
</div>
{(onMaximize || onClose) && (
{(onMaximize || onClose || onReportIssue) && (
<div className="flex items-center gap-1">
{onReportIssue && (
<button
type="button"
onClick={handleReportIssue}
className="grid h-6 w-6 place-items-center rounded bg-white hover:bg-slate-50 border border-slate-200 text-slate-500 hover:text-amber-600 transition-colors shadow-sm"
title={isEn ? "Report this chart" : "反馈此图表"}
>
<Bug size={13} />
</button>
)}
{onMaximize && (
<button
type="button"
@@ -0,0 +1,237 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { Bug, CheckCircle2, Lightbulb, MessageSquare, WalletCards, X } from "lucide-react";
import {
getAnalyticsClientId,
getAnalyticsSessionId,
} from "@/lib/app-analytics";
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
export type FeedbackDraft = {
category?: FeedbackCategory;
source?: string;
context?: Record<string, unknown>;
};
const CATEGORY_OPTIONS: Array<{
key: FeedbackCategory;
labelZh: string;
labelEn: string;
Icon: typeof Bug;
}> = [
{ key: "bug", labelZh: "Bug", labelEn: "Bug", Icon: Bug },
{ key: "data", labelZh: "数据问题", labelEn: "Data issue", Icon: MessageSquare },
{ key: "idea", labelZh: "功能建议", labelEn: "Suggestion", Icon: Lightbulb },
{ key: "payment", labelZh: "支付问题", labelEn: "Payment", Icon: WalletCards },
];
function buildRuntimeContext(extra: Record<string, unknown>) {
if (typeof window === "undefined") return extra;
return {
...extra,
client_id: getAnalyticsClientId() || "",
session_id: getAnalyticsSessionId() || "",
path: window.location.pathname,
href: window.location.href,
language: navigator.language || "",
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
user_agent: navigator.userAgent || "",
viewport: `${window.innerWidth || 0}x${window.innerHeight || 0}`,
captured_at: new Date().toISOString(),
};
}
export function UserFeedbackModal({
draft,
isEn,
onClose,
}: {
draft: FeedbackDraft | null;
isEn: boolean;
onClose: () => void;
}) {
const open = Boolean(draft);
const [category, setCategory] = useState<FeedbackCategory>("bug");
const [message, setMessage] = useState("");
const [contact, setContact] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (!open) return;
setCategory(draft?.category || "bug");
setMessage("");
setError("");
setSubmitted(false);
}, [open, draft?.category]);
const contextPreview = useMemo(() => {
const context = draft?.context || {};
const city = String(context.city || context.display_city || "").trim();
const source = String(draft?.source || context.source || "terminal").trim();
if (city) {
return isEn ? `Context: ${city} · ${source}` : `上下文:${city} · ${source}`;
}
return isEn ? `Context: ${source}` : `上下文:${source}`;
}, [draft, isEn]);
if (!open) return null;
const canSubmit = message.trim().length >= 3 && !submitting;
const submit = async () => {
if (!canSubmit) return;
setSubmitting(true);
setError("");
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
message: message.trim(),
contact: contact.trim() || undefined,
source: draft?.source || "terminal",
context: buildRuntimeContext(draft?.context || {}),
}),
});
if (!res.ok) {
const detail = (await res.text().catch(() => "")).slice(0, 200);
throw new Error(detail || `HTTP ${res.status}`);
}
setSubmitted(true);
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/30 px-4 backdrop-blur-[2px]">
<div className="w-full max-w-lg overflow-hidden rounded-lg border border-slate-200 bg-white shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<div className="text-sm font-black text-slate-950">
{isEn ? "Send feedback" : "提交反馈"}
</div>
<div className="mt-0.5 text-xs text-slate-500">{contextPreview}</div>
</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 hover:text-slate-900"
aria-label={isEn ? "Close feedback" : "关闭反馈"}
>
<X size={16} />
</button>
</div>
{submitted ? (
<div className="px-5 py-8 text-center">
<CheckCircle2 className="mx-auto h-8 w-8 text-emerald-500" />
<div className="mt-3 text-sm font-black text-slate-950">
{isEn ? "Feedback received" : "反馈已收到"}
</div>
<p className="mx-auto mt-2 max-w-sm text-xs leading-5 text-slate-500">
{isEn
? "We saved the report with the current terminal context."
: "已附带当前终端上下文保存,后续会在后台统一处理。"}
</p>
<button
type="button"
onClick={onClose}
className="mt-5 rounded bg-blue-600 px-4 py-2 text-xs font-black text-white transition hover:bg-blue-700"
>
{isEn ? "Done" : "完成"}
</button>
</div>
) : (
<div className="space-y-4 p-5">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => (
<button
key={key}
type="button"
onClick={() => setCategory(key)}
className={clsx(
"flex h-11 items-center justify-center gap-2 rounded border px-2 text-xs font-bold transition",
category === key
? "border-blue-300 bg-blue-50 text-blue-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50",
)}
>
<Icon size={14} />
<span>{isEn ? labelEn : labelZh}</span>
</button>
))}
</div>
<label className="block">
<span className="text-xs font-bold text-slate-600">
{isEn ? "What happened?" : "问题或建议"}
</span>
<textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
rows={5}
className="mt-1 w-full resize-none rounded border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
placeholder={
isEn
? "Example: This chart keeps loading after I switch to Helsinki."
: "例如:切到 Helsinki 后图表一直加载。"
}
/>
</label>
<label className="block">
<span className="text-xs font-bold text-slate-600">
{isEn ? "Contact, optional" : "联系方式,可选"}
</span>
<input
value={contact}
onChange={(event) => setContact(event.target.value)}
className="mt-1 h-9 w-full rounded border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none transition focus:border-blue-400 focus:ring-2 focus:ring-blue-100"
placeholder={isEn ? "Email or Telegram" : "邮箱或 Telegram"}
/>
</label>
<div className="rounded border border-slate-200 bg-slate-50 px-3 py-2 text-[11px] leading-4 text-slate-500">
{isEn
? "Terminal context is attached automatically: city, slot, source state, browser and session diagnostics."
: "会自动附带终端上下文:城市、槽位、数据源状态、浏览器和会话诊断信息。"}
</div>
{error && (
<div className="rounded border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{isEn ? "Submit failed: " : "提交失败:"}{error}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="h-9 rounded border border-slate-200 px-4 text-xs font-bold text-slate-600 transition hover:bg-slate-50"
>
{isEn ? "Cancel" : "取消"}
</button>
<button
type="button"
onClick={submit}
disabled={!canSubmit}
className="h-9 rounded bg-blue-600 px-4 text-xs font-black text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? (isEn ? "Sending..." : "提交中...") : (isEn ? "Send" : "提交")}
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const dashboardSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
const chartSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "LiveTemperatureThresholdChart.tsx"),
"utf8",
);
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
const opsSidebarSource = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
const modalSource = fs.readFileSync(modalPath, "utf8");
assert(
dashboardSource.includes("onFeedbackClick") &&
dashboardSource.includes("<UserFeedbackModal") &&
dashboardSource.includes("setFeedbackDraft"),
"terminal sidebar must expose a feedback entry that opens the shared modal",
);
assert(
chartSource.includes("onReportIssue") &&
chartSource.includes("Bug") &&
chartSource.includes("detailError"),
"each chart must expose a report-this-chart action with chart loading/error context",
);
assert(
modalSource.includes("/api/feedback") &&
modalSource.includes("getAnalyticsClientId") &&
modalSource.includes("navigator.userAgent") &&
modalSource.includes("type=\"textarea\"") === false,
"feedback modal must submit to the feedback API and attach client/session diagnostics without using invalid textarea input types",
);
assert(
opsSidebarSource.includes("/ops/feedback") &&
opsApiSource.includes("feedback(") &&
opsApiSource.includes("updateFeedbackStatus"),
"ops must expose a feedback inbox in navigation and API client",
);
}