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;