Add terminal feedback workflow
This commit is contained in:
@@ -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<ReturnType<typeof buildBackendRequestHeaders>> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 <FeedbackPageClient />;
|
||||
}
|
||||
@@ -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({
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={clsx("mt-auto w-full border-t border-slate-200 pt-2", navExpanded ? "" : "px-0")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFeedbackClick}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 rounded text-slate-500 transition-colors hover:bg-slate-50 hover:text-blue-700",
|
||||
navExpanded ? "h-9 w-full px-2 text-left" : "grid h-9 w-full place-items-center border-l-4 border-transparent",
|
||||
)}
|
||||
title={isEn ? "Feedback / Bug" : "反馈 / Bug"}
|
||||
>
|
||||
<MessageSquare size={16} className="shrink-0" />
|
||||
{navExpanded && (
|
||||
<span className="text-xs font-semibold whitespace-nowrap">
|
||||
{isEn ? "Feedback" : "反馈 / Bug"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -595,6 +621,7 @@ function PolyWeatherTerminal({
|
||||
}, [searchInputRef, setSearchQuery]);
|
||||
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
|
||||
const [onlineCount, setOnlineCount] = useState<number | null>(null);
|
||||
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
|
||||
const handleSelectNav = useCallback((key: string) => {
|
||||
setActiveNavKey(key);
|
||||
}, []);
|
||||
@@ -653,6 +680,47 @@ function PolyWeatherTerminal({
|
||||
const [activeSearchSlotIndex, setActiveSearchSlotIndex] = useState<number | null>(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<string, unknown>) => {
|
||||
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 (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#e9edf3] text-[#202833]">
|
||||
<TerminalSidebar activeNavKey={activeNavKey} isEn={isEn} onSelectNav={handleSelectNav} />
|
||||
<TerminalSidebar
|
||||
activeNavKey={activeNavKey}
|
||||
isEn={isEn}
|
||||
onFeedbackClick={openTerminalFeedback}
|
||||
onSelectNav={handleSelectNav}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-[#d2d9e2] bg-white px-4 text-slate-800">
|
||||
@@ -872,6 +945,7 @@ function PolyWeatherTerminal({
|
||||
allRows={filteredRegionRows}
|
||||
compact={false}
|
||||
disableClose={true}
|
||||
onReportIssue={openChartFeedback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -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({
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<UserFeedbackModal
|
||||
draft={feedbackDraft}
|
||||
isEn={isEn}
|
||||
onClose={() => setFeedbackDraft(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Bug, CheckCircle2, MessageSquare, RefreshCcw } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { opsApi } from "@/lib/ops-api";
|
||||
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "open", label: "新建" },
|
||||
{ key: "triaged", label: "已确认" },
|
||||
{ key: "investigating", label: "处理中" },
|
||||
{ key: "resolved", label: "已解决" },
|
||||
{ key: "closed", label: "关闭" },
|
||||
] as const;
|
||||
|
||||
const NEXT_STATUS: Record<string, string> = {
|
||||
open: "triaged",
|
||||
triaged: "investigating",
|
||||
investigating: "resolved",
|
||||
resolved: "closed",
|
||||
closed: "open",
|
||||
};
|
||||
|
||||
function compactDate(value?: string) {
|
||||
if (!value) return "—";
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function categoryLabel(value?: string) {
|
||||
const key = String(value || "").toLowerCase();
|
||||
if (key === "bug") return "Bug";
|
||||
if (key === "data") return "数据";
|
||||
if (key === "idea") return "建议";
|
||||
if (key === "payment") return "支付";
|
||||
if (key === "account") return "账号";
|
||||
return "其他";
|
||||
}
|
||||
|
||||
function statusLabel(value?: string) {
|
||||
const key = String(value || "open").toLowerCase();
|
||||
if (key === "open") return "新建";
|
||||
if (key === "triaged") return "已确认";
|
||||
if (key === "investigating") return "处理中";
|
||||
if (key === "resolved") return "已解决";
|
||||
if (key === "closed") return "关闭";
|
||||
return key;
|
||||
}
|
||||
|
||||
function statusTone(value?: string) {
|
||||
const key = String(value || "open").toLowerCase();
|
||||
if (key === "open") return "border-red-200 bg-red-50 text-red-700";
|
||||
if (key === "triaged") return "border-amber-200 bg-amber-50 text-amber-700";
|
||||
if (key === "investigating") return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
if (key === "resolved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
return "border-slate-200 bg-slate-50 text-slate-600";
|
||||
}
|
||||
|
||||
function contextSummary(context?: Record<string, unknown>) {
|
||||
if (!context) return "—";
|
||||
const city = String(context.city || context.display_city || "").trim();
|
||||
const slot = context.slot_index != null ? `slot ${context.slot_index}` : "";
|
||||
const source = String(context.source || "").trim();
|
||||
const pieces = [city, slot, source].filter(Boolean);
|
||||
return pieces.length ? pieces.join(" · ") : "terminal";
|
||||
}
|
||||
|
||||
function feedbackActionLabel(status?: string) {
|
||||
const next = NEXT_STATUS[String(status || "open").toLowerCase()] || "triaged";
|
||||
return statusLabel(next);
|
||||
}
|
||||
|
||||
export function FeedbackPageClient() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
||||
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload;
|
||||
setPayload(data);
|
||||
} catch (err) {
|
||||
setError(String(err).slice(0, 220));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [filter]);
|
||||
|
||||
const rows = payload?.feedback || [];
|
||||
const counts = payload?.status_counts || {};
|
||||
const openCount = Number(counts.open || 0);
|
||||
const activeCount = Number(counts.open || 0) + Number(counts.triaged || 0) + Number(counts.investigating || 0);
|
||||
|
||||
const categoryCounts = useMemo(() => {
|
||||
const acc: Record<string, number> = {};
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.category || "other");
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
});
|
||||
return acc;
|
||||
}, [rows]);
|
||||
|
||||
const advanceStatus = async (row: UserFeedbackEntry) => {
|
||||
const next = NEXT_STATUS[String(row.status || "open").toLowerCase()] || "triaged";
|
||||
setUpdatingId(row.id);
|
||||
try {
|
||||
await opsApi.updateFeedbackStatus(row.id, next);
|
||||
await load();
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !payload) {
|
||||
return <div className="text-slate-400 animate-pulse">加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">用户反馈</h1>
|
||||
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
加载失败:{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Bug className="h-5 w-5 text-red-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">新反馈</div>
|
||||
<div className="text-2xl font-black text-slate-950">{openCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<MessageSquare className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">处理中</div>
|
||||
<div className="text-2xl font-black text-slate-950">{activeCount}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">已解决</div>
|
||||
<div className="text-2xl font-black text-slate-950">{Number(counts.resolved || 0)}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-xs text-slate-500">当前列表</div>
|
||||
<div className="mt-1 text-2xl font-black text-slate-950">{rows.length}</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
Bug {categoryCounts.bug || 0} · 数据 {categoryCounts.data || 0} · 建议 {categoryCounts.idea || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<CardTitle>反馈收件箱</CardTitle>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key || "all"}
|
||||
type="button"
|
||||
onClick={() => setFilter(item.key)}
|
||||
className={
|
||||
"rounded border px-2.5 py-1 text-xs font-bold transition " +
|
||||
(filter === item.key
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:bg-slate-50")
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rows.length === 0 ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500">
|
||||
暂无反馈。
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2 pr-4 font-bold">状态</th>
|
||||
<th className="py-2 pr-4 font-bold">类型</th>
|
||||
<th className="py-2 pr-4 font-bold">内容</th>
|
||||
<th className="py-2 pr-4 font-bold">上下文</th>
|
||||
<th className="py-2 pr-4 font-bold">用户</th>
|
||||
<th className="py-2 pr-4 font-bold">时间</th>
|
||||
<th className="py-2 pr-4 font-bold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.id} className="border-b border-slate-100 align-top">
|
||||
<td className="py-3 pr-4">
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-bold ${statusTone(row.status)}`}>
|
||||
{statusLabel(row.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-slate-500">{categoryLabel(row.category)}</td>
|
||||
<td className="max-w-xl py-3 pr-4">
|
||||
<div className="font-semibold leading-5 text-slate-900">{row.message || "—"}</div>
|
||||
{row.contact && <div className="mt-1 text-xs text-slate-500">联系:{row.contact}</div>}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-mono text-xs text-blue-700">{contextSummary(row.context)}</div>
|
||||
{Boolean(row.context?.detail_error) && (
|
||||
<div className="mt-1 max-w-xs text-xs text-amber-700">
|
||||
{String(row.context?.detail_error || "").slice(0, 120)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-slate-500">
|
||||
{row.user_email || row.user_id || "—"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-3 pr-4 text-xs text-slate-500">{compactDate(row.created_at)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => advanceStatus(row)}
|
||||
disabled={updatingId === row.id}
|
||||
className="rounded border border-slate-200 bg-white px-2.5 py-1 text-xs font-bold text-slate-600 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
标为{feedbackActionLabel(row.status)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ScrollText,
|
||||
Activity,
|
||||
ShieldAlert,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const navGroups = [
|
||||
@@ -33,6 +34,7 @@ const navGroups = [
|
||||
items: [
|
||||
{ href: "/ops/payments", icon: CreditCard, label: "支付管理" },
|
||||
{ href: "/ops/memberships", icon: UserCheck, label: "会员订阅" },
|
||||
{ href: "/ops/feedback", icon: MessageSquare, label: "用户反馈" },
|
||||
{ href: "/ops/telegram-audit", icon: ShieldAlert, label: "电报清理" },
|
||||
{ href: "/ops/users", icon: Users, label: "用户积分" },
|
||||
],
|
||||
|
||||
@@ -106,6 +106,18 @@ export const opsApi = {
|
||||
if (reason) params.set("reason", reason);
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents?${params}`);
|
||||
},
|
||||
feedback(limit = 100, status?: string) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (status) params.set("status", status);
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/feedback?${params}`);
|
||||
},
|
||||
updateFeedbackStatus(feedbackId: string | number, status: string) {
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/feedback/${feedbackId}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
},
|
||||
resolveIncident(eventId: string | number) {
|
||||
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents/${eventId}/resolve`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -136,6 +136,26 @@ export type PaymentsPayload = {
|
||||
total?: number;
|
||||
};
|
||||
|
||||
export type UserFeedbackEntry = {
|
||||
id: number;
|
||||
category?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
contact?: string;
|
||||
user_id?: string;
|
||||
user_email?: string;
|
||||
context?: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type UserFeedbackPayload = {
|
||||
feedback?: UserFeedbackEntry[];
|
||||
total?: number;
|
||||
status_counts?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type BillingRiskIssue = {
|
||||
category?: string;
|
||||
severity?: "high" | "medium" | "low" | string;
|
||||
|
||||
@@ -547,6 +547,27 @@ class DBManager:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_app_analytics_events_type_created_at ON app_analytics_events(event_type, created_at DESC)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_feedback (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'terminal',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
contact TEXT,
|
||||
user_id TEXT,
|
||||
user_email TEXT,
|
||||
context_json TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_feedback_status_created_at ON user_feedback(status, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_feedback_created_at ON user_feedback(created_at DESC)"
|
||||
)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS supabase_bindings (
|
||||
supabase_user_id TEXT PRIMARY KEY,
|
||||
@@ -1048,6 +1069,149 @@ class DBManager:
|
||||
)
|
||||
return out
|
||||
|
||||
def _feedback_row_to_dict(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
try:
|
||||
context = json.loads(str(row["context_json"] or "{}"))
|
||||
except Exception:
|
||||
context = {}
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"category": str(row["category"] or ""),
|
||||
"message": str(row["message"] or ""),
|
||||
"source": str(row["source"] or ""),
|
||||
"status": str(row["status"] or ""),
|
||||
"contact": str(row["contact"] or ""),
|
||||
"user_id": str(row["user_id"] or ""),
|
||||
"user_email": str(row["user_email"] or ""),
|
||||
"context": context if isinstance(context, dict) else {},
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def append_user_feedback(
|
||||
self,
|
||||
*,
|
||||
category: str,
|
||||
message: str,
|
||||
source: str = "terminal",
|
||||
contact: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
user_email: Optional[str] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
normalized_category = str(category or "other").strip().lower()[:40] or "other"
|
||||
normalized_message = str(message or "").strip()
|
||||
normalized_source = str(source or "terminal").strip().lower()[:40] or "terminal"
|
||||
normalized_contact = str(contact or "").strip()[:180]
|
||||
normalized_user_id = str(user_id or "").strip().lower()[:128]
|
||||
normalized_user_email = str(user_email or "").strip().lower()[:180]
|
||||
context_payload = context if isinstance(context, dict) else {}
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO user_feedback (
|
||||
category,
|
||||
message,
|
||||
source,
|
||||
status,
|
||||
contact,
|
||||
user_id,
|
||||
user_email,
|
||||
context_json,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
normalized_category,
|
||||
normalized_message,
|
||||
normalized_source,
|
||||
normalized_contact,
|
||||
normalized_user_id,
|
||||
normalized_user_email,
|
||||
json.dumps(context_payload, ensure_ascii=False, default=str),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
feedback_id = int(cursor.lastrowid)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(feedback_id,),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row)
|
||||
|
||||
def list_user_feedback(
|
||||
self,
|
||||
*,
|
||||
limit: int = 100,
|
||||
status: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
safe_limit = max(1, min(int(limit or 100), 500))
|
||||
normalized_status = str(status or "").strip().lower()
|
||||
clauses: List[str] = []
|
||||
params: List[Any] = []
|
||||
if normalized_status:
|
||||
clauses.append("status = ?")
|
||||
params.append(normalized_status)
|
||||
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
params.append(safe_limit)
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, created_at, updated_at
|
||||
FROM user_feedback
|
||||
{where_sql}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
return [self._feedback_row_to_dict(row) for row in rows]
|
||||
|
||||
def update_user_feedback_status(
|
||||
self,
|
||||
feedback_id: int,
|
||||
*,
|
||||
status: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
normalized_status = str(status or "").strip().lower()
|
||||
if not normalized_status:
|
||||
return None
|
||||
now = datetime.now().isoformat()
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE user_feedback
|
||||
SET status = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(normalized_status, now, int(feedback_id)),
|
||||
)
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
user_email, context_json, created_at, updated_at
|
||||
FROM user_feedback
|
||||
WHERE id = ?
|
||||
""",
|
||||
(int(feedback_id),),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
return self._feedback_row_to_dict(row) if row else None
|
||||
|
||||
def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]:
|
||||
safe_days = max(1, min(int(days or 30), 365))
|
||||
since_dt = datetime.now() - timedelta(days=safe_days)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
from src.database.db_manager import DBManager
|
||||
|
||||
|
||||
def test_user_feedback_round_trip_includes_context_and_status(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback.db"))
|
||||
|
||||
created = db.append_user_feedback(
|
||||
category="bug",
|
||||
message="The Helsinki chart keeps loading.",
|
||||
source="chart",
|
||||
contact="pilot@example.com",
|
||||
user_id="user-123",
|
||||
user_email="pilot@example.com",
|
||||
context={"city": "helsinki", "slot": 3, "detail_error": "timeout"},
|
||||
)
|
||||
|
||||
assert created["id"] > 0
|
||||
assert created["status"] == "open"
|
||||
|
||||
rows = db.list_user_feedback(limit=10)
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["category"] == "bug"
|
||||
assert rows[0]["message"] == "The Helsinki chart keeps loading."
|
||||
assert rows[0]["source"] == "chart"
|
||||
assert rows[0]["contact"] == "pilot@example.com"
|
||||
assert rows[0]["user_id"] == "user-123"
|
||||
assert rows[0]["user_email"] == "pilot@example.com"
|
||||
assert rows[0]["context"]["city"] == "helsinki"
|
||||
assert rows[0]["context"]["detail_error"] == "timeout"
|
||||
|
||||
updated = db.update_user_feedback_status(created["id"], status="triaged")
|
||||
|
||||
assert updated["status"] == "triaged"
|
||||
assert db.list_user_feedback(limit=10, status="triaged")[0]["id"] == created["id"]
|
||||
|
||||
|
||||
def test_user_feedback_status_filter_excludes_other_statuses(tmp_path):
|
||||
db = DBManager(str(tmp_path / "polyweather-feedback-filter.db"))
|
||||
db.append_user_feedback(category="idea", message="Add a dark chart grid.")
|
||||
closed = db.append_user_feedback(category="bug", message="Payment page failed.")
|
||||
db.update_user_feedback_status(closed["id"], status="closed")
|
||||
|
||||
open_rows = db.list_user_feedback(limit=10, status="open")
|
||||
|
||||
assert [row["status"] for row in open_rows] == ["open"]
|
||||
assert open_rows[0]["message"] == "Add a dark chart grid."
|
||||
@@ -13,6 +13,7 @@ from web.core import app as core_app
|
||||
from web.routers.analytics import router as analytics_router
|
||||
from web.routers.city import router as city_router
|
||||
from web.routers.auth import router as auth_router
|
||||
from web.routers.feedback import router as feedback_router
|
||||
from web.routers.ops import router as ops_router
|
||||
from web.routers.payments import router as payments_router
|
||||
from web.routers.scan import router as scan_router
|
||||
@@ -41,6 +42,7 @@ def create_app() -> FastAPI:
|
||||
core_app.include_router(system_router)
|
||||
core_app.include_router(city_router)
|
||||
core_app.include_router(auth_router)
|
||||
core_app.include_router(feedback_router)
|
||||
core_app.include_router(analytics_router)
|
||||
core_app.include_router(scan_router)
|
||||
core_app.include_router(sse_router)
|
||||
|
||||
@@ -491,6 +491,14 @@ class AnalyticsEventRequest(BaseModel):
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserFeedbackRequest(BaseModel):
|
||||
category: str = Field(default="bug", min_length=2, max_length=40)
|
||||
message: str = Field(..., min_length=3, max_length=5000)
|
||||
source: str = Field(default="terminal", max_length=40)
|
||||
contact: Optional[str] = Field(default=None, max_length=180)
|
||||
context: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class GrantPointsRequest(BaseModel):
|
||||
email: str = Field(..., min_length=3)
|
||||
points: int = Field(..., gt=0, le=100000)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""User feedback API routes."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
from web.core import UserFeedbackRequest
|
||||
from web.services.feedback_api import submit_user_feedback
|
||||
|
||||
router = APIRouter(tags=["feedback"])
|
||||
|
||||
|
||||
@router.post("/api/feedback")
|
||||
async def feedback_submit(request: Request, body: UserFeedbackRequest):
|
||||
return submit_user_feedback(request, body)
|
||||
@@ -16,6 +16,7 @@ from web.services.ops_api import (
|
||||
get_ops_source_health,
|
||||
get_ops_truth_history,
|
||||
get_ops_weekly_leaderboard,
|
||||
list_ops_feedback,
|
||||
get_ops_user_subscriptions,
|
||||
grant_ops_points,
|
||||
transfer_ops_points,
|
||||
@@ -27,6 +28,7 @@ from web.services.ops_api import (
|
||||
search_ops_users,
|
||||
update_ops_config,
|
||||
update_ops_sensitive_config,
|
||||
update_ops_feedback_status,
|
||||
get_ops_training_accuracy,
|
||||
get_ops_telegram_audit,
|
||||
)
|
||||
@@ -71,6 +73,20 @@ async def ops_weekly_leaderboard(request: Request, limit: int = 20):
|
||||
return get_ops_weekly_leaderboard(request, limit=limit)
|
||||
|
||||
|
||||
@router.get("/api/ops/feedback")
|
||||
async def ops_feedback(request: Request, limit: int = 100, status: str = ""):
|
||||
return list_ops_feedback(request, limit=limit, status=status)
|
||||
|
||||
|
||||
@router.post("/api/ops/feedback/{feedback_id}/status")
|
||||
async def ops_feedback_update_status(request: Request, feedback_id: int):
|
||||
import json as _json
|
||||
body_bytes = await request.body()
|
||||
body = _json.loads(body_bytes.decode("utf-8") or "{}")
|
||||
status = str(body.get("status") or "").strip()
|
||||
return update_ops_feedback_status(request, feedback_id=feedback_id, status=status)
|
||||
|
||||
|
||||
@router.get("/api/ops/memberships")
|
||||
async def ops_memberships(request: Request, limit: int = 200):
|
||||
return list_ops_memberships(request, limit=limit)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""User feedback API service functions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from src.database.db_manager import DBManager
|
||||
from web.core import UserFeedbackRequest
|
||||
import web.routes as legacy_routes
|
||||
|
||||
|
||||
_ALLOWED_FEEDBACK_CATEGORIES = {
|
||||
"bug",
|
||||
"data",
|
||||
"idea",
|
||||
"payment",
|
||||
"account",
|
||||
"other",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_category(value: str) -> str:
|
||||
normalized = str(value or "").strip().lower()
|
||||
return normalized if normalized in _ALLOWED_FEEDBACK_CATEGORIES else "other"
|
||||
|
||||
|
||||
def _bounded_context(context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(context, dict):
|
||||
return {}
|
||||
try:
|
||||
encoded = json.dumps(context, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return {"_error": "context_not_serializable"}
|
||||
if len(encoded) <= 20_000:
|
||||
return json.loads(encoded)
|
||||
return {
|
||||
"_truncated": True,
|
||||
"raw_preview": encoded[:20_000],
|
||||
}
|
||||
|
||||
|
||||
def submit_user_feedback(request: Request, body: UserFeedbackRequest) -> Dict[str, Any]:
|
||||
legacy_routes._bind_optional_supabase_identity(request)
|
||||
|
||||
message = str(body.message or "").strip()
|
||||
if len(message) < 3:
|
||||
raise HTTPException(status_code=400, detail="message is required")
|
||||
|
||||
user_id = str(getattr(request.state, "auth_user_id", "") or "").strip()
|
||||
user_email = str(getattr(request.state, "auth_email", "") or "").strip()
|
||||
contact = str(body.contact or "").strip() or user_email
|
||||
|
||||
feedback = DBManager().append_user_feedback(
|
||||
category=_normalize_category(body.category),
|
||||
message=message,
|
||||
source=str(body.source or "terminal").strip().lower() or "terminal",
|
||||
contact=contact,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
context=_bounded_context(body.context),
|
||||
)
|
||||
return {"ok": True, "feedback": feedback}
|
||||
@@ -135,6 +135,44 @@ def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, A
|
||||
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
|
||||
|
||||
|
||||
def list_ops_feedback(
|
||||
request: Request,
|
||||
*,
|
||||
limit: int = 100,
|
||||
status: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
db = DBManager()
|
||||
rows = db.list_user_feedback(limit=limit, status=status or None)
|
||||
status_counts: Dict[str, int] = {}
|
||||
recent_rows = db.list_user_feedback(limit=500)
|
||||
for row in recent_rows:
|
||||
key = str(row.get("status") or "unknown")
|
||||
status_counts[key] = status_counts.get(key, 0) + 1
|
||||
return {
|
||||
"feedback": rows,
|
||||
"total": len(rows),
|
||||
"status_counts": status_counts,
|
||||
}
|
||||
|
||||
|
||||
def update_ops_feedback_status(
|
||||
request: Request,
|
||||
*,
|
||||
feedback_id: int,
|
||||
status: str,
|
||||
) -> Dict[str, Any]:
|
||||
_require_ops(request)
|
||||
normalized = str(status or "").strip().lower()
|
||||
allowed = {"open", "triaged", "investigating", "resolved", "closed"}
|
||||
if normalized not in allowed:
|
||||
raise HTTPException(status_code=400, detail="unsupported feedback status")
|
||||
updated = DBManager().update_user_feedback_status(feedback_id, status=normalized)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="feedback not found")
|
||||
return {"ok": True, "feedback": updated}
|
||||
|
||||
|
||||
def _list_active_subscriptions_with_windows(
|
||||
limit: int,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]:
|
||||
|
||||
Reference in New Issue
Block a user