Add user feedback status notifications

This commit is contained in:
2569718930@qq.com
2026-06-06 22:55:15 +08:00
parent a5de604d85
commit f7625218ae
11 changed files with 530 additions and 4 deletions
@@ -60,6 +60,7 @@ import {
UserFeedbackModal,
type FeedbackDraft,
} from "@/components/dashboard/scan-terminal/UserFeedbackModal";
import { UserFeedbackStatusButton } from "@/components/dashboard/scan-terminal/UserFeedbackStatusButton";
import {
mergeAccessStateWithAuthPayload,
type AuthProfilePayload,
@@ -663,6 +664,7 @@ function PolyWeatherTerminal({
const [activeNavKey, setActiveNavKey] = useState<string>("thresholds");
const [onlineCount, setOnlineCount] = useState<number | null>(null);
const [feedbackDraft, setFeedbackDraft] = useState<FeedbackDraft | null>(null);
const [feedbackRefreshKey, setFeedbackRefreshKey] = useState(0);
const handleSelectNav = useCallback((key: string) => {
setActiveNavKey(key);
}, []);
@@ -754,6 +756,10 @@ function PolyWeatherTerminal({
});
}, [activeNavKey, gridCols, gridRows, locale]);
const handleFeedbackSubmitted = useCallback(() => {
setFeedbackRefreshKey((value) => value + 1);
}, []);
const handleSetGridSize = (cols: number, rows: number) => {
const safeCols = clampGridSide(cols);
const safeRows = clampGridSide(rows);
@@ -970,6 +976,7 @@ function PolyWeatherTerminal({
>
{isEn ? "中文" : "EN"}
</button>
<UserFeedbackStatusButton isEn={isEn} refreshKey={feedbackRefreshKey} />
<Link
href="/account"
className="grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
@@ -1189,6 +1196,7 @@ function PolyWeatherTerminal({
draft={feedbackDraft}
isEn={isEn}
onClose={() => setFeedbackDraft(null)}
onSubmitted={handleFeedbackSubmitted}
/>
</div>
);
@@ -7,6 +7,7 @@ import {
getAnalyticsClientId,
getAnalyticsSessionId,
} from "@/lib/app-analytics";
import type { UserFeedbackEntry } from "@/types/ops";
export type FeedbackCategory = "bug" | "data" | "idea" | "payment" | "account" | "other";
@@ -48,10 +49,12 @@ export function UserFeedbackModal({
draft,
isEn,
onClose,
onSubmitted,
}: {
draft: FeedbackDraft | null;
isEn: boolean;
onClose: () => void;
onSubmitted?: (entry?: UserFeedbackEntry) => void;
}) {
const open = Boolean(draft);
const [category, setCategory] = useState<FeedbackCategory>("bug");
@@ -103,7 +106,11 @@ export function UserFeedbackModal({
const detail = (await res.text().catch(() => "")).slice(0, 200);
throw new Error(detail || `HTTP ${res.status}`);
}
const payload = (await res.json().catch(() => null)) as {
feedback?: UserFeedbackEntry;
} | null;
setSubmitted(true);
onSubmitted?.(payload?.feedback);
} catch (err) {
setError(String(err).slice(0, 220));
} finally {
@@ -139,8 +146,8 @@ export function UserFeedbackModal({
</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."
: "已附带当前终端上下文保存,后续会在后台统一处理。"}
? "We saved the report with the current terminal context. The bell icon will show handling updates."
: "已附带当前终端上下文保存。右上角铃铛会显示后续处理进度。"}
</p>
<button
type="button"
@@ -0,0 +1,228 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Bell, RefreshCcw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
feedbackStatusLabel,
feedbackStatusTone,
} from "./feedback-status";
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
const FEEDBACK_STATUS_POLL_MS = 60_000;
function loadSeenKeys() {
if (typeof window === "undefined") return new Set<string>();
try {
const raw = window.localStorage.getItem(FEEDBACK_STATUS_SEEN_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return new Set(Array.isArray(parsed) ? parsed.map(String) : []);
} catch {
return new Set<string>();
}
}
function saveSeenKeys(keys: Set<string>) {
if (typeof window === "undefined") return;
try {
const trimmed = Array.from(keys).slice(-200);
window.localStorage.setItem(FEEDBACK_STATUS_SEEN_KEY, JSON.stringify(trimmed));
} catch {
// Ignore storage failures; the badge can reappear without breaking feedback status.
}
}
function compactDate(value?: string) {
if (!value) return "";
return value.slice(0, 16).replace("T", " ");
}
function categoryLabel(value?: string, isEn = false) {
const key = String(value || "").toLowerCase();
if (key === "bug") return "Bug";
if (key === "data") return isEn ? "Data" : "数据";
if (key === "idea") return isEn ? "Suggestion" : "建议";
if (key === "payment") return isEn ? "Payment" : "支付";
if (key === "account") return isEn ? "Account" : "账号";
return isEn ? "Other" : "其他";
}
export function UserFeedbackStatusButton({
isEn,
refreshKey = 0,
}: {
isEn: boolean;
refreshKey?: number;
}) {
const [available, setAvailable] = useState(true);
const [entries, setEntries] = useState<UserFeedbackEntry[]>([]);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
const unseenCount = useMemo(
() => countUnseenFeedbackUpdates(entries, seenKeys),
[entries, seenKeys],
);
const emptyStateText = loading
? isEn
? "Loading..."
: "加载中..."
: isEn
? "No submitted feedback yet."
: "暂无已提交反馈。";
const load = useCallback(async (signal?: AbortSignal) => {
if (typeof fetch !== "function") return;
setLoading(true);
try {
const res = await fetch("/api/feedback?limit=12", {
cache: "no-store",
headers: { Accept: "application/json" },
signal,
});
if (res.status === 401 || res.status === 403) {
setAvailable(false);
setEntries([]);
return;
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = (await res.json()) as UserFeedbackPayload;
setEntries(Array.isArray(payload.feedback) ? payload.feedback : []);
setAvailable(true);
setError("");
} catch (err) {
if (signal?.aborted) return;
setError(String(err).slice(0, 120));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, []);
const markVisibleAsSeen = useCallback((visibleEntries: UserFeedbackEntry[]) => {
setSeenKeys((current) => {
const next = new Set(current);
visibleEntries.forEach((entry) => next.add(buildFeedbackNotificationKey(entry)));
saveSeenKeys(next);
return next;
});
}, []);
useEffect(() => {
const controller = new AbortController();
void load(controller.signal);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void load();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
const id = window.setInterval(() => {
if (document.visibilityState === "visible") void load();
}, FEEDBACK_STATUS_POLL_MS);
return () => {
controller.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.clearInterval(id);
};
}, [load, refreshKey]);
useEffect(() => {
if (open) markVisibleAsSeen(entries);
}, [entries, markVisibleAsSeen, open]);
if (!available) return null;
return (
<div className="relative">
<button
type="button"
onClick={() => {
setOpen((value) => {
const next = !value;
if (next) markVisibleAsSeen(entries);
return next;
});
}}
className="relative grid h-7 w-7 place-items-center rounded-full border border-slate-300 bg-white text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-900"
title={isEn ? "Feedback status" : "反馈处理状态"}
aria-label={isEn ? "Feedback status" : "反馈处理状态"}
>
<Bell size={13} />
{unseenCount > 0 && (
<span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-blue-600 px-1 text-[9px] font-black leading-none text-white ring-2 ring-white">
{unseenCount > 9 ? "9+" : unseenCount}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-9 z-[70] w-[340px] max-w-[calc(100vw-1rem)] overflow-hidden rounded-md border border-slate-200 bg-white text-slate-800 shadow-xl">
<div className="flex items-center justify-between border-b border-slate-200 px-3 py-2">
<div>
<div className="text-xs font-black text-slate-950">
{isEn ? "Feedback updates" : "反馈处理动态"}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{isEn ? "Your submitted reports only" : "仅显示你提交过的反馈"}
</div>
</div>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="grid h-7 w-7 place-items-center rounded border border-slate-200 text-slate-500 transition hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60"
title={isEn ? "Refresh" : "刷新"}
aria-label={isEn ? "Refresh feedback status" : "刷新反馈状态"}
>
<RefreshCcw size={13} className={loading ? "animate-spin" : ""} />
</button>
</div>
{error && (
<div className="border-b border-amber-100 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
{isEn ? "Status refresh failed: " : "状态刷新失败:"}{error}
</div>
)}
{entries.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-slate-500">
{emptyStateText}
</div>
) : (
<div className="max-h-[360px] overflow-y-auto">
{entries.map((entry) => (
<div key={entry.id} className="border-b border-slate-100 px-3 py-2 last:border-b-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-bold text-slate-900">
{entry.message || (isEn ? "Feedback" : "反馈")}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-slate-500">
<span>{categoryLabel(entry.category, isEn)}</span>
{entry.created_at && <span>{compactDate(entry.created_at)}</span>}
</div>
</div>
<span className={`shrink-0 rounded border px-1.5 py-0.5 text-[10px] font-black ${feedbackStatusTone(entry.status)}`}>
{feedbackStatusLabel(entry.status, isEn)}
</span>
</div>
{entry.updated_at && entry.updated_at !== entry.created_at && (
<div className="mt-1 text-[11px] text-slate-500">
{isEn ? "Updated " : "更新于 "}{compactDate(entry.updated_at)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
feedbackStatusLabel,
} from "@/components/dashboard/scan-terminal/feedback-status";
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
export function runTests() {
assert(feedbackStatusLabel("open", false) === "已收到", "open feedback should read as received to users");
assert(feedbackStatusLabel("triaged", false) === "已确认", "triaged feedback should read as confirmed to users");
assert(feedbackStatusLabel("investigating", false) === "处理中", "investigating feedback should read as in progress");
assert(feedbackStatusLabel("resolved", false) === "已解决", "resolved feedback should read as handled");
assert(feedbackStatusLabel("closed", true) === "Closed", "closed feedback should have English copy");
const firstVersion = {
id: 7,
status: "open",
updated_at: "2026-06-06T10:00:00",
};
const handledVersion = {
...firstVersion,
status: "resolved",
updated_at: "2026-06-06T10:30:00",
};
const firstKey = buildFeedbackNotificationKey(firstVersion);
const handledKey = buildFeedbackNotificationKey(handledVersion);
assert(firstKey !== handledKey, "status and updated_at changes should create a new notification key");
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey])) === 1,
"already seen feedback versions should not count, but later handled versions should",
);
assert(
countUnseenFeedbackUpdates([firstVersion, handledVersion], new Set([firstKey, handledKey])) === 0,
"opening the status panel should be able to clear all visible feedback update badges",
);
}
@@ -16,14 +16,22 @@ export function runTests() {
"utf8",
);
const modalPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackModal.tsx");
const statusButtonPath = path.join(projectRoot, "components", "dashboard", "scan-terminal", "UserFeedbackStatusButton.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");
const feedbackRouteSource = fs.readFileSync(path.join(projectRoot, "app", "api", "feedback", "route.ts"), "utf8");
assert(fs.existsSync(modalPath), "terminal must ship a user feedback modal component");
assert(fs.existsSync(statusButtonPath), "terminal must ship a user-facing feedback status notification component");
const modalSource = fs.readFileSync(modalPath, "utf8");
const statusButtonSource = fs.readFileSync(statusButtonPath, "utf8");
const statusHelperSource = fs.readFileSync(
path.join(projectRoot, "components", "dashboard", "scan-terminal", "feedback-status.ts"),
"utf8",
);
assert(
dashboardSource.includes("onFeedbackClick") &&
@@ -40,9 +48,30 @@ export function runTests() {
assert(
modalSource.includes("/api/feedback") &&
modalSource.includes("getAnalyticsClientId") &&
modalSource.includes("onSubmitted") &&
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",
"feedback modal must submit to the feedback API, notify the dashboard after success, and attach client/session diagnostics without using invalid textarea input types",
);
assert(
feedbackRouteSource.includes("export async function GET") &&
feedbackRouteSource.includes("method: \"GET\"") &&
feedbackRouteSource.includes("limit"),
"feedback API proxy must expose a GET endpoint for the current user's feedback status list",
);
assert(
dashboardSource.includes("<UserFeedbackStatusButton") &&
dashboardSource.includes("feedbackRefreshKey") &&
dashboardSource.includes("setFeedbackRefreshKey"),
"terminal header must expose a small feedback status notification icon and refresh it after submissions",
);
assert(
statusButtonSource.includes("/api/feedback") &&
statusButtonSource.includes("localStorage") &&
statusButtonSource.includes("Bell") &&
statusHelperSource.includes("triaged") &&
statusHelperSource.includes("resolved"),
"feedback status notification must poll the user feedback API, badge unseen status changes, and translate handled states",
);
assert(
opsSidebarSource.includes("/ops/feedback") &&
@@ -0,0 +1,40 @@
import type { UserFeedbackEntry } from "@/types/ops";
export function feedbackStatusLabel(status: string | undefined, isEn: boolean) {
const key = String(status || "open").toLowerCase();
if (key === "triaged") return isEn ? "Confirmed" : "已确认";
if (key === "investigating") return isEn ? "In progress" : "处理中";
if (key === "resolved") return isEn ? "Resolved" : "已解决";
if (key === "closed") return isEn ? "Closed" : "已关闭";
return isEn ? "Received" : "已收到";
}
export function feedbackStatusTone(status: string | undefined) {
const key = String(status || "open").toLowerCase();
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";
if (key === "closed") return "border-slate-200 bg-slate-50 text-slate-500";
return "border-red-200 bg-red-50 text-red-700";
}
export function buildFeedbackNotificationKey(
entry: Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">,
) {
return [
Number(entry.id || 0),
String(entry.status || "open").toLowerCase(),
String(entry.updated_at || entry.created_at || ""),
].join(":");
}
export function countUnseenFeedbackUpdates(
entries: Array<
Pick<UserFeedbackEntry, "id" | "status" | "updated_at" | "created_at">
>,
seenKeys: Set<string>,
) {
return entries.reduce((count, entry) => {
return count + (seenKeys.has(buildFeedbackNotificationKey(entry)) ? 0 : 1);
}, 0);
}