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
+58
View File
@@ -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",
});
}
}
+47
View File
@@ -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",
});
}
}
+10
View File
@@ -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: "用户积分" },
],
+12
View File
@@ -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",
+20
View File
@@ -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;
+164
View File
@@ -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)
+47
View File
@@ -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."
+2
View File
@@ -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)
+8
View File
@@ -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)
+13
View File
@@ -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
View File
@@ -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)
+65
View File
@@ -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}
+38
View File
@@ -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]: