285 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|