feat(moderation): add AI moderation for user-generated content

Add AI moderation settings, caching, and status tracking
Require AI approval for Life Posts, Comments, and Discussions
Implement language filtering and moderation status UI
Add retry mechanism for failed moderation checks
This commit is contained in:
2026-05-03 17:08:51 +08:00
parent 590bd6a0ae
commit 18baf7b513
12 changed files with 2217 additions and 102 deletions

View File

@@ -2,15 +2,19 @@
import { Icon } from '@iconify/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { iconCancel, iconComment, iconDelete, iconReply } from '../icons';
import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons';
import {
api,
getAuthToken,
onAuthTokenChange,
setAuthToken,
type AiModerationStatus,
type AuthUser,
type DiscussionEntityType,
type EntityDiscussionComment
type EntityDiscussionComment,
type Language
} from '../services/api';
import Skeleton from './Skeleton.vue';
@@ -21,6 +25,7 @@ const props = defineProps<{
const { locale, t } = useI18n();
const comments = ref<EntityDiscussionComment[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
@@ -33,8 +38,11 @@ const loadError = ref('');
const formError = ref('');
const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null);
const activeLanguageCode = ref('all');
const moderationBusyId = ref<number | null>(null);
const commentMaxLength = 1000;
const discussionPageSize = 20;
const allLanguageValue = 'all';
let requestId = 0;
let removeAuthListener: (() => void) | null = null;
const nextCursor = ref<string | null>(null);
@@ -47,6 +55,11 @@ function can(permissionKey: string) {
const canComment = computed(() => can('discussions.comments.create'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
async function loadCurrentUser() {
authReady.value = false;
@@ -68,6 +81,20 @@ async function loadCurrentUser() {
}
}
async function loadLanguages() {
try {
languages.value = (await api.languages()).filter((language) => language.enabled);
if (
activeLanguageCode.value !== allLanguageValue &&
!languages.value.some((language) => language.code === activeLanguageCode.value)
) {
activeLanguageCode.value = allLanguageValue;
}
} catch {
languages.value = [];
}
}
function mergeComments(existing: EntityDiscussionComment[], incoming: EntityDiscussionComment[]) {
const ids = new Set(existing.map((comment) => comment.id));
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
@@ -89,7 +116,8 @@ async function loadDiscussion(reset = true) {
try {
const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize,
cursor: reset ? null : nextCursor.value
cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value
});
if (nextRequestId === requestId) {
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
@@ -143,6 +171,36 @@ function canManageComment(comment: EntityDiscussionComment) {
);
}
function canSeeModeration(comment: EntityDiscussionComment) {
return currentUser.value?.id === comment.author?.id || can('discussions.comments.delete-any');
}
function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && canSeeModeration(comment);
}
function moderationLabel(status: AiModerationStatus) {
const labels: Record<AiModerationStatus, string> = {
unreviewed: t('discussion.moderationUnreviewed'),
reviewing: t('discussion.moderationReviewing'),
approved: t('discussion.moderationApproved'),
rejected: t('discussion.moderationRejected'),
failed: t('discussion.moderationFailed')
};
return labels[status];
}
function moderationTone(status: AiModerationStatus) {
const tones: Record<AiModerationStatus, 'info' | 'success' | 'warning' | 'danger' | 'neutral'> = {
unreviewed: 'neutral',
reviewing: 'info',
approved: 'success',
rejected: 'danger',
failed: 'warning'
};
return tones[status];
}
function commentAuthorName(comment: EntityDiscussionComment) {
return comment.deleted ? t('discussion.deletedComment') : comment.author?.displayName ?? t('discussion.byUnknown');
}
@@ -190,7 +248,10 @@ async function submitComment() {
formError.value = '';
try {
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, { body: nextBody });
const comment = await api.createEntityDiscussionComment(props.entityType, props.entityId, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? null
});
comments.value = [...comments.value, comment];
commentTotal.value += 1;
body.value = '';
@@ -213,7 +274,10 @@ async function submitReply(comment: EntityDiscussionComment) {
clearCommentError(key);
try {
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, { body: nextBody });
const reply = await api.createEntityDiscussionReply(props.entityType, props.entityId, comment.id, {
body: nextBody,
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
});
comment.replies.push(reply);
commentTotal.value += 1;
cancelReply(comment.id);
@@ -224,6 +288,22 @@ async function submitReply(comment: EntityDiscussionComment) {
}
}
async function retryModeration(comment: EntityDiscussionComment) {
const key = commentKey(comment.id);
moderationBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = await api.retryEntityDiscussionModeration(comment.id);
comment.moderationStatus = updated.moderationStatus;
comment.moderationLanguageCode = updated.moderationLanguageCode;
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.moderationRetryFailed'));
} finally {
moderationBusyId.value = null;
}
}
function markCommentDeleted(rows: EntityDiscussionComment[], id: number): boolean {
for (const comment of rows) {
if (comment.id === id) {
@@ -272,8 +352,17 @@ watch(
}
);
watch(activeLanguageCode, () => {
comments.value = [];
nextCursor.value = null;
hasMoreComments.value = false;
commentTotal.value = 0;
void loadDiscussion();
});
onMounted(() => {
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
@@ -294,6 +383,8 @@ onUnmounted(() => {
</div>
</div>
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" />
</div>
@@ -352,6 +443,12 @@ onUnmounted(() => {
</RouterLink>
<strong v-else>{{ commentAuthorName(comment) }}</strong>
<time :datetime="comment.createdAt">{{ formatDateTime(comment.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(comment)"
:label="moderationLabel(comment.moderationStatus)"
:tone="moderationTone(comment.moderationStatus)"
compact
/>
</div>
<p v-if="!comment.deleted" class="entity-discussion-comment__body">{{ comment.body }}</p>
@@ -366,6 +463,19 @@ onUnmounted(() => {
<Icon :icon="iconReply" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.reply') }}</span>
</button>
<button
v-if="canRetryModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.moderationRetry')"
:disabled="moderationBusyId === comment.id"
@click="retryModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyId === comment.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
</span>
</button>
<button
v-if="canManageComment(comment)"
class="life-icon-button life-icon-button--flat life-icon-button--danger"
@@ -427,9 +537,28 @@ onUnmounted(() => {
</RouterLink>
<strong v-else>{{ commentAuthorName(reply) }}</strong>
<time :datetime="reply.createdAt">{{ formatDateTime(reply.createdAt) }}</time>
<StatusBadge
v-if="canSeeModeration(reply)"
:label="moderationLabel(reply.moderationStatus)"
:tone="moderationTone(reply.moderationStatus)"
compact
/>
</div>
<p v-if="!reply.deleted" class="entity-discussion-comment__body">{{ reply.body }}</p>
<div v-if="canManageComment(reply)" class="entity-discussion-comment__actions">
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions">
<button
v-if="canRetryModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="t('discussion.moderationRetry')"
:disabled="moderationBusyId === reply.id"
@click="retryModeration(reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ moderationBusyId === reply.id ? t('discussion.moderationRetrying') : t('discussion.moderationRetry') }}
</span>
</button>
<button
class="life-icon-button life-icon-button--flat life-icon-button--danger"
type="button"