"use client"; import { useEffect, useMemo, useState } from "react"; import { Bug, CheckCircle2, Coins, 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 STATUS_UPDATE_OPTIONS = STATUS_OPTIONS.filter((item) => item.key); const REWARD_POINT_OPTIONS = [ { value: 100, label: "100 分", title: "轻量提醒" }, { value: 300, label: "300 分", title: "可复现 Bug" }, { value: 500, label: "500 分", title: "有效数据问题" }, { value: 1000, label: "1000 分", title: "高影响问题" }, { value: 1500, label: "1500 分", title: "重大事故" }, ] as const; const REWARD_GUIDELINES = [ { points: "0", title: "无效/重复", detail: "重复反馈、无法复现、非问题" }, { points: "100", title: "轻量提醒", detail: "文案、体验、小范围提示" }, { points: "300", title: "可复现 Bug", detail: "加载失败、操作异常、局部影响" }, { points: "500", title: "有效数据问题", detail: "城市数据、图表、关键变量异常" }, { points: "1000", title: "高影响问题", detail: "支付、账号、订阅、核心终端异常" }, { points: "1500", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" }, ] as const; 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) { 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 objectRecord(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function finiteNumber(value: unknown) { const number = Number(value); return Number.isFinite(number) ? number : null; } function formatSeconds(value: unknown) { const number = finiteNumber(value); return number === null ? null : `${number}s`; } function latencyTone(value: unknown) { const number = finiteNumber(value); if (number === null) return "border-slate-200 bg-slate-50 text-slate-600"; if (number >= 300) return "border-red-200 bg-red-50 text-red-700"; if (number >= 60) return "border-amber-200 bg-amber-50 text-amber-700"; return "border-emerald-200 bg-emerald-50 text-emerald-700"; } function feedbackFreshnessBadges(context?: Record) { const freshness = objectRecord(context?.freshness); const detailBatchDiagnostics = objectRecord(context?.detail_batch_diagnostics); const badges: Array<{ key: string; label: string; tone: string }> = []; const livePath = String(freshness?.live_path || context?.live_path || "").trim(); const liveAge = formatSeconds(freshness?.live_age_sec ?? context?.live_age_sec); if (livePath) { badges.push({ key: "live", label: liveAge ? `实时 ${livePath} ${liveAge}` : `实时 ${livePath}`, tone: latencyTone(freshness?.live_age_sec ?? context?.live_age_sec), }); } const detailStatus = String(freshness?.detail_status || context?.detail_status || "").trim(); const detailSource = String(freshness?.detail_source || context?.detail_source || "").trim(); if (detailStatus) { badges.push({ key: "detail", label: detailSource ? `详情 ${detailStatus}/${detailSource}` : `详情 ${detailStatus}`, tone: ["degraded", "stale_cache"].includes(detailStatus) ? "border-amber-200 bg-amber-50 text-amber-700" : "border-slate-200 bg-slate-50 text-slate-600", }); } const sseServerToClientLatency = formatSeconds(freshness?.sse_server_to_client_latency_sec); if (sseServerToClientLatency) { badges.push({ key: "sse-server", label: `SSE ${sseServerToClientLatency}`, tone: latencyTone(freshness?.sse_server_to_client_latency_sec), }); } const sseCollectorToClientLatency = formatSeconds(freshness?.sse_collector_to_client_latency_sec); if (sseCollectorToClientLatency) { badges.push({ key: "sse-collector", label: `采集到前端 ${sseCollectorToClientLatency}`, tone: latencyTone(freshness?.sse_collector_to_client_latency_sec), }); } const sseSourceToCollectorLatency = formatSeconds(freshness?.sse_source_to_collector_latency_sec); if (sseSourceToCollectorLatency) { badges.push({ key: "sse-source", label: `源到采集 ${sseSourceToCollectorLatency}`, tone: latencyTone(freshness?.sse_source_to_collector_latency_sec), }); } const partialReason = String(detailBatchDiagnostics?.partial_reason || "").trim(); const responseSource = String(detailBatchDiagnostics?.response_source || "").trim(); if (partialReason || responseSource) { badges.push({ key: "batch", label: responseSource ? `批量 ${partialReason || "ok"} · ${responseSource}` : `批量 ${partialReason}`, tone: partialReason ? "border-amber-200 bg-amber-50 text-amber-700" : "border-slate-200 bg-slate-50 text-slate-600", }); } const missingCount = finiteNumber(detailBatchDiagnostics?.missing_count); const errorCount = finiteNumber(detailBatchDiagnostics?.error_count); if (missingCount || errorCount) { badges.push({ key: "batch-counts", label: `缺失 ${missingCount || 0} · 错误 ${errorCount || 0}`, tone: "border-red-200 bg-red-50 text-red-700", }); } return badges; } export function FeedbackPageClient() { const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); const [actionError, setActionError] = useState(""); const [filter, setFilter] = useState(""); const [payload, setPayload] = useState(null); const [updatingId, setUpdatingId] = useState(null); const [rewardingId, setRewardingId] = useState(null); const [rewardPointsById, setRewardPointsById] = useState>({}); const load = async () => { setLoading(true); setLoadError(""); try { const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload; setPayload(data); } catch (err) { setLoadError(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 = {}; rows.forEach((row) => { const key = String(row.category || "other"); acc[key] = (acc[key] || 0) + 1; }); return acc; }, [rows]); const changeStatus = async (row: UserFeedbackEntry, next: string) => { const current = String(row.status || "open").toLowerCase(); if (!next || next === current) return; setUpdatingId(row.id); try { await opsApi.updateFeedbackStatus(row.id, next); await load(); } finally { setUpdatingId(null); } }; const updateRewardPoints = (rowId: number, points: string) => { setRewardPointsById((prev) => ({ ...prev, [rowId]: points, })); }; const handleRewardGrant = async (row: UserFeedbackEntry) => { const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value); const points = Number.parseInt(selectedPoints, 10); if (!row.user_email) { setActionError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。"); return; } if (!Number.isFinite(points) || points <= 0) { setActionError("请输入有效的奖励积分。"); return; } setRewardingId(row.id); setActionError(""); try { await opsApi.grantFeedbackReward(row.id, points); setRewardPointsById((prev) => { const next = { ...prev }; delete next[row.id]; return next; }); await load(); } catch (err) { setActionError(String(err).slice(0, 220)); } finally { setRewardingId(null); } }; if (loading && !payload) { return
加载中...
; } return (

用户反馈

{loadError && (
加载失败:{loadError}
)} {actionError && (
奖励发放失败:{actionError}
)}
新反馈
{openCount}
处理中
{activeCount}
已解决
{Number(counts.resolved || 0)}
当前列表
{rows.length}
Bug {categoryCounts.bug || 0} · 数据 {categoryCounts.data || 0} · 建议 {categoryCounts.idea || 0}
积分奖励标准
{REWARD_GUIDELINES.map((item) => (
{item.points} 分
{item.title}
{item.detail}
))}

先确认反馈是否有效;未复现的问题可先标为已确认/处理中,奖励发放后会自动记录到用户账户页。

反馈收件箱
{STATUS_OPTIONS.map((item) => ( ))}
{rows.length === 0 ? (
暂无反馈。
) : (
{rows.map((row) => { const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value); const rewardPoints = Number(row.reward_points || 0); const rewardStatus = String(row.reward_status || "").toLowerCase(); const hasReward = rewardStatus === "granted" && rewardPoints > 0; const freshnessBadges = feedbackFreshnessBadges(row.context); return ( ); })}
状态 类型 内容 上下文 用户 时间 奖励 操作
{statusLabel(row.status)} {categoryLabel(row.category)}
{row.message || "—"}
{row.contact &&
联系:{row.contact}
}
{contextSummary(row.context)}
{Boolean(row.context?.detail_error) && (
{String(row.context?.detail_error || "").slice(0, 120)}
)} {freshnessBadges.length > 0 && (
{freshnessBadges.map((item) => ( {item.label} ))}
)}
{row.user_email || row.user_id || "—"} {compactDate(row.created_at)} {hasReward ? (
已发放 +{rewardPoints.toLocaleString()} 分
) : (
{!row.user_email && (
无用户邮箱,无法直接发放。
)}
)}
)}
); }