report system
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Clock, Inbox, Search, SendHorizontal, Undo2, XCircle } from "lucide-react";
|
||||
import { api, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||
import { api, getToken, type AccessMessage, type AccessRequest, type AccessStatus } from "../lib/api.ts";
|
||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||
import { Button, CopyButton, Field, Input, Spinner } from "./ui.tsx";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
@@ -23,11 +24,8 @@ export function AccessRequestsPanel() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const listBusy = useRef(false);
|
||||
|
||||
async function refresh(select?: string) {
|
||||
if (listBusy.current) return;
|
||||
listBusy.current = true;
|
||||
try {
|
||||
const res = await api.adminAccessList();
|
||||
setRequests(res.requests);
|
||||
@@ -35,18 +33,25 @@ export function AccessRequestsPanel() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||
} finally {
|
||||
listBusy.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// Live list: new requests / replies pop without manual refresh.
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) refresh();
|
||||
}, 15000);
|
||||
return () => clearInterval(id);
|
||||
// Live list: the admin firehose pushes every access event (shares its
|
||||
// socket with the AdminCenter badge via the pool). Resync on
|
||||
// (re)connect covers events missed while offline.
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||
onEvent: (evt) => {
|
||||
if (evt.type !== "pong") refresh();
|
||||
},
|
||||
onSync: () => {
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -141,16 +146,12 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
||||
setReason(request.reason);
|
||||
setFreshKey(null);
|
||||
known.current = { status: request.status, reason: request.reason };
|
||||
let stopped = false;
|
||||
let busy = false;
|
||||
// Live thread: requester replies appear without manual refresh, and a
|
||||
// status change made elsewhere syncs the list (badges, filters).
|
||||
// Live thread: requester replies arrive instantly, and a status change
|
||||
// made elsewhere syncs the list (badges, filters). Resync on
|
||||
// (re)connect covers events missed while offline.
|
||||
async function fetchOnce() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await api.adminAccessGet(request.id);
|
||||
if (stopped) return;
|
||||
setMessages(res.messages);
|
||||
if (
|
||||
res.request.status !== known.current.status ||
|
||||
@@ -160,17 +161,26 @@ function TicketDetail({ request, onChanged }: { request: AccessRequest; onChange
|
||||
onChangedRef.current();
|
||||
}
|
||||
} catch {
|
||||
// Transient failure: keep last state, retry next tick.
|
||||
} finally {
|
||||
busy = false;
|
||||
// Transient failure: keep last state.
|
||||
}
|
||||
}
|
||||
fetchOnce();
|
||||
const id = setInterval(fetchOnce, 5000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
return subscribeAccessLive(wsUrl(`/api/access/ws/${request.id}`), {
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === "pong" || evt.request_id !== request.id) return;
|
||||
if (evt.type === "access_message") {
|
||||
const msg = evt.message;
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
|
||||
onChangedRef.current();
|
||||
} else if (evt.type === "access_status") {
|
||||
known.current = { status: evt.request.status, reason: evt.request.reason };
|
||||
onChangedRef.current();
|
||||
}
|
||||
},
|
||||
onSync: () => {
|
||||
fetchOnce();
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [request.id]);
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Eye, EyeOff, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { ArrowLeft, Eye, EyeOff, Flag, Inbox, LayoutDashboard, Mail, Send, ShieldAlert, SlidersHorizontal, Users } from "lucide-react";
|
||||
import { api, getToken } from "../lib/api.ts";
|
||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||
import { AdminPanel } from "./AdminPanel.tsx";
|
||||
import { AccessRequestsPanel } from "./AccessRequests.tsx";
|
||||
import { ReportsPanel } from "./ReportsPanel.tsx";
|
||||
import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
|
||||
type Tab = "accounts" | "access" | "features" | "mail";
|
||||
type Tab = "accounts" | "access" | "reports" | "features" | "mail";
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
const root = document.documentElement;
|
||||
@@ -24,10 +26,12 @@ export function AdminCenter() {
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||
const [openReportCount, setOpenReportCount] = useState(0);
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: typeof Users }[] = [
|
||||
{ id: "accounts", label: t("center.tabAccounts"), icon: Users },
|
||||
{ id: "access", label: t("center.tabAccess"), icon: Inbox },
|
||||
{ id: "reports", label: t("center.tabReports"), icon: Flag },
|
||||
{ id: "features", label: t("center.tabFeatures"), icon: SlidersHorizontal },
|
||||
{ id: "mail", label: t("center.tabMail"), icon: Mail },
|
||||
];
|
||||
@@ -46,30 +50,40 @@ export function AdminCenter() {
|
||||
.catch(() => setAllowed(false));
|
||||
}, []);
|
||||
|
||||
// Live badge on the Access tab: poll the open (pending + reviewing)
|
||||
// request count so new demands pop without opening the tab.
|
||||
// Live badges on the Access/Reports tabs: the admin firehose pushes every access
|
||||
// event, so the open counts stay fresh with zero polling (reports re-fetch
|
||||
// alongside). Resync on (re)connect covers events missed while offline.
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
let stopped = false;
|
||||
async function fetchCount() {
|
||||
try {
|
||||
const res = await api.adminAccessList();
|
||||
if (stopped) return;
|
||||
setOpenAccessCount(
|
||||
res.requests.filter((r) => r.status === "pending" || r.status === "reviewing").length,
|
||||
);
|
||||
} catch {
|
||||
// Transient failure: keep last count, retry next tick.
|
||||
// Transient failure: keep last count.
|
||||
}
|
||||
try {
|
||||
const res = await api.adminReportList();
|
||||
setOpenReportCount(
|
||||
res.reports.filter((r) => r.status === "open" || r.status === "reviewing").length,
|
||||
);
|
||||
} catch {
|
||||
// Transient failure: keep last count.
|
||||
}
|
||||
}
|
||||
fetchCount();
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) fetchCount();
|
||||
}, 15000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
return subscribeAccessLive(wsUrl(`/api/admin/ws?token=${encodeURIComponent(token)}`), {
|
||||
onEvent: (evt) => {
|
||||
if (evt.type !== "pong") fetchCount();
|
||||
},
|
||||
onSync: () => {
|
||||
fetchCount();
|
||||
},
|
||||
});
|
||||
}, [allowed]);
|
||||
|
||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean }) {
|
||||
@@ -122,7 +136,7 @@ export function AdminCenter() {
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||
tab === t.id
|
||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
@@ -139,6 +153,15 @@ export function AdminCenter() {
|
||||
{openAccessCount > 99 ? "99+" : openAccessCount}
|
||||
</span>
|
||||
)}
|
||||
{t.id === "reports" && openReportCount > 0 && (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={`${t.label}: ${openReportCount}`}
|
||||
className="absolute -right-0.5 -top-1.5 grid min-h-5 min-w-5 place-items-center rounded-full bg-red-500 px-1 text-[11px] font-bold leading-5 text-white shadow"
|
||||
>
|
||||
{openReportCount > 99 ? "99+" : openReportCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,6 +183,12 @@ export function AdminCenter() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "reports" && (
|
||||
<section className="rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<ReportsPanel />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "features" && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Archive, ArchiveRestore, BookOpen, Bot, Cpu, FileText, Menu, PanelLeftOpen, Paperclip, ScanText, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
||||
import { Archive, ArchiveRestore, BookOpen, Bot, Check, Copy, Cpu, FileText, Flag, Menu, PanelLeftOpen, Paperclip, ScanText, Search, SendHorizontal, Share2, User as UserIcon, X } from "lucide-react";
|
||||
import type { Attachment, ChatMessage, Conversation, DriverModel, User } from "../lib/types.ts";
|
||||
import { AssistantAvatar, Avatar, FlowerMark, IconButton, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||
import { api, type ReportReason } from "../lib/api.ts";
|
||||
import { AssistantAvatar, Avatar, Button, Field, FlowerMark, IconButton, Modal, Picker, type PickerGroup, Spinner } from "./ui.tsx";
|
||||
import { Markdown } from "./Markdown.tsx";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
import { useFeatures } from "../lib/features.ts";
|
||||
import { useT } from "../lib/i18n.ts";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
|
||||
function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; authorAvatar?: string; authorName: string }) {
|
||||
const REPORT_REASONS: ReportReason[] = ["copyright", "gore", "falseinfo", "bug"];
|
||||
|
||||
function MessageBubble({ msg, index, authorAvatar, authorName, onReport }: { msg: ChatMessage; index: number; authorAvatar?: string; authorName: string; onReport?: (index: number) => void }) {
|
||||
const { t } = useT();
|
||||
const isUser = msg.role === "user";
|
||||
const [copied, setCopied] = useState(false);
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(msg.content).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}).catch(() => {});
|
||||
}
|
||||
function searchWeb() {
|
||||
window.open(`https://duckduckgo.com/?q=${encodeURIComponent(msg.content.slice(0, 400))}`, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
{isUser
|
||||
@@ -29,11 +42,117 @@ function MessageBubble({ msg, authorAvatar, authorName }: { msg: ChatMessage; au
|
||||
<Markdown text={msg.content} />
|
||||
</div>
|
||||
)}
|
||||
{!isUser && onReport && (
|
||||
<div className="mt-1.5 flex items-center gap-0.5 opacity-70 transition hover:opacity-100">
|
||||
<IconButton title={copied ? t("common.copied") : t("msg.copy")} onClick={copy}>
|
||||
{copied ? <Check size={14} className="text-accent-500" /> : <Copy size={14} />}
|
||||
</IconButton>
|
||||
<IconButton title={t("msg.search")} onClick={searchWeb}>
|
||||
<Search size={14} />
|
||||
</IconButton>
|
||||
<IconButton title={t("msg.report")} onClick={() => onReport(index)}>
|
||||
<Flag size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDialog({ open, onClose, conversationId, messageIndex, content, model }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
conversationId: string | null;
|
||||
messageIndex: number;
|
||||
content: string;
|
||||
model: string;
|
||||
}) {
|
||||
const { t } = useT();
|
||||
const [reason, setReason] = useState("");
|
||||
const [details, setDetails] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason("");
|
||||
setDetails("");
|
||||
setError("");
|
||||
setSent(false);
|
||||
}
|
||||
}, [open, messageIndex]);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!reason || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.createReport({
|
||||
conversationId: conversationId ?? undefined,
|
||||
messageIndex,
|
||||
reason: reason as ReportReason,
|
||||
details: details.trim(),
|
||||
content,
|
||||
model,
|
||||
});
|
||||
setSent(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("report.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={t("report.title")} icon={Flag}>
|
||||
{sent ? (
|
||||
<div className="space-y-4">
|
||||
<p className="flex items-center gap-2 rounded-2xl bg-emerald-600/10 px-4 py-3 text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<Check size={16} /> {t("report.sent")}
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" onClick={onClose}>{t("common.close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<Field label={t("report.reason")}>
|
||||
<Picker
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
options={REPORT_REASONS.map((r) => ({ value: r, label: t(`report.reason.${r}` as StringKey) }))}
|
||||
placeholder={t("report.reasonPh")}
|
||||
ariaLabel={t("report.reason")}
|
||||
align="left"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("report.details")}>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
placeholder={t("report.detailsPh")}
|
||||
aria-label={t("report.details")}
|
||||
maxLength={2000}
|
||||
className="max-h-40 w-full resize-none rounded-2xl border border-stone-200 bg-white px-3.5 py-2.5 text-sm outline-none transition placeholder:text-stone-400 focus:border-accent-500/60 dark:border-zinc-700 dark:bg-zinc-900 dark:placeholder:text-zinc-500"
|
||||
/>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>{t("common.cancel")}</Button>
|
||||
<Button type="submit" disabled={!reason || busy}>
|
||||
{busy ? <Spinner size={14} /> : <Flag size={14} />} {t("report.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
user,
|
||||
conv,
|
||||
@@ -80,6 +199,7 @@ export function Chat({
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
const [reportIndex, setReportIndex] = useState<number | null>(null);
|
||||
const { t } = useT();
|
||||
const features = useFeatures();
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
@@ -169,7 +289,7 @@ export function Chat({
|
||||
</div>
|
||||
)}
|
||||
{messages.filter((m) => m.role !== "system").map((m, i) => (
|
||||
<MessageBubble key={i} msg={m} authorAvatar={user.avatarUrl} authorName={user.displayName} />
|
||||
<MessageBubble key={i} msg={m} index={i} authorAvatar={user.avatarUrl} authorName={user.displayName} onReport={readOnly ? undefined : setReportIndex} />
|
||||
))}
|
||||
{streaming !== "" && (
|
||||
<div className="flex gap-3">
|
||||
@@ -306,6 +426,20 @@ export function Chat({
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const shown = messages.filter((m) => m.role !== "system");
|
||||
const target = reportIndex !== null ? shown[reportIndex] : undefined;
|
||||
return (
|
||||
<ReportDialog
|
||||
open={reportIndex !== null}
|
||||
onClose={() => setReportIndex(null)}
|
||||
conversationId={conv?.id ?? null}
|
||||
messageIndex={reportIndex ?? 0}
|
||||
content={target?.content ?? ""}
|
||||
model={conv?.model || model}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, FileText } from "lucide-react";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
/**
|
||||
* User message content with attached files folded away.
|
||||
*
|
||||
* Text/OCR attachments travel inlined in the message as:
|
||||
* [attached text file: foo.txt]
|
||||
* ```text
|
||||
* <whole file content, up to 500KB>
|
||||
* ```
|
||||
* Rendering that raw blows the bubble off-screen, so each block renders as
|
||||
* a collapsed attachment row (header + expand chevron, no i18n needed) with
|
||||
* the body capped to a scrollable pane when expanded.
|
||||
*/
|
||||
const ATTACH_RE = /^(\[.*?\])\n```text\n([\s\S]*?)\n```/gm;
|
||||
|
||||
function AttachmentBlock({ header, body }: { header: string; body: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const label = header.startsWith("[") && header.endsWith("]") ? header.slice(1, -1) : header;
|
||||
return (
|
||||
<span className="mt-2 block overflow-hidden rounded-2xl bg-black/25 text-left dark:bg-black/30">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-[13px] font-medium"
|
||||
>
|
||||
<FileText size={14} className="shrink-0 opacity-80" />
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0 opacity-80 transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words border-t border-white/10 px-3 py-2 font-mono text-[12.5px] leading-relaxed">
|
||||
{body}
|
||||
</pre>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserMessageContent({ content }: { content: string }) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
let key = 0;
|
||||
ATTACH_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = ATTACH_RE.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(<span key={key++}>{content.slice(last, m.index)}</span>);
|
||||
parts.push(<AttachmentBlock key={key++} header={m[1]!} body={m[2] ?? ""} />);
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < content.length) parts.push(<span key={key++}>{content.slice(last)}</span>);
|
||||
if (parts.length === 0) return <>{content}</>;
|
||||
return <>{parts}</>;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { CheckCircle2, Clock, Flag, Inbox, Search, Undo2, XCircle } from "lucide-react";
|
||||
import { api, type Report, type ReportStatus } from "../lib/api.ts";
|
||||
import { Button, Field, Input, Spinner } from "./ui.tsx";
|
||||
import { useT, type StringKey } from "../lib/i18n.ts";
|
||||
import { cn } from "../lib/cn.ts";
|
||||
|
||||
const STATUS_STYLE: Record<ReportStatus, { cls: string }> = {
|
||||
open: { cls: "bg-amber-500/10 text-amber-700 dark:text-amber-400" },
|
||||
reviewing: { cls: "bg-sky-500/10 text-sky-700 dark:text-sky-400" },
|
||||
resolved: { cls: "bg-accent-500/10 text-accent-700 dark:text-accent-400" },
|
||||
dismissed: { cls: "bg-red-500/10 text-red-600 dark:text-red-400" },
|
||||
};
|
||||
|
||||
type Filter = "all" | ReportStatus;
|
||||
const FILTERS: Filter[] = ["all", "open", "reviewing", "resolved", "dismissed"];
|
||||
|
||||
/** Admin triage for user-flagged AI responses: review / resolve / dismiss. */
|
||||
export function ReportsPanel() {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [filter, setFilter] = useState<Filter>("open");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const listBusy = useRef(false);
|
||||
|
||||
async function refresh(select?: string) {
|
||||
if (listBusy.current) return;
|
||||
listBusy.current = true;
|
||||
try {
|
||||
const res = await api.adminReportList();
|
||||
setReports(res.reports);
|
||||
if (select) setSelectedId(select);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("admin.loadFailed"));
|
||||
} finally {
|
||||
listBusy.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// Live list: new reports pop without manual refresh.
|
||||
const id = setInterval(() => {
|
||||
if (!document.hidden) refresh();
|
||||
}, 15000);
|
||||
return () => clearInterval(id);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const visible = reports.filter((r) => filter === "all" || r.status === filter);
|
||||
const openCount = reports.filter((r) => r.status === "open").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{FILTERS.map((f) => {
|
||||
const n = f === "all" ? reports.length : reports.filter((r) => r.status === f).length;
|
||||
return (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
"rounded-full px-3.5 py-1.5 text-xs font-medium capitalize transition",
|
||||
filter === f
|
||||
? "bg-stone-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
|
||||
: "bg-stone-200/70 hover:bg-stone-300/60 dark:bg-zinc-800 dark:hover:bg-zinc-700",
|
||||
)}
|
||||
>
|
||||
{t(`reports.filter.${f}` as StringKey)} · {n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => refresh()}
|
||||
className="ml-auto rounded-full p-2 opacity-60 transition hover:bg-stone-200/60 hover:opacity-100 dark:hover:bg-zinc-800"
|
||||
title={t("reports.refresh")}
|
||||
aria-label={t("reports.refreshAria")}
|
||||
>
|
||||
<Undo2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="rounded-2xl bg-red-500/10 px-4 py-2.5 text-sm text-red-600 dark:text-red-400" role="alert">{error}</p>}
|
||||
{loading && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||
|
||||
{!loading && visible.length === 0 && (
|
||||
<p className="flex items-center justify-center gap-2 rounded-3xl border border-dashed border-stone-300 px-4 py-8 text-sm opacity-50 dark:border-zinc-700">
|
||||
<Inbox size={16} />
|
||||
{openCount > 0 ? t("reports.emptyFiltered") : t("reports.empty")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{visible.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
onClick={() => setSelectedId((cur) => (cur === r.id ? null : r.id))}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-3xl border p-4 text-left transition",
|
||||
selectedId === r.id
|
||||
? "border-accent-500/50 bg-accent-500/5"
|
||||
: "border-stone-200/70 bg-white hover:border-stone-300 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-zinc-700",
|
||||
)}
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-stone-200/70 dark:bg-zinc-800">
|
||||
<Flag size={15} className="opacity-70" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{t(`report.reason.${r.reason}` as StringKey)}
|
||||
<span className="font-normal opacity-50"> · @{r.reporter_name} · {new Date(r.created_at).toLocaleString()}</span>
|
||||
</span>
|
||||
<span className="block truncate text-xs opacity-60">{r.content || t(`report.reason.${r.reason}` as StringKey)}</span>
|
||||
</span>
|
||||
<span className={cn("shrink-0 rounded-full px-2.5 py-1 text-xs font-medium", STATUS_STYLE[r.status].cls)}>
|
||||
{t(`reports.status.${r.status}` as StringKey)}
|
||||
</span>
|
||||
</button>
|
||||
{selectedId === r.id && <ReportDetail report={r} onChanged={() => refresh(r.id)} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetail({ report, onChanged }: { report: Report; onChanged: () => void }) {
|
||||
const [note, setNote] = useState(report.admin_note);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { t } = useT();
|
||||
const closed = report.status === "resolved" || report.status === "dismissed";
|
||||
|
||||
useEffect(() => {
|
||||
setNote(report.admin_note);
|
||||
}, [report.id, report.admin_note]);
|
||||
|
||||
async function setStatus(status: ReportStatus) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.adminReportPatch(report.id, { status, adminNote: note.trim() });
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("reports.updateFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-3 rounded-3xl border border-stone-200/70 bg-stone-50 p-4 dark:border-zinc-800 dark:bg-zinc-900/60">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wider opacity-50">{t("reports.response")}</p>
|
||||
<div className="max-h-56 overflow-y-auto whitespace-pre-wrap rounded-2xl border border-stone-200/70 bg-white px-3.5 py-2.5 text-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||
{report.content || <span className="opacity-50">{t("reports.noContent")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs opacity-70">
|
||||
<span>{t("reports.model")}: <span className="font-medium opacity-100">{report.model || "—"}</span></span>
|
||||
<span>{t("reports.reporter")}: <span className="font-medium opacity-100">@{report.reporter_name}</span></span>
|
||||
{report.conversation_id && (
|
||||
<span>{t("reports.conversation")}: <code className="font-mono">{report.conversation_id.slice(0, 8)}…</code></span>
|
||||
)}
|
||||
{report.details && (
|
||||
<span className="w-full">{t("reports.detailsGiven")}: <span className="opacity-100">{report.details}</span></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!closed ? (
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-2xl border border-stone-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="min-w-48 flex-1">
|
||||
<Field label={t("reports.noteLabel")}>
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)} placeholder={t("reports.notePh")} maxLength={1000} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{report.status === "open" && (
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("reviewing")}>
|
||||
<Search size={14} /> {t("reports.btnReviewing")}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" disabled={busy} onClick={() => setStatus("resolved")}>
|
||||
<CheckCircle2 size={14} /> {t("reports.btnResolve")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setStatus("dismissed")} className="!text-red-600 dark:!text-red-400">
|
||||
<XCircle size={14} /> {t("reports.btnDismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="flex items-center gap-2 text-xs opacity-60">
|
||||
<Clock size={13} /> {t("reports.closedNote", { status: t(`reports.status.${report.status}` as StringKey).toLowerCase() })}
|
||||
{report.admin_note && <> {t("reports.noteWord")}: <em>{report.admin_note}</em></>}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api, type AccessMessage, type AccessRequest } from "../lib/api.ts";
|
||||
import { subscribeAccessLive, wsUrl } from "../lib/accessWs.ts";
|
||||
import {
|
||||
Button,
|
||||
CopyButton,
|
||||
@@ -82,32 +83,36 @@ export function ReviewPage({ ticketId }: { ticketId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
load();
|
||||
// Live updates: short-poll the ticket so admin replies and the
|
||||
// decision show up without a manual refresh. No WS infra on the
|
||||
// backend — traffic here is tiny, polling is the clean fit.
|
||||
let busy = false;
|
||||
async function poll() {
|
||||
if (stopped || busy || document.hidden) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await api.accessTicket(ticketId);
|
||||
if (stopped) return;
|
||||
setRequest(res.request);
|
||||
setMessages(res.messages);
|
||||
} catch {
|
||||
// Transient failure: keep last known state, retry next tick.
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
/** Silent resync after a (re)connect — never flips to the error state. */
|
||||
async function refreshSilent() {
|
||||
try {
|
||||
const res = await api.accessTicket(ticketId);
|
||||
setRequest(res.request);
|
||||
setMessages(res.messages);
|
||||
} catch {
|
||||
// Transient failure: keep last known state.
|
||||
}
|
||||
const id = setInterval(poll, 5000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Live updates over WebSocket: admin replies and the decision arrive
|
||||
// instantly; resync on (re)connect covers events missed while offline.
|
||||
const close = subscribeAccessLive(wsUrl(`/api/access/ws/${ticketId}`), {
|
||||
onEvent: (evt) => {
|
||||
if (evt.type === "pong" || evt.request_id !== ticketId) return;
|
||||
if (evt.type === "access_message") {
|
||||
const msg = evt.message;
|
||||
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
|
||||
} else if (evt.type === "access_status") {
|
||||
setRequest(evt.request);
|
||||
}
|
||||
},
|
||||
onSync: () => {
|
||||
refreshSilent();
|
||||
},
|
||||
});
|
||||
return close;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ticketId]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user