Files
PolyWeather/frontend/components/dashboard/scan-terminal/UserFeedbackStatusButton.tsx
2026-06-08 15:12:41 +08:00

285 lines
10 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Bell, RefreshCcw } from "lucide-react";
import type { UserFeedbackEntry, UserFeedbackPayload } from "@/types/ops";
import {
buildFeedbackNotificationKey,
countUnseenFeedbackUpdates,
FEEDBACK_STATUS_CACHE_TTL_MS,
FEEDBACK_STATUS_POLL_MS,
feedbackStatusLabel,
feedbackStatusTone,
} from "./feedback-status";
const FEEDBACK_STATUS_SEEN_KEY = "polyweather_feedback_status_seen_v1";
const FEEDBACK_STATUS_CACHE_KEY = "polyweather_feedback_status_cache_v1";
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 readFeedbackStatusCache(): { entries: UserFeedbackEntry[]; ts: number } | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(FEEDBACK_STATUS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
const ts = Number(parsed?.ts || 0);
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
if (!ts || Date.now() - ts > FEEDBACK_STATUS_CACHE_TTL_MS) return null;
return { entries, ts };
} catch {
return null;
}
}
function writeFeedbackStatusCache(entries: UserFeedbackEntry[]) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
FEEDBACK_STATUS_CACHE_KEY,
JSON.stringify({ entries: entries.slice(0, 20), ts: Date.now() }),
);
} catch {
// Ignore storage failures; status will still refresh over the network.
}
}
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[]>(() => readFeedbackStatusCache()?.entries || []);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [seenKeys, setSeenKeys] = useState<Set<string>>(() => loadSeenKeys());
const lastLoadedAtRef = useRef<number>(readFeedbackStatusCache()?.ts || 0);
const unseenCount = useMemo(
() => countUnseenFeedbackUpdates(entries, seenKeys),
[entries, seenKeys],
);
const emptyStateText = loading
? isEn
? "Loading..."
: "加载中..."
: isEn
? "No submitted feedback yet."
: "暂无已提交反馈。";
const load = useCallback(async (
signal?: AbortSignal,
options?: { force?: boolean },
) => {
if (typeof fetch !== "function") return;
const cached = readFeedbackStatusCache();
if (!options?.force && cached) {
lastLoadedAtRef.current = cached.ts;
setEntries(cached.entries);
setAvailable(true);
return;
}
const now = Date.now();
if (
!options?.force &&
lastLoadedAtRef.current &&
now - lastLoadedAtRef.current < FEEDBACK_STATUS_CACHE_TTL_MS
) {
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;
const nextEntries = Array.isArray(payload.feedback) ? payload.feedback : [];
setEntries(nextEntries);
writeFeedbackStatusCache(nextEntries);
lastLoadedAtRef.current = Date.now();
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, { force: refreshKey > 0 });
const handleVisibilityChange = () => {
if (
document.visibilityState === "visible" &&
Date.now() - lastLoadedAtRef.current >= FEEDBACK_STATUS_CACHE_TTL_MS
) {
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(undefined, { force: true })}
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>
);
}