Files
frontend/src/components/business/ReportDialog/ReportDialog.tsx
lafay fe6a03da5d
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m39s
Frontend CI / ota-android (push) Successful in 10m22s
Frontend CI / build-android-apk (push) Has been cancelled
feat: 优化架构
2026-04-13 01:30:37 +08:00

494 lines
15 KiB
TypeScript

/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Modal,
View,
TouchableOpacity,
StyleSheet,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import { reportService, ReportReason, ReportTargetType, REPORT_REASONS } from '@/services/post';
import { blurActiveElement } from '../../../infrastructure/platform';
import Text from '../../common/Text';
export interface ReportDialogProps {
visible: boolean;
targetType: ReportTargetType;
targetId: string;
onClose: () => void;
onSuccess?: () => void;
}
// 目标类型配置
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
};
// 举报原因详细配置(带图标和说明)
const REPORT_REASONS_DETAILED = [
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
];
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 targetConfig = TARGET_CONFIG[targetType];
useEffect(() => {
if (visible) {
blurActiveElement();
}
}, [visible]);
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired
? '请详细描述您举报的原因(必填)...'
: '请提供更多详细信息(选填)...';
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
return;
}
if (isDescriptionRequired && !description.trim()) {
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
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('举报失败', '请稍后重试', [{ text: '确定' }]);
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
} finally {
setSubmitting(false);
}
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
// 重置状态
const handleClose = useCallback(() => {
setSelectedReason(null);
setDescription('');
onClose();
}, [onClose]);
const selectedReasonData = useMemo(
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
[selectedReason]
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.overlay}>
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
<MaterialCommunityIcons
name={targetConfig.icon as any}
size={20}
color={targetConfig.color}
/>
</View>
<View style={styles.headerText}>
<Text style={styles.title}>{targetConfig.label}</Text>
<Text style={styles.subtitle}></Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={submitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialCommunityIcons
name="close"
size={24}
color={colors.chat.textSecondary}
/>
</TouchableOpacity>
</View>
{/* 内容 */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 举报原因选择 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.reasonList}>
{REPORT_REASONS_DETAILED.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.reasonItem,
selectedReason === item.value && [
styles.reasonItemSelected,
{ borderColor: colors.primary.main + '40' }
],
]}
onPress={() => setSelectedReason(item.value)}
disabled={submitting}
activeOpacity={0.7}
>
<View style={styles.reasonLeft}>
<View
style={[
styles.reasonIcon,
selectedReason === item.value && styles.reasonIconSelected,
]}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
/>
</View>
<View style={styles.reasonTextContainer}>
<Text style={[
styles.reasonLabel,
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
]}>
{item.label}
</Text>
<Text style={styles.reasonDescription}>{item.description}</Text>
</View>
</View>
<View
style={[
styles.radio,
selectedReason === item.value && styles.radioSelected,
]}
>
{selectedReason === item.value && (
<View style={styles.radioDot} />
)}
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 补充说明 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{isDescriptionRequired ? ' *' : ''}
</Text>
<Text style={styles.charCount}>{description.length}/500</Text>
</View>
<TextInput
style={[
styles.textInput,
isDescriptionRequired && !description && styles.textInputError,
]}
placeholder={descriptionPlaceholder}
placeholderTextColor={colors.chat.textPlaceholder}
multiline
maxLength={500}
value={description}
onChangeText={setDescription}
editable={!submitting}
textAlignVertical="top"
/>
</View>
</ScrollView>
{/* 底部按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={submitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
(!selectedReason || submitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!selectedReason || submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
keyboardView: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
container: {
backgroundColor: colors.chat.card,
borderRadius: borderRadius.xl,
width: '100%',
maxWidth: 420,
maxHeight: '85%',
...shadows.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
paddingBottom: spacing.md,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
headerIcon: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
headerText: {
flex: 1,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.chat.textPrimary,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
closeButton: {
padding: spacing.xs,
borderRadius: borderRadius.full,
},
content: {
padding: spacing.lg,
paddingTop: 0,
maxHeight: 400,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.chat.textPrimary,
},
reasonList: {
gap: spacing.sm,
},
reasonItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: 'transparent',
backgroundColor: colors.chat.surfaceMuted,
},
reasonItemSelected: {
backgroundColor: colors.primary.main + '08',
borderWidth: 1,
},
reasonLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: spacing.md,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.chat.surfaceRaised,
justifyContent: 'center',
alignItems: 'center',
},
reasonIconSelected: {
backgroundColor: colors.primary.main + '15',
},
reasonTextContainer: {
flex: 1,
},
reasonLabel: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textPrimary,
},
reasonLabelSelected: {
color: colors.primary.main,
fontWeight: '600',
},
reasonDescription: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.chat.border,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.sm,
},
radioSelected: {
borderColor: colors.primary.main,
backgroundColor: colors.primary.main,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF',
},
textInput: {
borderWidth: 1,
borderColor: colors.chat.border,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: fontSizes.md,
color: colors.chat.textPrimary,
minHeight: 100,
backgroundColor: colors.chat.surfaceMuted,
},
textInputError: {
borderColor: colors.error.main,
backgroundColor: colors.error.main + '08',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
gap: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.chat.borderLight,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.chat.border,
alignItems: 'center',
backgroundColor: colors.chat.surfaceMuted,
},
cancelButtonText: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.background.disabled,
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '600',
},
});
export default ReportDialog;