diff --git a/frontend/app/api/feedback/route.ts b/frontend/app/api/feedback/route.ts new file mode 100644 index 00000000..e36f65db --- /dev/null +++ b/frontend/app/api/feedback/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { + buildProxyExceptionResponse, + buildUpstreamErrorResponse, +} from "@/lib/api-proxy"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + let auth: Awaited> | null = null; + try { + const body = await req.json(); + auth = await buildBackendRequestHeaders(req); + const headers = new Headers(auth.headers); + headers.set("Content-Type", "application/json"); + + const res = await fetch(`${API_BASE}/api/feedback`, { + method: "POST", + headers, + body: JSON.stringify(body), + cache: "no-store", + }); + const raw = await res.text(); + if (!res.ok) { + return applyAuthResponseCookies( + buildUpstreamErrorResponse(res.status, raw, { + detailLimit: 260, + error: "Feedback request failed", + }), + auth.response, + ); + } + const response = new NextResponse(raw, { + status: res.status, + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + const response = buildProxyExceptionResponse(error, { + publicMessage: "Failed to submit feedback", + }); + return auth ? applyAuthResponseCookies(response, auth.response) : response; + } +} diff --git a/frontend/app/api/ops/feedback/[feedbackId]/status/route.ts b/frontend/app/api/ops/feedback/[feedbackId]/status/route.ts new file mode 100644 index 00000000..9509ac49 --- /dev/null +++ b/frontend/app/api/ops/feedback/[feedbackId]/status/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ feedbackId: string }> }, +) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const { feedbackId } = await context.params; + const body = await req.json(); + const res = await fetch( + `${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/status`, + { + method: "POST", + cache: "no-store", + headers: { + ...Object.fromEntries(new Headers(auth.headers).entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to update feedback status", + }); + } +} diff --git a/frontend/app/api/ops/feedback/route.ts b/frontend/app/api/ops/feedback/route.ts new file mode 100644 index 00000000..f4e8e4af --- /dev/null +++ b/frontend/app/api/ops/feedback/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function GET(req: NextRequest) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const upstream = new URL(`${API_BASE}/api/ops/feedback`); + req.nextUrl.searchParams.forEach((value, key) => { + upstream.searchParams.set(key, value); + }); + + const res = await fetch(upstream.toString(), { + cache: "no-store", + headers: auth.headers, + }); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to fetch ops feedback", + }); + } +} diff --git a/frontend/app/ops/feedback/page.tsx b/frontend/app/ops/feedback/page.tsx new file mode 100644 index 00000000..add3bee9 --- /dev/null +++ b/frontend/app/ops/feedback/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; +import { requireOpsAdmin } from "@/lib/ops-admin"; +import { FeedbackPageClient } from "@/components/ops/feedback/FeedbackPageClient"; + +export const metadata: Metadata = { title: "用户反馈 — PolyWeather Ops" }; + +export default async function OpsFeedbackPage() { + await requireOpsAdmin("/ops/feedback"); + return ; +} diff --git a/frontend/components/dashboard/ScanTerminalDashboard.tsx b/frontend/components/dashboard/ScanTerminalDashboard.tsx index edd545bf..2b850a3f 100644 --- a/frontend/components/dashboard/ScanTerminalDashboard.tsx +++ b/frontend/components/dashboard/ScanTerminalDashboard.tsx @@ -11,6 +11,7 @@ import { ChevronRight, GraduationCap, Menu, + MessageSquare, Search, UserRound, Users, @@ -55,6 +56,10 @@ import { KoyfinRowsTable } from "@/components/dashboard/scan-terminal/KoyfinRows import { rowName, pct, money, temp, edgeClass } from "@/components/dashboard/scan-terminal/utils"; import { CitySelectorDropdown } from "@/components/dashboard/scan-terminal/CitySelectorDropdown"; import { GridLayoutSelector } from "@/components/dashboard/scan-terminal/GridLayoutSelector"; +import { + UserFeedbackModal, + type FeedbackDraft, +} from "@/components/dashboard/scan-terminal/UserFeedbackModal"; import { mergeAccessStateWithAuthPayload, type AuthProfilePayload, @@ -441,10 +446,12 @@ function EmptySlotCard({ const TerminalSidebar = memo(function TerminalSidebar({ activeNavKey, isEn, + onFeedbackClick, onSelectNav, }: { activeNavKey: string; isEn: boolean; + onFeedbackClick: () => void; onSelectNav: (key: string) => void; }) { const [navExpanded, setNavExpanded] = useState(false); @@ -527,6 +534,25 @@ const TerminalSidebar = memo(function TerminalSidebar({ ); })} + +
+ +
); }); @@ -595,6 +621,7 @@ function PolyWeatherTerminal({ }, [searchInputRef, setSearchQuery]); const [activeNavKey, setActiveNavKey] = useState("thresholds"); const [onlineCount, setOnlineCount] = useState(null); + const [feedbackDraft, setFeedbackDraft] = useState(null); const handleSelectNav = useCallback((key: string) => { setActiveNavKey(key); }, []); @@ -653,6 +680,47 @@ function PolyWeatherTerminal({ const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState(null); const visibleSlots = useMemo(() => slots.slice(0, totalSlots), [slots, totalSlots]); + const openTerminalFeedback = useCallback(() => { + setFeedbackDraft({ + category: "bug", + source: "terminal", + context: { + source: "terminal_sidebar", + active_nav: activeNavKey, + locale, + grid: `${gridCols}x${gridRows}`, + active_slot_index: activeSlotIndex, + maximized_slot_index: maximizedSlotIndex, + visible_slots: visibleSlots.filter(Boolean), + selected_city: selectedCity || "", + selected_region: selectedRegionKey, + }, + }); + }, [ + activeNavKey, + activeSlotIndex, + gridCols, + gridRows, + locale, + maximizedSlotIndex, + selectedCity, + selectedRegionKey, + visibleSlots, + ]); + + const openChartFeedback = useCallback((context: Record) => { + setFeedbackDraft({ + category: "bug", + source: "chart", + context: { + ...context, + active_nav: activeNavKey, + locale, + grid: `${gridCols}x${gridRows}`, + }, + }); + }, [activeNavKey, gridCols, gridRows, locale]); + const handleSetGridSize = (cols: number, rows: number) => { const safeCols = clampGridSide(cols); const safeRows = clampGridSide(rows); @@ -800,7 +868,12 @@ function PolyWeatherTerminal({ return (
- +
@@ -872,6 +945,7 @@ function PolyWeatherTerminal({ allRows={filteredRegionRows} compact={false} disableClose={true} + onReportIssue={openChartFeedback} />
)} @@ -931,6 +1005,7 @@ function PolyWeatherTerminal({ handleSelectCityForSlot(maximizedSlotIndex, null); setMaximizedSlotIndex(null); }} + onReportIssue={openChartFeedback} isMaximized={true} disableClose={visibleSlots.filter(Boolean).length <= 1} /> @@ -1019,6 +1094,7 @@ function PolyWeatherTerminal({ onClose={() => { handleSelectCityForSlot(slotIndex, null); }} + onReportIssue={openChartFeedback} isMaximized={false} disableClose={visibleSlots.filter(Boolean).length <= 1} /> @@ -1046,6 +1122,11 @@ function PolyWeatherTerminal({ )}
+ setFeedbackDraft(null)} + /> ); } diff --git a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx index 20d1e71c..c7a5fc25 100644 --- a/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx +++ b/frontend/components/dashboard/scan-terminal/LiveTemperatureThresholdChart.tsx @@ -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) => void; isMaximized?: boolean; disableClose?: boolean; isActive?: boolean; @@ -780,6 +783,54 @@ export function LiveTemperatureThresholdChart({ setDetailRetryNonce((value) => value + 1); }, []); + const handleReportIssue = useCallback((event: React.MouseEvent) => { + 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 ? (
+ )} {onMaximize && ( +
+ + {submitted ? ( +
+ +
+ {isEn ? "Feedback received" : "反馈已收到"} +
+

+ {isEn + ? "We saved the report with the current terminal context." + : "已附带当前终端上下文保存,后续会在后台统一处理。"} +

+ +
+ ) : ( +
+
+ {CATEGORY_OPTIONS.map(({ key, labelZh, labelEn, Icon }) => ( + + ))} +
+ +