feat: 添加举报功能前端支持
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m45s
Frontend CI / ota-android (push) Successful in 12m44s
Frontend CI / build-android-apk (push) Successful in 52m27s

- 新增 reportService API 服务
- 新增 ReportDialog 举报对话框组件
- 集成举报入口到 PostCard、CommentItem、LongPressMenu

Made-with: Cursor
This commit is contained in:
lafay
2026-03-29 20:18:49 +08:00
parent 584d98307c
commit e0ee29caf8
7 changed files with 425 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ interface CommentItemProps {
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
}
function createCommentItemStyles(colors: AppColors) {

View File

@@ -22,7 +22,8 @@ export type PostCardActionType =
| 'unbookmark' // 取消收藏
| 'share' // 分享
| 'imagePress' // 点击图片
| 'delete'; // 删除
| 'delete' // 删除
| 'report'; // 举报
/**
* Action payload 类型

View File

@@ -0,0 +1,335 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
*/
import React, { useState, useCallback } from 'react';
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes } from '../../../theme';
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
export interface ReportDialogProps {
visible: boolean;
targetType: ReportTargetType;
targetId: string;
onClose: () => void;
onSuccess?: () => void;
}
// 目标类型中文名称映射
const TARGET_TYPE_LABELS: Record<ReportTargetType, string> = {
post: '帖子',
comment: '评论',
message: '消息',
};
export const ReportDialog: React.FC<ReportDialogProps> = ({
visible,
targetType,
targetId,
onClose,
onSuccess,
}) => {
const colors = useAppColors();
const styles = useStyles(colors);
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因');
return;
}
setSubmitting(true);
try {
const result = await reportService.createReport(
targetType,
targetId,
selectedReason,
description.trim() || undefined
);
if (result) {
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
{
text: '确定',
onPress: () => {
onClose();
onSuccess?.();
},
},
]);
} else {
Alert.alert('举报失败', '请稍后重试');
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试');
} finally {
setSubmitting(false);
}
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
// 重置状态
const handleClose = useCallback(() => {
setSelectedReason(null);
setDescription('');
onClose();
}, [onClose]);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<Text style={styles.title}>
{TARGET_TYPE_LABELS[targetType]}
</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={submitting}
>
<MaterialCommunityIcons
name="close"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
{/* 内容 */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 举报原因选择 */}
<Text style={styles.sectionTitle}></Text>
<View style={styles.reasonList}>
{REPORT_REASONS.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.reasonItem,
selectedReason === item.value && styles.reasonItemSelected,
]}
onPress={() => setSelectedReason(item.value)}
disabled={submitting}
>
<View style={styles.radioContainer}>
<View
style={[
styles.radio,
selectedReason === item.value && styles.radioSelected,
]}
>
{selectedReason === item.value && (
<View style={styles.radioDot} />
)}
</View>
<Text style={styles.reasonLabel}>{item.label}</Text>
</View>
</TouchableOpacity>
))}
</View>
{/* 补充说明 */}
<Text style={styles.sectionTitle}></Text>
<TextInput
style={styles.textInput}
placeholder="请提供更多详细信息..."
placeholderTextColor={colors.textTertiary}
multiline
maxLength={500}
value={description}
onChangeText={setDescription}
editable={!submitting}
/>
<Text style={styles.charCount}>{description.length}/500</Text>
</ScrollView>
{/* 底部按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={submitting}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
(!selectedReason || submitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!selectedReason || submitting}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
};
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
container: {
backgroundColor: colors.backgroundPrimary,
borderRadius: borderRadius.lg,
width: '100%',
maxWidth: 400,
maxHeight: '80%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
title: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.textPrimary,
},
closeButton: {
padding: spacing.xs,
},
content: {
padding: spacing.lg,
maxHeight: 400,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: spacing.sm,
},
reasonList: {
marginBottom: spacing.lg,
},
reasonItem: {
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
reasonItemSelected: {
backgroundColor: colors.backgroundSecondary,
marginHorizontal: -spacing.sm,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.sm,
},
radioContainer: {
flexDirection: 'row',
alignItems: 'center',
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: colors.border,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
},
radioSelected: {
borderColor: colors.primary,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
reasonLabel: {
fontSize: fontSizes.md,
color: colors.textPrimary,
},
textInput: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.sm,
fontSize: fontSizes.md,
color: colors.textPrimary,
minHeight: 100,
textAlignVertical: 'top',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.textTertiary,
textAlign: 'right',
marginTop: spacing.xs,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
gap: spacing.sm,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
},
cancelButtonText: {
fontSize: fontSizes.md,
color: colors.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.sm,
borderRadius: borderRadius.md,
backgroundColor: colors.primary,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.primary + '60',
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '500',
},
});
export default ReportDialog;

View File

@@ -0,0 +1,2 @@
export { ReportDialog } from './ReportDialog';
export type { ReportDialogProps } from './ReportDialog';

View File

@@ -22,3 +22,4 @@ export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as ReportDialog } from './ReportDialog';

View File

@@ -166,6 +166,7 @@ export interface LongPressMenuProps {
onRecall: (messageId: string) => void;
onDelete: (messageId: string) => void;
onAddSticker?: (imageUrl: string) => void;
onReport?: (message: GroupMessage) => void; // 举报消息回调
}
// 聊天头部 Props

View File

@@ -0,0 +1,83 @@
/**
* 举报服务
* 处理帖子、评论、消息的举报功能
*/
import { api } from './api';
// 举报原因类型
export type ReportReason = 'spam' | 'inappropriate' | 'harassment' | 'misinformation' | 'other';
// 举报目标类型
export type ReportTargetType = 'post' | 'comment' | 'message';
// 创建举报请求
interface CreateReportRequest {
target_type: ReportTargetType;
target_id: string;
reason: ReportReason;
description?: string;
}
// 举报响应
export interface ReportResponse {
id: string;
reporter_id: string;
target_type: ReportTargetType;
target_id: string;
reason: ReportReason;
description?: string;
status: 'pending' | 'processing' | 'resolved' | 'rejected';
created_at: string;
}
// 举报原因选项
export const REPORT_REASONS: { value: ReportReason; label: string }[] = [
{ value: 'spam', label: '垃圾广告' },
{ value: 'inappropriate', label: '违规内容' },
{ value: 'harassment', label: '辱骂/攻击' },
{ value: 'misinformation', label: '虚假信息' },
{ value: 'other', label: '其他' },
];
// 举报服务类
class ReportService {
/**
* 创建举报
* @param targetType 举报目标类型
* @param targetId 目标ID
* @param reason 举报原因
* @param description 详细描述(可选)
*/
async createReport(
targetType: ReportTargetType,
targetId: string,
reason: ReportReason,
description?: string
): Promise<ReportResponse | null> {
try {
const request: CreateReportRequest = {
target_type: targetType,
target_id: targetId,
reason,
description,
};
const response = await api.post<ReportResponse>('/reports', request);
return response.data;
} catch (error) {
console.error('创建举报失败:', error);
return null;
}
}
/**
* 获取举报原因标签
*/
getReasonLabel(reason: ReportReason): string {
const item = REPORT_REASONS.find(r => r.value === reason);
return item?.label ?? reason;
}
}
export const reportService = new ReportService();
export default reportService;