feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CsfWywUl.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CkSmQdTc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import type {
|
|||
GroupMessageListResponse,
|
||||
MessageDetail,
|
||||
MessageListResponse,
|
||||
ModerationCaseDetail,
|
||||
ModerationCaseRow,
|
||||
ModerationReport,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
|
|
@ -73,6 +76,27 @@ export const api = {
|
|||
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
|
||||
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
|
||||
},
|
||||
moderationCases: (params: URLSearchParams) =>
|
||||
request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`),
|
||||
moderationCase: (id: number) =>
|
||||
request<ModerationCaseDetail>(`/api/moderation/cases/${id}`),
|
||||
moderationReport: (id: number) =>
|
||||
request<ModerationReport>(`/api/moderation/reports/${id}`),
|
||||
claimModerationCase: (id: number, expectedVersion: number) =>
|
||||
request<ModerationCaseRow>(`/api/moderation/cases/${id}/claim`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_version: expectedVersion })
|
||||
}),
|
||||
decideModerationCase: (id: number, payload: Record<string, unknown>) =>
|
||||
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
reviewModerationAppeal: (caseID: number, appealID: number, payload: Record<string, unknown>) =>
|
||||
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}),
|
||||
gifts: () => request<StarGiftListResponse>("/api/gifts"),
|
||||
officialGifts: () => request<OfficialStarGiftListResponse>("/api/official-gifts"),
|
||||
officialGiftAnimation: (id: string) => request<Record<string, unknown>>(`/api/official-gifts/${encodeURIComponent(id)}/animation`),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
MessageSquareText,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Users,
|
||||
|
|
@ -80,6 +81,7 @@ export function Shell({
|
|||
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
|
||||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
|
||||
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{t("layout.moderation")}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.accountsSubtitle": "Console / Accounts",
|
||||
"route.channels": "Supergroups and Channels",
|
||||
"route.channelsSubtitle": "Console / Channels",
|
||||
"route.moderation": "Reports and Moderation",
|
||||
"route.moderationSubtitle": "Console / Moderation",
|
||||
"route.dashboard": "Operations Console",
|
||||
"route.dashboardSubtitle": "Console / Overview",
|
||||
"route.messages": "Message Audit",
|
||||
|
|
@ -70,6 +72,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.dashboard": "Overview",
|
||||
"layout.accounts": "Accounts",
|
||||
"layout.channels": "Supergroups / Channels",
|
||||
"layout.moderation": "Reports / Moderation",
|
||||
"layout.messages": "Messages",
|
||||
"layout.gifts": "Star Gifts",
|
||||
"layout.giveGifts": "Give Gifts",
|
||||
|
|
@ -539,6 +542,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.accountsSubtitle": "控制台 / 账号",
|
||||
"route.channels": "超级群与频道",
|
||||
"route.channelsSubtitle": "控制台 / 频道",
|
||||
"route.moderation": "举报与审核",
|
||||
"route.moderationSubtitle": "控制台 / 内容安全",
|
||||
"route.dashboard": "运维控制台",
|
||||
"route.dashboardSubtitle": "控制台 / 总览",
|
||||
"route.messages": "消息审计",
|
||||
|
|
@ -552,6 +557,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.dashboard": "总览",
|
||||
"layout.accounts": "账号",
|
||||
"layout.channels": "超级群/频道",
|
||||
"layout.moderation": "举报/审核",
|
||||
"layout.messages": "消息",
|
||||
"layout.gifts": "礼物目录",
|
||||
"layout.giveGifts": "赠送礼物",
|
||||
|
|
@ -1021,6 +1027,8 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"route.accountsSubtitle": "Консоль / Аккаунты",
|
||||
"route.channels": "Супергруппы и каналы",
|
||||
"route.channelsSubtitle": "Консоль / Каналы",
|
||||
"route.moderation": "Жалобы и модерация",
|
||||
"route.moderationSubtitle": "Консоль / Модерация",
|
||||
"route.dashboard": "Панель управления",
|
||||
"route.dashboardSubtitle": "Консоль / Обзор",
|
||||
"route.messages": "Аудит сообщений",
|
||||
|
|
@ -1034,6 +1042,7 @@ const translations: Record<Language, Record<string, string>> = {
|
|||
"layout.dashboard": "Обзор",
|
||||
"layout.accounts": "Аккаунты",
|
||||
"layout.channels": "Супергруппы / Каналы",
|
||||
"layout.moderation": "Жалобы / Модерация",
|
||||
"layout.messages": "Сообщения",
|
||||
"layout.gifts": "Звёздные подарки",
|
||||
"layout.giveGifts": "Выдача подарков",
|
||||
|
|
|
|||
362
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
362
cmd/telesrv-admin/web/src/pages/ModerationCaseDetailPage.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ModerationCaseDetail, ModerationReport } from "../types";
|
||||
import { CaseStatus } from "./ModerationCasesPage";
|
||||
|
||||
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
|
||||
|
||||
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
|
||||
const [report, setReport] = useState<ModerationReport | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [preset, setPreset] = useState<DecisionPreset>("no_violation");
|
||||
const [messageIDs, setMessageIDs] = useState("");
|
||||
const [ownerUserID, setOwnerUserID] = useState("");
|
||||
const [revokeMessages, setRevokeMessages] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function selectReport(next: ModerationReport | null) {
|
||||
setReport(next);
|
||||
if (!next) return;
|
||||
const ids = next.Items
|
||||
.filter((item) => item.Kind === "message")
|
||||
.map((item) => Number(item.ItemID))
|
||||
.filter((value) => Number.isSafeInteger(value) && value > 0);
|
||||
setMessageIDs(ids.join(", "));
|
||||
setOwnerUserID(String(next.ReporterUserID));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setError("");
|
||||
try {
|
||||
const next = await api.moderationCase(id);
|
||||
setDetail(next);
|
||||
const reportID = next.ReportIDs[0];
|
||||
selectReport(reportID ? await api.moderationReport(reportID) : null);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
const selectedActions = useMemo(
|
||||
() => actionsForPreset(
|
||||
preset,
|
||||
detail?.Case.Target.Type,
|
||||
parseMessageIDs(messageIDs),
|
||||
Number(ownerUserID),
|
||||
revokeMessages
|
||||
),
|
||||
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
|
||||
);
|
||||
const appealRemedy = useMemo(
|
||||
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "无", blocked: false },
|
||||
[detail]
|
||||
);
|
||||
|
||||
async function claim() {
|
||||
if (!detail) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.claimModerationCase(id, detail.Case.Version);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function decide() {
|
||||
if (!detail || !reason.trim()) {
|
||||
setError("必须填写审核理由。");
|
||||
return;
|
||||
}
|
||||
if (preset === "delete_messages" && selectedActions.length === 0) {
|
||||
setError(detail.Case.Target.Type === "user"
|
||||
? "私聊删除需要合法的证据消息 ID 和举报人 owner_user_id。"
|
||||
: "频道删除需要至少一个合法的证据消息 ID。");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确认提交 ${preset} 决定?处置会通过 durable action 队列执行。`)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api.decideModerationCase(id, {
|
||||
expected_version: detail.Case.Version,
|
||||
reason: reason.trim(),
|
||||
kind: preset === "no_violation" ? "no_violation" : "violation",
|
||||
actions: selectedActions
|
||||
});
|
||||
setDetail(result.case);
|
||||
setReason("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewAppeal(appealID: number, granted: boolean) {
|
||||
if (!detail || !reason.trim()) {
|
||||
setError("必须填写申诉复核理由。");
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(granted ? "确认通过申诉?" : "确认驳回申诉?")) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await api.reviewModerationAppeal(id, appealID, {
|
||||
expected_version: detail.Case.Version,
|
||||
reason: reason.trim(),
|
||||
granted,
|
||||
actions: granted ? appealRemedy.actions : []
|
||||
});
|
||||
setDetail(result.case);
|
||||
setReason("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !detail) return <Alert>{error}</Alert>;
|
||||
if (!detail) return <LoadingSurface label="正在加载审核案件…" />;
|
||||
const item = detail.Case;
|
||||
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
|
||||
const canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
|
||||
const canSubmitDecision = canDecide && (item.Status !== "action_failed" || preset !== "no_violation");
|
||||
const pendingAppeal = detail.Appeals.find((appeal) => appeal.Status === "pending");
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={`审核案件 #${item.ID}`}
|
||||
eyebrow="Moderation / Case detail"
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" onClick={() => navigate("/moderation")}><ArrowLeft size={15} /> 返回队列</button>
|
||||
<button className="btn icon-text" onClick={load}><RefreshCw size={15} /> 刷新</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{item.Target.Type}:{item.Target.ID}</div>
|
||||
<div className="entity-subtitle">版本 {item.Version} · 最近更新 {formatDate(item.UpdatedAt)}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<CaseStatus status={item.Status} />
|
||||
<Badge tone={item.Severity >= 4 ? "danger" : item.Severity >= 3 ? "warn" : "neutral"}>severity {item.Severity}</Badge>
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label="目标" value={`${item.Target.Type}:${item.Target.ID}`} mono />
|
||||
<Summary label="举报数" value={`${item.ReportCount}(${item.DistinctReporterCount} 位举报人)`} />
|
||||
<Summary label="审核人" value={item.AssignedTo || "-"} />
|
||||
<Summary label="首个 / 最近举报" value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`} />
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<SectionHead title="举报证据" text="最多显示最近 100 条;快照在举报受理时冻结。" />
|
||||
<div className="toolbar">
|
||||
{detail.ReportIDs.map((reportID) => (
|
||||
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
|
||||
#{reportID}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{report && (
|
||||
<>
|
||||
<div className="summary-grid">
|
||||
<Summary label="来源 / 原因" value={`${report.Source} / ${report.Reason}`} />
|
||||
<Summary label="举报人" value={String(report.ReporterUserID)} mono />
|
||||
<Summary label="选项" value={report.Option} mono />
|
||||
<Summary label="时间" value={formatDate(report.CreatedAt)} />
|
||||
</div>
|
||||
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
||||
<JsonBlock value={JSON.stringify(report, null, 2)} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<SectionHead title="决定与处置审计" text="动作由租约 worker 幂等执行;失败保留错误与尝试次数。" />
|
||||
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
|
||||
</section>
|
||||
{detail.Appeals.length > 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title="申诉" />
|
||||
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">案件操作</div>
|
||||
{canClaim && (
|
||||
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
|
||||
<ShieldCheck size={15} /> {item.AssignedTo ? "续领案件" : "领取案件"}
|
||||
</button>
|
||||
)}
|
||||
<label className="field">
|
||||
<span>审核理由</span>
|
||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>决定模板</span>
|
||||
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
|
||||
<option value="no_violation">无违规(驳回举报)</option>
|
||||
<option value="scam">标记 SCAM</option>
|
||||
<option value="fake">标记 FAKE</option>
|
||||
<option value="freeze">冻结账号</option>
|
||||
<option value="scam_freeze">SCAM + 冻结</option>
|
||||
<option value="fake_freeze">FAKE + 冻结</option>
|
||||
<option value="delete_messages">删除证据覆盖的消息</option>
|
||||
<option value="delete_account">删除账号</option>
|
||||
</select>
|
||||
</label>
|
||||
{preset === "delete_messages" && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>证据消息 ID(逗号分隔)</span>
|
||||
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
|
||||
</label>
|
||||
{item.Target.Type === "user" && (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>私聊 owner_user_id</span>
|
||||
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
|
||||
</label>
|
||||
<label className="field checkbox-field">
|
||||
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
|
||||
<span>双方撤回</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<Alert>服务端会再次校验每个消息 ID 必须存在于该案件的不可变举报证据中。</Alert>
|
||||
</>
|
||||
)}
|
||||
{item.Status === "action_failed" && preset === "no_violation" && (
|
||||
<Alert>处置已部分执行,不能直接改为无违规;请选择新的处置动作重新执行并保留旧失败审计。</Alert>
|
||||
)}
|
||||
{canDecide && (
|
||||
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
|
||||
<CheckCircle2 size={15} /> {item.Status === "action_failed" ? "重新执行处置" : "提交决定"}
|
||||
</button>
|
||||
)}
|
||||
{pendingAppeal && item.AssignedTo && (
|
||||
<>
|
||||
<div className="dock-title">申诉复核 #{pendingAppeal.ID}</div>
|
||||
<Summary label="通过后自动恢复" value={appealRemedy.label} />
|
||||
{appealRemedy.blocked && (
|
||||
<Alert>案件包含已成功的不可逆删除动作,不能标记为“申诉通过并已恢复”;请驳回或升级人工处理。</Alert>
|
||||
)}
|
||||
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>驳回申诉</button>
|
||||
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>通过申诉</button>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function actionsForPreset(
|
||||
preset: DecisionPreset,
|
||||
targetType: string | undefined,
|
||||
messageIDs: number[],
|
||||
ownerUserID: number,
|
||||
revoke: boolean
|
||||
): Array<{ kind: string; payload: Record<string, unknown> }> {
|
||||
switch (preset) {
|
||||
case "scam":
|
||||
return [{ kind: "mark_scam", payload: {} }];
|
||||
case "fake":
|
||||
return [{ kind: "mark_fake", payload: {} }];
|
||||
case "freeze":
|
||||
return [{ kind: "freeze_account", payload: {} }];
|
||||
case "scam_freeze":
|
||||
return [{ kind: "mark_scam", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||
case "fake_freeze":
|
||||
return [{ kind: "mark_fake", payload: {} }, { kind: "freeze_account", payload: {} }];
|
||||
case "delete_messages":
|
||||
if (messageIDs.length === 0) return [];
|
||||
if (targetType === "channel") {
|
||||
return [{ kind: "delete_channel_message", payload: { ids: messageIDs } }];
|
||||
}
|
||||
if (targetType === "user" && Number.isSafeInteger(ownerUserID) && ownerUserID > 0) {
|
||||
return [{ kind: "delete_private_message", payload: { owner_user_id: ownerUserID, ids: messageIDs, revoke } }];
|
||||
}
|
||||
return [];
|
||||
case "delete_account":
|
||||
return [{ kind: "delete_account", payload: {} }];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseMessageIDs(raw: string): number[] {
|
||||
const values = raw
|
||||
.split(/[,\s]+/)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value <= 0)) return [];
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function requiredAppealRemedy(detail: ModerationCaseDetail): {
|
||||
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
|
||||
label: string;
|
||||
blocked: boolean;
|
||||
} {
|
||||
let flagsActive = false;
|
||||
let freezeActive = false;
|
||||
let irreversible = false;
|
||||
for (const action of [...detail.Actions].sort((left, right) => left.ID - right.ID)) {
|
||||
if (action.Status !== "succeeded") continue;
|
||||
switch (action.Kind) {
|
||||
case "mark_scam":
|
||||
case "mark_fake":
|
||||
flagsActive = true;
|
||||
break;
|
||||
case "clear_peer_flags":
|
||||
flagsActive = false;
|
||||
break;
|
||||
case "freeze_account":
|
||||
freezeActive = true;
|
||||
break;
|
||||
case "unfreeze_account":
|
||||
freezeActive = false;
|
||||
break;
|
||||
case "delete_private_message":
|
||||
case "delete_channel_message":
|
||||
case "delete_account":
|
||||
irreversible = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const actions: Array<{ kind: string; payload: Record<string, unknown> }> = [];
|
||||
const labels: string[] = [];
|
||||
if (flagsActive) {
|
||||
actions.push({ kind: "clear_peer_flags", payload: {} });
|
||||
labels.push("清除 SCAM / FAKE");
|
||||
}
|
||||
if (freezeActive) {
|
||||
actions.push({ kind: "unfreeze_account", payload: {} });
|
||||
labels.push("解除冻结");
|
||||
}
|
||||
return { actions, label: labels.join(" + ") || "无需恢复动作", blocked: irreversible };
|
||||
}
|
||||
117
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
117
cmd/telesrv-admin/web/src/pages/ModerationCasesPage.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { ModerationCaseRow } from "../types";
|
||||
|
||||
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
|
||||
|
||||
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||
const [statuses, setStatuses] = useState(defaultStatuses);
|
||||
const [assignedTo, setAssignedTo] = useState("");
|
||||
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const params = new URLSearchParams({ statuses, limit: "100" });
|
||||
if (assignedTo.trim()) params.set("assigned_to", assignedTo.trim());
|
||||
setRows((await api.moderationCases(params)).cases);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const pendingActions = rows.filter((row) => row.Status === "action_pending" || row.Status === "action_failed").length;
|
||||
const critical = rows.filter((row) => row.Severity === 4).length;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title="举报与审核"
|
||||
eyebrow="Moderation / Cases"
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> 刷新
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label="当前队列" value={String(rows.length)} />
|
||||
<Metric label="关键案件" value={String(critical)} tone={critical ? "danger" : "neutral"} />
|
||||
<Metric label="处置待完成/失败" value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
|
||||
</div>
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
|
||||
<label className="field-inline">
|
||||
<span>状态</span>
|
||||
<input value={statuses} onChange={(event) => setStatuses(event.target.value)} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>审核人</span>
|
||||
<input value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)} placeholder="留空为全部" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
<ShieldAlert size={15} /> 查询
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>案件</th><th>目标</th><th>状态</th><th>等级</th>
|
||||
<th>举报 / 举报人</th><th>审核人</th><th>最近举报</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">#{row.ID}</td>
|
||||
<td className="mono">{row.Target.Type}:{row.Target.ID}</td>
|
||||
<td><CaseStatus status={row.Status} /></td>
|
||||
<td><Severity value={row.Severity} /></td>
|
||||
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
|
||||
<td>{row.AssignedTo || "-"}</td>
|
||||
<td>{formatDate(row.LastReportAt)}</td>
|
||||
<td>
|
||||
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
|
||||
审核 <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function CaseStatus({ status }: { status: string }) {
|
||||
const tone = status === "resolved" || status === "dismissed"
|
||||
? "good"
|
||||
: status === "action_failed"
|
||||
? "danger"
|
||||
: status === "action_pending"
|
||||
? "warn"
|
||||
: "neutral";
|
||||
return <Badge tone={tone}>{status}</Badge>;
|
||||
}
|
||||
|
||||
function Severity({ value }: { value: number }) {
|
||||
const labels = ["", "低", "中", "高", "关键"];
|
||||
return <Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>{labels[value] || value}</Badge>;
|
||||
}
|
||||
|
|
@ -13,11 +13,14 @@ import { MessageDetailPage } from "./MessageDetailPage";
|
|||
import { MessagesPage } from "./MessagesPage";
|
||||
import { GiftsPage } from "./GiftsPage";
|
||||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -27,6 +30,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (botID) {
|
||||
return <BotDetailPage id={Number(botID)} navigate={navigate} />;
|
||||
}
|
||||
if (moderationCaseID) {
|
||||
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/accounts") {
|
||||
return <AccountsPage navigate={navigate} />;
|
||||
}
|
||||
|
|
@ -36,6 +42,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/bots") {
|
||||
return <BotsPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/moderation") {
|
||||
return <ModerationCasesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/emoji") {
|
||||
return <EmojiPage />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
||||
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||
if (pathname.startsWith("/bots")) return t("route.bots");
|
||||
if (pathname.startsWith("/moderation")) return t("route.moderation");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emoji");
|
||||
if (pathname.startsWith("/messages")) return t("route.messages");
|
||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts");
|
||||
|
|
@ -31,6 +32,7 @@ export function routeSubtitle(pathname: string, t: TFunction): string {
|
|||
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
||||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
|
||||
if (pathname.startsWith("/moderation")) return t("route.moderationSubtitle");
|
||||
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle");
|
||||
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
|
||||
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle");
|
||||
|
|
|
|||
|
|
@ -239,6 +239,85 @@ export type OfficialStarGiftRow = {
|
|||
|
||||
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
|
||||
|
||||
export type ModerationPeer = {
|
||||
Type: "user" | "channel";
|
||||
ID: number;
|
||||
};
|
||||
|
||||
export type ModerationCaseRow = {
|
||||
ID: number;
|
||||
Target: ModerationPeer;
|
||||
Status: string;
|
||||
Severity: number;
|
||||
AssignedTo: string;
|
||||
Version: number;
|
||||
ReportCount: number;
|
||||
DistinctReporterCount: number;
|
||||
FirstReportAt: string;
|
||||
LastReportAt: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationDecision = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
AppealID: number;
|
||||
Kind: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
CommandID: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationAction = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
DecisionID: number;
|
||||
Kind: string;
|
||||
Payload: Record<string, unknown>;
|
||||
Status: string;
|
||||
Attempts: number;
|
||||
LastError: string;
|
||||
CommandID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationAppeal = {
|
||||
ID: number;
|
||||
CaseID: number;
|
||||
AppellantUserID: number;
|
||||
Text: string;
|
||||
Status: string;
|
||||
PreviousCaseStatus: string;
|
||||
Reviewer: string;
|
||||
ReviewReason: string;
|
||||
CreatedAt: string;
|
||||
ReviewedAt: string;
|
||||
};
|
||||
|
||||
export type ModerationCaseDetail = {
|
||||
Case: ModerationCaseRow;
|
||||
ReportIDs: number[];
|
||||
Decisions: ModerationDecision[];
|
||||
Actions: ModerationAction[];
|
||||
Appeals: ModerationAppeal[];
|
||||
};
|
||||
|
||||
export type ModerationReport = {
|
||||
ID: number;
|
||||
ReporterUserID: number;
|
||||
Source: string;
|
||||
Target: ModerationPeer;
|
||||
Reason: string;
|
||||
Option: string;
|
||||
Comment: string;
|
||||
Items: Array<Record<string, unknown>>;
|
||||
MediaHolds: Array<Record<string, unknown>>;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type StarGiftCollectibleAttributeRow = {
|
||||
id: string;
|
||||
kind: "model" | "pattern" | "backdrop";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue