fix(admin): sync moderation page localization
This commit is contained in:
parent
3369d0bd5c
commit
8b866b2bb2
5 changed files with 454 additions and 79 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>
|
||||||
<script type="module" crossorigin src="/assets/index-CkSmQdTc.js"></script>
|
<script type="module" crossorigin src="/assets/index-DRjvnVte.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwxAoLbQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -455,6 +455,105 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"collectibles.validationReady": "Attribute pool is valid",
|
"collectibles.validationReady": "Attribute pool is valid",
|
||||||
"collectibles.validationHint": "Review the normalized assets, then publish this immutable revision.",
|
"collectibles.validationHint": "Review the normalized assets, then publish this immutable revision.",
|
||||||
"collectibles.publish": "Publish revision",
|
"collectibles.publish": "Publish revision",
|
||||||
|
"moderation.casesEyebrow": "Moderation / Cases",
|
||||||
|
"moderation.currentQueue": "Current queue",
|
||||||
|
"moderation.criticalCases": "Critical cases",
|
||||||
|
"moderation.pendingOrFailed": "Pending / failed actions",
|
||||||
|
"moderation.statusFilter": "Case status filter (comma-separated identifiers)",
|
||||||
|
"moderation.assignee": "Reviewer",
|
||||||
|
"moderation.allAssignees": "Leave blank for all",
|
||||||
|
"moderation.case": "Case",
|
||||||
|
"moderation.target": "Target",
|
||||||
|
"moderation.severity": "Severity",
|
||||||
|
"moderation.reportsAndReporters": "Reports / Reporters",
|
||||||
|
"moderation.latestReport": "Latest report",
|
||||||
|
"moderation.review": "Review",
|
||||||
|
"moderation.status.open": "Open",
|
||||||
|
"moderation.status.in_review": "In review",
|
||||||
|
"moderation.status.action_pending": "Action pending",
|
||||||
|
"moderation.status.action_failed": "Action failed",
|
||||||
|
"moderation.status.resolved": "Resolved",
|
||||||
|
"moderation.status.dismissed": "Dismissed",
|
||||||
|
"moderation.status.appeal_review": "Appeal review",
|
||||||
|
"moderation.severity.low": "Low",
|
||||||
|
"moderation.severity.medium": "Medium",
|
||||||
|
"moderation.severity.high": "High",
|
||||||
|
"moderation.severity.critical": "Critical",
|
||||||
|
"moderation.targetType.user": "Account",
|
||||||
|
"moderation.targetType.chat": "Group",
|
||||||
|
"moderation.targetType.channel": "Channel",
|
||||||
|
"moderation.caseDetailTitle": "Review case #{id}",
|
||||||
|
"moderation.caseDetailEyebrow": "Moderation / Case detail",
|
||||||
|
"moderation.backToQueue": "Back to queue",
|
||||||
|
"moderation.loadingCase": "Loading moderation case…",
|
||||||
|
"moderation.versionAndUpdated": "Version {version} · Updated {time}",
|
||||||
|
"moderation.reportCount": "Reports",
|
||||||
|
"moderation.reportCountValue": "{reports} reports from {reporters} reporters",
|
||||||
|
"moderation.firstAndLatestReport": "First / latest report",
|
||||||
|
"moderation.evidence": "Report evidence",
|
||||||
|
"moderation.evidenceHint": "Shows up to the latest 100 reports; snapshots are frozen when reports are admitted.",
|
||||||
|
"moderation.sourceAndReason": "Source / Reason",
|
||||||
|
"moderation.reporter": "Reporter",
|
||||||
|
"moderation.option": "Option",
|
||||||
|
"moderation.decisionAudit": "Decision and action audit",
|
||||||
|
"moderation.decisionAuditHint": "Actions run idempotently through a lease worker; failures retain their error and attempt count.",
|
||||||
|
"moderation.appeals": "Appeals",
|
||||||
|
"moderation.caseActions": "Case actions",
|
||||||
|
"moderation.renewClaim": "Renew claim",
|
||||||
|
"moderation.claimCase": "Claim case",
|
||||||
|
"moderation.reviewReason": "Review reason",
|
||||||
|
"moderation.decisionPreset": "Decision template",
|
||||||
|
"moderation.preset.noViolation": "No violation (dismiss report)",
|
||||||
|
"moderation.preset.scam": "Mark as SCAM",
|
||||||
|
"moderation.preset.fake": "Mark as FAKE",
|
||||||
|
"moderation.preset.freeze": "Freeze account",
|
||||||
|
"moderation.preset.scamFreeze": "SCAM + freeze",
|
||||||
|
"moderation.preset.fakeFreeze": "FAKE + freeze",
|
||||||
|
"moderation.preset.deleteMessages": "Delete messages covered by evidence",
|
||||||
|
"moderation.preset.deleteAccount": "Delete account",
|
||||||
|
"moderation.evidenceMessageIDs": "Evidence message IDs (comma-separated)",
|
||||||
|
"moderation.privateOwnerUserID": "Private-chat owner_user_id",
|
||||||
|
"moderation.revokeForBoth": "Revoke for both sides",
|
||||||
|
"moderation.evidenceValidationHint": "The server will verify again that every message ID exists in this case's immutable report evidence.",
|
||||||
|
"moderation.failedActionHint": "The action was partially executed and cannot be changed directly to no violation. Select a new action to retry while retaining the previous failure audit.",
|
||||||
|
"moderation.retryAction": "Retry action",
|
||||||
|
"moderation.submitDecision": "Submit decision",
|
||||||
|
"moderation.appealReviewTitle": "Appeal review #{id}",
|
||||||
|
"moderation.automaticRemedy": "Automatic remedy after approval",
|
||||||
|
"moderation.irreversibleAppealHint": "The case contains a completed irreversible deletion. It cannot be marked as approved and restored; deny it or escalate for manual handling.",
|
||||||
|
"moderation.denyAppeal": "Deny appeal",
|
||||||
|
"moderation.grantAppeal": "Grant appeal",
|
||||||
|
"moderation.reasonRequired": "A review reason is required.",
|
||||||
|
"moderation.appealReasonRequired": "An appeal review reason is required.",
|
||||||
|
"moderation.privateDeleteValidation": "Private-message deletion requires valid evidence message IDs and the reporter's owner_user_id.",
|
||||||
|
"moderation.channelDeleteValidation": "Channel-message deletion requires at least one valid evidence message ID.",
|
||||||
|
"moderation.confirmDecision": "Submit the “{decision}” decision? The action will run through the durable action queue.",
|
||||||
|
"moderation.confirmGrantAppeal": "Grant this appeal?",
|
||||||
|
"moderation.confirmDenyAppeal": "Deny this appeal?",
|
||||||
|
"moderation.remedy.clearFlags": "Clear SCAM / FAKE",
|
||||||
|
"moderation.remedy.unfreeze": "Unfreeze account",
|
||||||
|
"moderation.remedy.none": "No recovery action needed",
|
||||||
|
"moderation.source.account_peer": "Account / peer",
|
||||||
|
"moderation.source.profile_photo": "Profile photo",
|
||||||
|
"moderation.source.messages_spam": "Message spam",
|
||||||
|
"moderation.source.messages": "Messages",
|
||||||
|
"moderation.source.encrypted_spam": "Encrypted-chat spam",
|
||||||
|
"moderation.source.reaction": "Reaction",
|
||||||
|
"moderation.source.channel_spam": "Channel spam",
|
||||||
|
"moderation.source.story": "Story",
|
||||||
|
"moderation.source.ephemeral": "Ephemeral media",
|
||||||
|
"moderation.source.sponsored": "Sponsored message",
|
||||||
|
"moderation.source.antispam_false_positive": "Anti-spam false positive",
|
||||||
|
"moderation.reason.spam": "Spam",
|
||||||
|
"moderation.reason.violence": "Violence",
|
||||||
|
"moderation.reason.pornography": "Pornography",
|
||||||
|
"moderation.reason.child_abuse": "Child abuse",
|
||||||
|
"moderation.reason.other": "Other",
|
||||||
|
"moderation.reason.copyright": "Copyright",
|
||||||
|
"moderation.reason.geo_irrelevant": "Location-irrelevant",
|
||||||
|
"moderation.reason.fake": "Fake",
|
||||||
|
"moderation.reason.illegal_drugs": "Illegal drugs",
|
||||||
|
"moderation.reason.personal_details": "Personal details",
|
||||||
"messages.msgIDsInvalid": "Message IDs are invalid",
|
"messages.msgIDsInvalid": "Message IDs are invalid",
|
||||||
"auth.device": "Device",
|
"auth.device": "Device",
|
||||||
"auth.platform": "Platform",
|
"auth.platform": "Platform",
|
||||||
|
|
@ -940,6 +1039,105 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"collectibles.validationReady": "属性池校验通过",
|
"collectibles.validationReady": "属性池校验通过",
|
||||||
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
|
"collectibles.validationHint": "确认规范化资源无误后,即可发布这个不可变版本。",
|
||||||
"collectibles.publish": "发布版本",
|
"collectibles.publish": "发布版本",
|
||||||
|
"moderation.casesEyebrow": "内容安全 / 案件",
|
||||||
|
"moderation.currentQueue": "当前队列",
|
||||||
|
"moderation.criticalCases": "关键案件",
|
||||||
|
"moderation.pendingOrFailed": "处置待完成 / 失败",
|
||||||
|
"moderation.statusFilter": "案件状态筛选(英文标识以逗号分隔)",
|
||||||
|
"moderation.assignee": "审核人",
|
||||||
|
"moderation.allAssignees": "留空为全部",
|
||||||
|
"moderation.case": "案件",
|
||||||
|
"moderation.target": "目标",
|
||||||
|
"moderation.severity": "等级",
|
||||||
|
"moderation.reportsAndReporters": "举报 / 举报人",
|
||||||
|
"moderation.latestReport": "最近举报",
|
||||||
|
"moderation.review": "审核",
|
||||||
|
"moderation.status.open": "待审核",
|
||||||
|
"moderation.status.in_review": "审核中",
|
||||||
|
"moderation.status.action_pending": "等待处置",
|
||||||
|
"moderation.status.action_failed": "处置失败",
|
||||||
|
"moderation.status.resolved": "已处置",
|
||||||
|
"moderation.status.dismissed": "已驳回",
|
||||||
|
"moderation.status.appeal_review": "申诉复核中",
|
||||||
|
"moderation.severity.low": "低",
|
||||||
|
"moderation.severity.medium": "中",
|
||||||
|
"moderation.severity.high": "高",
|
||||||
|
"moderation.severity.critical": "关键",
|
||||||
|
"moderation.targetType.user": "账号",
|
||||||
|
"moderation.targetType.chat": "群组",
|
||||||
|
"moderation.targetType.channel": "频道",
|
||||||
|
"moderation.caseDetailTitle": "审核案件 #{id}",
|
||||||
|
"moderation.caseDetailEyebrow": "内容安全 / 案件详情",
|
||||||
|
"moderation.backToQueue": "返回队列",
|
||||||
|
"moderation.loadingCase": "正在加载审核案件…",
|
||||||
|
"moderation.versionAndUpdated": "版本 {version} · 最近更新 {time}",
|
||||||
|
"moderation.reportCount": "举报数",
|
||||||
|
"moderation.reportCountValue": "{reports} 次({reporters} 位举报人)",
|
||||||
|
"moderation.firstAndLatestReport": "首个 / 最近举报",
|
||||||
|
"moderation.evidence": "举报证据",
|
||||||
|
"moderation.evidenceHint": "最多显示最近 100 条;快照在举报受理时冻结。",
|
||||||
|
"moderation.sourceAndReason": "来源 / 原因",
|
||||||
|
"moderation.reporter": "举报人",
|
||||||
|
"moderation.option": "选项",
|
||||||
|
"moderation.decisionAudit": "决定与处置审计",
|
||||||
|
"moderation.decisionAuditHint": "动作由租约 worker 幂等执行;失败保留错误与尝试次数。",
|
||||||
|
"moderation.appeals": "申诉",
|
||||||
|
"moderation.caseActions": "案件操作",
|
||||||
|
"moderation.renewClaim": "续领案件",
|
||||||
|
"moderation.claimCase": "领取案件",
|
||||||
|
"moderation.reviewReason": "审核理由",
|
||||||
|
"moderation.decisionPreset": "决定模板",
|
||||||
|
"moderation.preset.noViolation": "无违规(驳回举报)",
|
||||||
|
"moderation.preset.scam": "标记 SCAM",
|
||||||
|
"moderation.preset.fake": "标记 FAKE",
|
||||||
|
"moderation.preset.freeze": "冻结账号",
|
||||||
|
"moderation.preset.scamFreeze": "SCAM + 冻结",
|
||||||
|
"moderation.preset.fakeFreeze": "FAKE + 冻结",
|
||||||
|
"moderation.preset.deleteMessages": "删除证据覆盖的消息",
|
||||||
|
"moderation.preset.deleteAccount": "删除账号",
|
||||||
|
"moderation.evidenceMessageIDs": "证据消息 ID(逗号分隔)",
|
||||||
|
"moderation.privateOwnerUserID": "私聊 owner_user_id",
|
||||||
|
"moderation.revokeForBoth": "双方撤回",
|
||||||
|
"moderation.evidenceValidationHint": "服务端会再次校验每个消息 ID 必须存在于该案件的不可变举报证据中。",
|
||||||
|
"moderation.failedActionHint": "处置已部分执行,不能直接改为无违规;请选择新的处置动作重新执行并保留旧失败审计。",
|
||||||
|
"moderation.retryAction": "重新执行处置",
|
||||||
|
"moderation.submitDecision": "提交决定",
|
||||||
|
"moderation.appealReviewTitle": "申诉复核 #{id}",
|
||||||
|
"moderation.automaticRemedy": "通过后自动恢复",
|
||||||
|
"moderation.irreversibleAppealHint": "案件包含已成功的不可逆删除动作,不能标记为“申诉通过并已恢复”;请驳回或升级人工处理。",
|
||||||
|
"moderation.denyAppeal": "驳回申诉",
|
||||||
|
"moderation.grantAppeal": "通过申诉",
|
||||||
|
"moderation.reasonRequired": "必须填写审核理由。",
|
||||||
|
"moderation.appealReasonRequired": "必须填写申诉复核理由。",
|
||||||
|
"moderation.privateDeleteValidation": "私聊删除需要合法的证据消息 ID 和举报人 owner_user_id。",
|
||||||
|
"moderation.channelDeleteValidation": "频道删除需要至少一个合法的证据消息 ID。",
|
||||||
|
"moderation.confirmDecision": "确认提交“{decision}”决定?处置会通过 durable action 队列执行。",
|
||||||
|
"moderation.confirmGrantAppeal": "确认通过申诉?",
|
||||||
|
"moderation.confirmDenyAppeal": "确认驳回申诉?",
|
||||||
|
"moderation.remedy.clearFlags": "清除 SCAM / FAKE",
|
||||||
|
"moderation.remedy.unfreeze": "解除冻结",
|
||||||
|
"moderation.remedy.none": "无需恢复动作",
|
||||||
|
"moderation.source.account_peer": "账号 / Peer",
|
||||||
|
"moderation.source.profile_photo": "资料照片",
|
||||||
|
"moderation.source.messages_spam": "消息垃圾内容",
|
||||||
|
"moderation.source.messages": "消息",
|
||||||
|
"moderation.source.encrypted_spam": "加密聊天垃圾内容",
|
||||||
|
"moderation.source.reaction": "回应",
|
||||||
|
"moderation.source.channel_spam": "频道垃圾内容",
|
||||||
|
"moderation.source.story": "Story",
|
||||||
|
"moderation.source.ephemeral": "阅后即焚媒体",
|
||||||
|
"moderation.source.sponsored": "赞助消息",
|
||||||
|
"moderation.source.antispam_false_positive": "反垃圾误判",
|
||||||
|
"moderation.reason.spam": "垃圾内容",
|
||||||
|
"moderation.reason.violence": "暴力",
|
||||||
|
"moderation.reason.pornography": "色情内容",
|
||||||
|
"moderation.reason.child_abuse": "儿童虐待",
|
||||||
|
"moderation.reason.other": "其他",
|
||||||
|
"moderation.reason.copyright": "版权",
|
||||||
|
"moderation.reason.geo_irrelevant": "与地区无关",
|
||||||
|
"moderation.reason.fake": "虚假信息",
|
||||||
|
"moderation.reason.illegal_drugs": "非法药物",
|
||||||
|
"moderation.reason.personal_details": "个人信息",
|
||||||
"messages.msgIDsInvalid": "消息 ID 无效",
|
"messages.msgIDsInvalid": "消息 ID 无效",
|
||||||
"auth.device": "设备",
|
"auth.device": "设备",
|
||||||
"auth.platform": "平台",
|
"auth.platform": "平台",
|
||||||
|
|
@ -1425,6 +1623,105 @@ const translations: Record<Language, Record<string, string>> = {
|
||||||
"collectibles.validationReady": "Пул атрибутов корректен",
|
"collectibles.validationReady": "Пул атрибутов корректен",
|
||||||
"collectibles.validationHint": "Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.",
|
"collectibles.validationHint": "Проверьте нормализованные ресурсы и опубликуйте эту неизменяемую версию.",
|
||||||
"collectibles.publish": "Опубликовать версию",
|
"collectibles.publish": "Опубликовать версию",
|
||||||
|
"moderation.casesEyebrow": "Модерация / Обращения",
|
||||||
|
"moderation.currentQueue": "Текущая очередь",
|
||||||
|
"moderation.criticalCases": "Критические обращения",
|
||||||
|
"moderation.pendingOrFailed": "Ожидающие / неудачные действия",
|
||||||
|
"moderation.statusFilter": "Фильтр по статусу (идентификаторы через запятую)",
|
||||||
|
"moderation.assignee": "Модератор",
|
||||||
|
"moderation.allAssignees": "Оставьте пустым для всех",
|
||||||
|
"moderation.case": "Обращение",
|
||||||
|
"moderation.target": "Объект",
|
||||||
|
"moderation.severity": "Важность",
|
||||||
|
"moderation.reportsAndReporters": "Жалобы / Авторы",
|
||||||
|
"moderation.latestReport": "Последняя жалоба",
|
||||||
|
"moderation.review": "Проверить",
|
||||||
|
"moderation.status.open": "Открыто",
|
||||||
|
"moderation.status.in_review": "На рассмотрении",
|
||||||
|
"moderation.status.action_pending": "Ожидает действия",
|
||||||
|
"moderation.status.action_failed": "Ошибка действия",
|
||||||
|
"moderation.status.resolved": "Решено",
|
||||||
|
"moderation.status.dismissed": "Отклонено",
|
||||||
|
"moderation.status.appeal_review": "Рассмотрение апелляции",
|
||||||
|
"moderation.severity.low": "Низкая",
|
||||||
|
"moderation.severity.medium": "Средняя",
|
||||||
|
"moderation.severity.high": "Высокая",
|
||||||
|
"moderation.severity.critical": "Критическая",
|
||||||
|
"moderation.targetType.user": "Аккаунт",
|
||||||
|
"moderation.targetType.chat": "Группа",
|
||||||
|
"moderation.targetType.channel": "Канал",
|
||||||
|
"moderation.caseDetailTitle": "Проверка обращения #{id}",
|
||||||
|
"moderation.caseDetailEyebrow": "Модерация / Детали обращения",
|
||||||
|
"moderation.backToQueue": "Назад к очереди",
|
||||||
|
"moderation.loadingCase": "Загрузка обращения…",
|
||||||
|
"moderation.versionAndUpdated": "Версия {version} · Обновлено {time}",
|
||||||
|
"moderation.reportCount": "Жалобы",
|
||||||
|
"moderation.reportCountValue": "Жалоб: {reports}; авторов: {reporters}",
|
||||||
|
"moderation.firstAndLatestReport": "Первая / последняя жалоба",
|
||||||
|
"moderation.evidence": "Материалы жалобы",
|
||||||
|
"moderation.evidenceHint": "Показываются последние 100 жалоб; снимки фиксируются при приёме жалобы.",
|
||||||
|
"moderation.sourceAndReason": "Источник / Причина",
|
||||||
|
"moderation.reporter": "Автор жалобы",
|
||||||
|
"moderation.option": "Вариант",
|
||||||
|
"moderation.decisionAudit": "Аудит решений и действий",
|
||||||
|
"moderation.decisionAuditHint": "Действия выполняются идемпотентно арендующим worker-процессом; при сбое сохраняются ошибка и число попыток.",
|
||||||
|
"moderation.appeals": "Апелляции",
|
||||||
|
"moderation.caseActions": "Действия с обращением",
|
||||||
|
"moderation.renewClaim": "Продлить назначение",
|
||||||
|
"moderation.claimCase": "Взять на проверку",
|
||||||
|
"moderation.reviewReason": "Причина решения",
|
||||||
|
"moderation.decisionPreset": "Шаблон решения",
|
||||||
|
"moderation.preset.noViolation": "Нет нарушения (отклонить жалобу)",
|
||||||
|
"moderation.preset.scam": "Пометить как SCAM",
|
||||||
|
"moderation.preset.fake": "Пометить как FAKE",
|
||||||
|
"moderation.preset.freeze": "Заморозить аккаунт",
|
||||||
|
"moderation.preset.scamFreeze": "SCAM + заморозка",
|
||||||
|
"moderation.preset.fakeFreeze": "FAKE + заморозка",
|
||||||
|
"moderation.preset.deleteMessages": "Удалить сообщения из материалов",
|
||||||
|
"moderation.preset.deleteAccount": "Удалить аккаунт",
|
||||||
|
"moderation.evidenceMessageIDs": "ID сообщений из материалов (через запятую)",
|
||||||
|
"moderation.privateOwnerUserID": "owner_user_id личного чата",
|
||||||
|
"moderation.revokeForBoth": "Удалить у обеих сторон",
|
||||||
|
"moderation.evidenceValidationHint": "Сервер повторно проверит, что каждый ID сообщения присутствует в неизменяемых материалах этого обращения.",
|
||||||
|
"moderation.failedActionHint": "Действие выполнено частично, поэтому решение нельзя сразу сменить на отсутствие нарушения. Выберите новое действие для повтора; аудит предыдущего сбоя сохранится.",
|
||||||
|
"moderation.retryAction": "Повторить действие",
|
||||||
|
"moderation.submitDecision": "Отправить решение",
|
||||||
|
"moderation.appealReviewTitle": "Рассмотрение апелляции #{id}",
|
||||||
|
"moderation.automaticRemedy": "Автовосстановление после одобрения",
|
||||||
|
"moderation.irreversibleAppealHint": "Обращение содержит завершённое необратимое удаление. Его нельзя отметить как одобренное и восстановленное; отклоните или передайте на ручную обработку.",
|
||||||
|
"moderation.denyAppeal": "Отклонить апелляцию",
|
||||||
|
"moderation.grantAppeal": "Одобрить апелляцию",
|
||||||
|
"moderation.reasonRequired": "Укажите причину решения.",
|
||||||
|
"moderation.appealReasonRequired": "Укажите причину рассмотрения апелляции.",
|
||||||
|
"moderation.privateDeleteValidation": "Для удаления личных сообщений нужны корректные ID сообщений из материалов и owner_user_id автора жалобы.",
|
||||||
|
"moderation.channelDeleteValidation": "Для удаления сообщений канала нужен хотя бы один корректный ID сообщения из материалов.",
|
||||||
|
"moderation.confirmDecision": "Отправить решение «{decision}»? Действие будет выполнено через устойчивую очередь.",
|
||||||
|
"moderation.confirmGrantAppeal": "Одобрить эту апелляцию?",
|
||||||
|
"moderation.confirmDenyAppeal": "Отклонить эту апелляцию?",
|
||||||
|
"moderation.remedy.clearFlags": "Снять SCAM / FAKE",
|
||||||
|
"moderation.remedy.unfreeze": "Разморозить аккаунт",
|
||||||
|
"moderation.remedy.none": "Восстановление не требуется",
|
||||||
|
"moderation.source.account_peer": "Аккаунт / пир",
|
||||||
|
"moderation.source.profile_photo": "Фото профиля",
|
||||||
|
"moderation.source.messages_spam": "Спам в сообщениях",
|
||||||
|
"moderation.source.messages": "Сообщения",
|
||||||
|
"moderation.source.encrypted_spam": "Спам в секретном чате",
|
||||||
|
"moderation.source.reaction": "Реакция",
|
||||||
|
"moderation.source.channel_spam": "Спам в канале",
|
||||||
|
"moderation.source.story": "История",
|
||||||
|
"moderation.source.ephemeral": "Исчезающее медиа",
|
||||||
|
"moderation.source.sponsored": "Рекламное сообщение",
|
||||||
|
"moderation.source.antispam_false_positive": "Ложное срабатывание антиспама",
|
||||||
|
"moderation.reason.spam": "Спам",
|
||||||
|
"moderation.reason.violence": "Насилие",
|
||||||
|
"moderation.reason.pornography": "Порнография",
|
||||||
|
"moderation.reason.child_abuse": "Жестокое обращение с детьми",
|
||||||
|
"moderation.reason.other": "Другое",
|
||||||
|
"moderation.reason.copyright": "Авторские права",
|
||||||
|
"moderation.reason.geo_irrelevant": "Не относится к региону",
|
||||||
|
"moderation.reason.fake": "Подделка",
|
||||||
|
"moderation.reason.illegal_drugs": "Незаконные наркотики",
|
||||||
|
"moderation.reason.personal_details": "Персональные данные",
|
||||||
"messages.msgIDsInvalid": "Некорректные ID сообщений",
|
"messages.msgIDsInvalid": "Некорректные ID сообщений",
|
||||||
"auth.device": "Устройство",
|
"auth.device": "Устройство",
|
||||||
"auth.platform": "Платформа",
|
"auth.platform": "Платформа",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
|
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { Alert, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
import { Alert, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||||
|
import { useI18n, type TFunction } from "../i18n";
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { ModerationCaseDetail, ModerationReport } from "../types";
|
import type { ModerationCaseDetail, ModerationReport } from "../types";
|
||||||
import { CaseStatus } from "./ModerationCasesPage";
|
import {
|
||||||
|
CaseSeverity,
|
||||||
|
CaseStatus,
|
||||||
|
moderationEnumLabel,
|
||||||
|
moderationTargetLabel
|
||||||
|
} from "./ModerationCasesPage";
|
||||||
|
|
||||||
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
|
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
|
||||||
|
|
||||||
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
|
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
|
||||||
const [report, setReport] = useState<ModerationReport | null>(null);
|
const [report, setReport] = useState<ModerationReport | null>(null);
|
||||||
const [reason, setReason] = useState("");
|
const [reason, setReason] = useState("");
|
||||||
|
|
@ -58,8 +65,8 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
|
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
|
||||||
);
|
);
|
||||||
const appealRemedy = useMemo(
|
const appealRemedy = useMemo(
|
||||||
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "无", blocked: false },
|
() => detail ? requiredAppealRemedy(detail, t) : { actions: [], label: t("common.none"), blocked: false },
|
||||||
[detail]
|
[detail, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
async function claim() {
|
async function claim() {
|
||||||
|
|
@ -78,16 +85,16 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
|
|
||||||
async function decide() {
|
async function decide() {
|
||||||
if (!detail || !reason.trim()) {
|
if (!detail || !reason.trim()) {
|
||||||
setError("必须填写审核理由。");
|
setError(t("moderation.reasonRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (preset === "delete_messages" && selectedActions.length === 0) {
|
if (preset === "delete_messages" && selectedActions.length === 0) {
|
||||||
setError(detail.Case.Target.Type === "user"
|
setError(detail.Case.Target.Type === "user"
|
||||||
? "私聊删除需要合法的证据消息 ID 和举报人 owner_user_id。"
|
? t("moderation.privateDeleteValidation")
|
||||||
: "频道删除需要至少一个合法的证据消息 ID。");
|
: t("moderation.channelDeleteValidation"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(`确认提交 ${preset} 决定?处置会通过 durable action 队列执行。`)) return;
|
if (!window.confirm(t("moderation.confirmDecision", { decision: decisionPresetLabel(t, preset) }))) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
|
@ -108,10 +115,10 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
|
|
||||||
async function reviewAppeal(appealID: number, granted: boolean) {
|
async function reviewAppeal(appealID: number, granted: boolean) {
|
||||||
if (!detail || !reason.trim()) {
|
if (!detail || !reason.trim()) {
|
||||||
setError("必须填写申诉复核理由。");
|
setError(t("moderation.appealReasonRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!window.confirm(granted ? "确认通过申诉?" : "确认驳回申诉?")) return;
|
if (!window.confirm(granted ? t("moderation.confirmGrantAppeal") : t("moderation.confirmDenyAppeal"))) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.reviewModerationAppeal(id, appealID, {
|
const result = await api.reviewModerationAppeal(id, appealID, {
|
||||||
|
|
@ -130,7 +137,7 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !detail) return <Alert>{error}</Alert>;
|
if (error && !detail) return <Alert>{error}</Alert>;
|
||||||
if (!detail) return <LoadingSurface label="正在加载审核案件…" />;
|
if (!detail) return <LoadingSurface label={t("moderation.loadingCase")} />;
|
||||||
const item = detail.Case;
|
const item = detail.Case;
|
||||||
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
|
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 canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
|
||||||
|
|
@ -139,12 +146,16 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title={`审核案件 #${item.ID}`}
|
title={t("moderation.caseDetailTitle", { id: item.ID })}
|
||||||
eyebrow="Moderation / Case detail"
|
eyebrow={t("moderation.caseDetailEyebrow")}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<button className="btn icon-text" onClick={() => navigate("/moderation")}><ArrowLeft size={15} /> 返回队列</button>
|
<button className="btn icon-text" onClick={() => navigate("/moderation")}>
|
||||||
<button className="btn icon-text" onClick={load}><RefreshCw size={15} /> 刷新</button>
|
<ArrowLeft size={15} /> {t("moderation.backToQueue")}
|
||||||
|
</button>
|
||||||
|
<button className="btn icon-text" onClick={load}>
|
||||||
|
<RefreshCw size={15} /> {t("common.refresh")}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -154,22 +165,33 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<section className="entity-head">
|
<section className="entity-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="entity-title">{item.Target.Type}:{item.Target.ID}</div>
|
<div className="entity-title">{moderationTargetLabel(t, item.Target.Type, item.Target.ID)}</div>
|
||||||
<div className="entity-subtitle">版本 {item.Version} · 最近更新 {formatDate(item.UpdatedAt)}</div>
|
<div className="entity-subtitle">
|
||||||
|
{t("moderation.versionAndUpdated", { version: item.Version, time: formatDate(item.UpdatedAt) })}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="entity-badges">
|
<div className="entity-badges">
|
||||||
<CaseStatus status={item.Status} />
|
<CaseStatus status={item.Status} />
|
||||||
<Badge tone={item.Severity >= 4 ? "danger" : item.Severity >= 3 ? "warn" : "neutral"}>severity {item.Severity}</Badge>
|
<CaseSeverity value={item.Severity} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label="目标" value={`${item.Target.Type}:${item.Target.ID}`} mono />
|
<Summary label={t("moderation.target")} value={moderationTargetLabel(t, item.Target.Type, item.Target.ID)} mono />
|
||||||
<Summary label="举报数" value={`${item.ReportCount}(${item.DistinctReporterCount} 位举报人)`} />
|
<Summary
|
||||||
<Summary label="审核人" value={item.AssignedTo || "-"} />
|
label={t("moderation.reportCount")}
|
||||||
<Summary label="首个 / 最近举报" value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`} />
|
value={t("moderation.reportCountValue", {
|
||||||
|
reports: item.ReportCount,
|
||||||
|
reporters: item.DistinctReporterCount
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<Summary label={t("moderation.assignee")} value={item.AssignedTo || "-"} />
|
||||||
|
<Summary
|
||||||
|
label={t("moderation.firstAndLatestReport")}
|
||||||
|
value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<SectionHead title="举报证据" text="最多显示最近 100 条;快照在举报受理时冻结。" />
|
<SectionHead title={t("moderation.evidence")} text={t("moderation.evidenceHint")} />
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{detail.ReportIDs.map((reportID) => (
|
{detail.ReportIDs.map((reportID) => (
|
||||||
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
|
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
|
||||||
|
|
@ -180,10 +202,13 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
{report && (
|
{report && (
|
||||||
<>
|
<>
|
||||||
<div className="summary-grid">
|
<div className="summary-grid">
|
||||||
<Summary label="来源 / 原因" value={`${report.Source} / ${report.Reason}`} />
|
<Summary
|
||||||
<Summary label="举报人" value={String(report.ReporterUserID)} mono />
|
label={t("moderation.sourceAndReason")}
|
||||||
<Summary label="选项" value={report.Option} mono />
|
value={`${moderationEnumLabel(t, "source", report.Source)} / ${moderationEnumLabel(t, "reason", report.Reason)}`}
|
||||||
<Summary label="时间" value={formatDate(report.CreatedAt)} />
|
/>
|
||||||
|
<Summary label={t("moderation.reporter")} value={String(report.ReporterUserID)} mono />
|
||||||
|
<Summary label={t("moderation.option")} value={report.Option} mono />
|
||||||
|
<Summary label={t("common.time")} value={formatDate(report.CreatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
{report.Comment && <p className="about-text">{report.Comment}</p>}
|
||||||
<JsonBlock value={JSON.stringify(report, null, 2)} />
|
<JsonBlock value={JSON.stringify(report, null, 2)} />
|
||||||
|
|
@ -191,12 +216,12 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<SectionHead title="决定与处置审计" text="动作由租约 worker 幂等执行;失败保留错误与尝试次数。" />
|
<SectionHead title={t("moderation.decisionAudit")} text={t("moderation.decisionAuditHint")} />
|
||||||
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
|
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
|
||||||
</section>
|
</section>
|
||||||
{detail.Appeals.length > 0 && (
|
{detail.Appeals.length > 0 && (
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<SectionHead title="申诉" />
|
<SectionHead title={t("moderation.appeals")} />
|
||||||
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
|
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
@ -204,67 +229,73 @@ export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigat
|
||||||
}
|
}
|
||||||
side={
|
side={
|
||||||
<section className="action-dock">
|
<section className="action-dock">
|
||||||
<div className="dock-title">案件操作</div>
|
<div className="dock-title">{t("moderation.caseActions")}</div>
|
||||||
{canClaim && (
|
{canClaim && (
|
||||||
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
|
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
|
||||||
<ShieldCheck size={15} /> {item.AssignedTo ? "续领案件" : "领取案件"}
|
<ShieldCheck size={15} /> {item.AssignedTo ? t("moderation.renewClaim") : t("moderation.claimCase")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>审核理由</span>
|
<span>{t("moderation.reviewReason")}</span>
|
||||||
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
|
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>决定模板</span>
|
<span>{t("moderation.decisionPreset")}</span>
|
||||||
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
|
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
|
||||||
<option value="no_violation">无违规(驳回举报)</option>
|
<option value="no_violation">{t("moderation.preset.noViolation")}</option>
|
||||||
<option value="scam">标记 SCAM</option>
|
<option value="scam">{t("moderation.preset.scam")}</option>
|
||||||
<option value="fake">标记 FAKE</option>
|
<option value="fake">{t("moderation.preset.fake")}</option>
|
||||||
<option value="freeze">冻结账号</option>
|
<option value="freeze">{t("moderation.preset.freeze")}</option>
|
||||||
<option value="scam_freeze">SCAM + 冻结</option>
|
<option value="scam_freeze">{t("moderation.preset.scamFreeze")}</option>
|
||||||
<option value="fake_freeze">FAKE + 冻结</option>
|
<option value="fake_freeze">{t("moderation.preset.fakeFreeze")}</option>
|
||||||
<option value="delete_messages">删除证据覆盖的消息</option>
|
<option value="delete_messages">{t("moderation.preset.deleteMessages")}</option>
|
||||||
<option value="delete_account">删除账号</option>
|
<option value="delete_account">{t("moderation.preset.deleteAccount")}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{preset === "delete_messages" && (
|
{preset === "delete_messages" && (
|
||||||
<>
|
<>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>证据消息 ID(逗号分隔)</span>
|
<span>{t("moderation.evidenceMessageIDs")}</span>
|
||||||
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
|
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
|
||||||
</label>
|
</label>
|
||||||
{item.Target.Type === "user" && (
|
{item.Target.Type === "user" && (
|
||||||
<>
|
<>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>私聊 owner_user_id</span>
|
<span>{t("moderation.privateOwnerUserID")}</span>
|
||||||
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
|
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
|
||||||
</label>
|
</label>
|
||||||
<label className="field checkbox-field">
|
<label className="field checkbox-field">
|
||||||
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
|
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
|
||||||
<span>双方撤回</span>
|
<span>{t("moderation.revokeForBoth")}</span>
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Alert>服务端会再次校验每个消息 ID 必须存在于该案件的不可变举报证据中。</Alert>
|
<Alert>{t("moderation.evidenceValidationHint")}</Alert>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{item.Status === "action_failed" && preset === "no_violation" && (
|
{item.Status === "action_failed" && preset === "no_violation" && (
|
||||||
<Alert>处置已部分执行,不能直接改为无违规;请选择新的处置动作重新执行并保留旧失败审计。</Alert>
|
<Alert>{t("moderation.failedActionHint")}</Alert>
|
||||||
)}
|
)}
|
||||||
{canDecide && (
|
{canDecide && (
|
||||||
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
|
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
|
||||||
<CheckCircle2 size={15} /> {item.Status === "action_failed" ? "重新执行处置" : "提交决定"}
|
<CheckCircle2 size={15} /> {item.Status === "action_failed"
|
||||||
|
? t("moderation.retryAction")
|
||||||
|
: t("moderation.submitDecision")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{pendingAppeal && item.AssignedTo && (
|
{pendingAppeal && item.AssignedTo && (
|
||||||
<>
|
<>
|
||||||
<div className="dock-title">申诉复核 #{pendingAppeal.ID}</div>
|
<div className="dock-title">{t("moderation.appealReviewTitle", { id: pendingAppeal.ID })}</div>
|
||||||
<Summary label="通过后自动恢复" value={appealRemedy.label} />
|
<Summary label={t("moderation.automaticRemedy")} value={appealRemedy.label} />
|
||||||
{appealRemedy.blocked && (
|
{appealRemedy.blocked && (
|
||||||
<Alert>案件包含已成功的不可逆删除动作,不能标记为“申诉通过并已恢复”;请驳回或升级人工处理。</Alert>
|
<Alert>{t("moderation.irreversibleAppealHint")}</Alert>
|
||||||
)}
|
)}
|
||||||
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>驳回申诉</button>
|
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>
|
||||||
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>通过申诉</button>
|
{t("moderation.denyAppeal")}
|
||||||
|
</button>
|
||||||
|
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>
|
||||||
|
{t("moderation.grantAppeal")}
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -317,7 +348,7 @@ function parseMessageIDs(raw: string): number[] {
|
||||||
return [...new Set(values)];
|
return [...new Set(values)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiredAppealRemedy(detail: ModerationCaseDetail): {
|
function requiredAppealRemedy(detail: ModerationCaseDetail, t: TFunction): {
|
||||||
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
|
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
|
||||||
label: string;
|
label: string;
|
||||||
blocked: boolean;
|
blocked: boolean;
|
||||||
|
|
@ -352,11 +383,25 @@ function requiredAppealRemedy(detail: ModerationCaseDetail): {
|
||||||
const labels: string[] = [];
|
const labels: string[] = [];
|
||||||
if (flagsActive) {
|
if (flagsActive) {
|
||||||
actions.push({ kind: "clear_peer_flags", payload: {} });
|
actions.push({ kind: "clear_peer_flags", payload: {} });
|
||||||
labels.push("清除 SCAM / FAKE");
|
labels.push(t("moderation.remedy.clearFlags"));
|
||||||
}
|
}
|
||||||
if (freezeActive) {
|
if (freezeActive) {
|
||||||
actions.push({ kind: "unfreeze_account", payload: {} });
|
actions.push({ kind: "unfreeze_account", payload: {} });
|
||||||
labels.push("解除冻结");
|
labels.push(t("moderation.remedy.unfreeze"));
|
||||||
}
|
}
|
||||||
return { actions, label: labels.join(" + ") || "无需恢复动作", blocked: irreversible };
|
return { actions, label: labels.join(" + ") || t("moderation.remedy.none"), blocked: irreversible };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionPresetLabel(t: TFunction, preset: DecisionPreset): string {
|
||||||
|
const keys: Record<DecisionPreset, string> = {
|
||||||
|
no_violation: "noViolation",
|
||||||
|
scam: "scam",
|
||||||
|
fake: "fake",
|
||||||
|
freeze: "freeze",
|
||||||
|
scam_freeze: "scamFreeze",
|
||||||
|
fake_freeze: "fakeFreeze",
|
||||||
|
delete_messages: "deleteMessages",
|
||||||
|
delete_account: "deleteAccount"
|
||||||
|
};
|
||||||
|
return t(`moderation.preset.${keys[preset]}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
|
import { useI18n, type TFunction } from "../i18n";
|
||||||
import { formatDate } from "../lib/format";
|
import { formatDate } from "../lib/format";
|
||||||
import type { Navigate } from "../routing";
|
import type { Navigate } from "../routing";
|
||||||
import type { ModerationCaseRow } from "../types";
|
import type { ModerationCaseRow } from "../types";
|
||||||
|
|
@ -9,6 +10,7 @@ import type { ModerationCaseRow } from "../types";
|
||||||
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
|
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
|
||||||
|
|
||||||
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [statuses, setStatuses] = useState(defaultStatuses);
|
const [statuses, setStatuses] = useState(defaultStatuses);
|
||||||
const [assignedTo, setAssignedTo] = useState("");
|
const [assignedTo, setAssignedTo] = useState("");
|
||||||
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
|
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
|
||||||
|
|
@ -38,32 +40,40 @@ export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageFrame
|
<PageFrame
|
||||||
title="举报与审核"
|
title={t("route.moderation")}
|
||||||
eyebrow="Moderation / Cases"
|
eyebrow={t("moderation.casesEyebrow")}
|
||||||
actions={
|
actions={
|
||||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> 刷新
|
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<div className="metric-row">
|
<div className="metric-row">
|
||||||
<Metric label="当前队列" value={String(rows.length)} />
|
<Metric label={t("moderation.currentQueue")} value={String(rows.length)} />
|
||||||
<Metric label="关键案件" value={String(critical)} tone={critical ? "danger" : "neutral"} />
|
<Metric label={t("moderation.criticalCases")} value={String(critical)} tone={critical ? "danger" : "neutral"} />
|
||||||
<Metric label="处置待完成/失败" value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
|
<Metric label={t("moderation.pendingOrFailed")} value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
|
||||||
</div>
|
</div>
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
|
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
|
||||||
<label className="field-inline">
|
<label className="field-inline">
|
||||||
<span>状态</span>
|
<span>{t("common.status")}</span>
|
||||||
<input value={statuses} onChange={(event) => setStatuses(event.target.value)} />
|
<input
|
||||||
|
aria-label={t("moderation.statusFilter")}
|
||||||
|
value={statuses}
|
||||||
|
onChange={(event) => setStatuses(event.target.value)}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="field-inline">
|
<label className="field-inline">
|
||||||
<span>审核人</span>
|
<span>{t("moderation.assignee")}</span>
|
||||||
<input value={assignedTo} onChange={(event) => setAssignedTo(event.target.value)} placeholder="留空为全部" />
|
<input
|
||||||
|
value={assignedTo}
|
||||||
|
onChange={(event) => setAssignedTo(event.target.value)}
|
||||||
|
placeholder={t("moderation.allAssignees")}
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||||
<ShieldAlert size={15} /> 查询
|
<ShieldAlert size={15} /> {t("common.search")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</QueryPanel>
|
</QueryPanel>
|
||||||
|
|
@ -71,23 +81,29 @@ export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>案件</th><th>目标</th><th>状态</th><th>等级</th>
|
<th>{t("moderation.case")}</th>
|
||||||
<th>举报 / 举报人</th><th>审核人</th><th>最近举报</th><th></th>
|
<th>{t("moderation.target")}</th>
|
||||||
|
<th>{t("common.status")}</th>
|
||||||
|
<th>{t("moderation.severity")}</th>
|
||||||
|
<th>{t("moderation.reportsAndReporters")}</th>
|
||||||
|
<th>{t("moderation.assignee")}</th>
|
||||||
|
<th>{t("moderation.latestReport")}</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<tr key={row.ID}>
|
<tr key={row.ID}>
|
||||||
<td className="mono">#{row.ID}</td>
|
<td className="mono">#{row.ID}</td>
|
||||||
<td className="mono">{row.Target.Type}:{row.Target.ID}</td>
|
<td className="mono">{moderationTargetLabel(t, row.Target.Type, row.Target.ID)}</td>
|
||||||
<td><CaseStatus status={row.Status} /></td>
|
<td><CaseStatus status={row.Status} /></td>
|
||||||
<td><Severity value={row.Severity} /></td>
|
<td><CaseSeverity value={row.Severity} /></td>
|
||||||
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
|
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
|
||||||
<td>{row.AssignedTo || "-"}</td>
|
<td>{row.AssignedTo || "-"}</td>
|
||||||
<td>{formatDate(row.LastReportAt)}</td>
|
<td>{formatDate(row.LastReportAt)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
|
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
|
||||||
审核 <ChevronRight size={14} />
|
{t("moderation.review")} <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -101,6 +117,7 @@ export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CaseStatus({ status }: { status: string }) {
|
export function CaseStatus({ status }: { status: string }) {
|
||||||
|
const { t } = useI18n();
|
||||||
const tone = status === "resolved" || status === "dismissed"
|
const tone = status === "resolved" || status === "dismissed"
|
||||||
? "good"
|
? "good"
|
||||||
: status === "action_failed"
|
: status === "action_failed"
|
||||||
|
|
@ -108,10 +125,26 @@ export function CaseStatus({ status }: { status: string }) {
|
||||||
: status === "action_pending"
|
: status === "action_pending"
|
||||||
? "warn"
|
? "warn"
|
||||||
: "neutral";
|
: "neutral";
|
||||||
return <Badge tone={tone}>{status}</Badge>;
|
return <Badge tone={tone}>{moderationEnumLabel(t, "status", status)}</Badge>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Severity({ value }: { value: number }) {
|
export function CaseSeverity({ value }: { value: number }) {
|
||||||
const labels = ["", "低", "中", "高", "关键"];
|
const { t } = useI18n();
|
||||||
return <Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>{labels[value] || value}</Badge>;
|
const keys = ["", "low", "medium", "high", "critical"];
|
||||||
|
const key = keys[value];
|
||||||
|
return (
|
||||||
|
<Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>
|
||||||
|
{key ? t(`moderation.severity.${key}`) : value}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moderationEnumLabel(t: TFunction, group: string, value: string): string {
|
||||||
|
const key = `moderation.${group}.${value}`;
|
||||||
|
const translated = t(key);
|
||||||
|
return translated === key ? value : translated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moderationTargetLabel(t: TFunction, type: string, id: number): string {
|
||||||
|
return `${moderationEnumLabel(t, "targetType", type)} #${id}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue