feat(CommentItem, PostDetailScreen, ReportDialog): integrate report functionality
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m54s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled

- Added report button to CommentItem for reporting comments and replies.
- Integrated ReportDialog into PostDetailScreen for handling report submissions.
- Enhanced ReportDialog with detailed report reasons and improved UI interactions.
- Updated styles across various components for a more modern look and feel.

Made-with: Cursor
This commit is contained in:
lafay
2026-03-30 17:53:30 +08:00
parent e0ee29caf8
commit 774b5c4b47
14 changed files with 793 additions and 373 deletions

View File

@@ -215,6 +215,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
onDelete,
onImagePress,
currentUserId,
onReport,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
@@ -541,6 +542,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
)}
{/* 举报按钮 - 对非子评论作者显示 */}
{!isSubReplyAuthor && onReport && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReport(reply)}
>
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
@@ -635,6 +648,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
{/* 举报按钮 - 只对非评论作者显示 */}
{!isCommentAuthor && onReport && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReport(comment)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
)}
{/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && (
<TouchableOpacity

View File

@@ -1,24 +1,27 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import {
Modal,
View,
Text,
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 } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
import Text from '../../common/Text';
export interface ReportDialogProps {
visible: boolean;
@@ -28,14 +31,23 @@ export interface ReportDialogProps {
onSuccess?: () => void;
}
// 目标类型中文名称映射
const TARGET_TYPE_LABELS: Record<ReportTargetType, string> = {
post: '帖子',
comment: '评论',
message: '消息',
// 目标类型配置
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' },
};
export const ReportDialog: React.FC<ReportDialogProps> = ({
// 举报原因详细配置(带图标和说明)
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,
@@ -48,10 +60,23 @@ export const ReportDialog: React.FC<ReportDialogProps> = ({
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const targetConfig = TARGET_CONFIG[targetType];
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired
? '请详细描述您举报的原因(必填)...'
: '请提供更多详细信息(选填)...';
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因');
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
return;
}
if (isDescriptionRequired && !description.trim()) {
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
return;
}
@@ -75,11 +100,11 @@ export const ReportDialog: React.FC<ReportDialogProps> = ({
},
]);
} else {
Alert.alert('举报失败', '请稍后重试');
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试');
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
} finally {
setSubmitting(false);
}
@@ -92,6 +117,11 @@ export const ReportDialog: React.FC<ReportDialogProps> = ({
onClose();
}, [onClose]);
const selectedReasonData = useMemo(
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
[selectedReason]
);
return (
<Modal
visible={visible}
@@ -99,99 +129,153 @@ export const ReportDialog: React.FC<ReportDialogProps> = ({
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>
))}
<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>
{/* 补充说明 */}
<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>
{/* 内容 */}
<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.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 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>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
@@ -199,6 +283,9 @@ export const ReportDialog: React.FC<ReportDialogProps> = ({
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
keyboardView: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
@@ -207,129 +294,193 @@ const useStyles = (colors: ReturnType<typeof useAppColors>) =>
padding: spacing.lg,
},
container: {
backgroundColor: colors.backgroundPrimary,
borderRadius: borderRadius.lg,
backgroundColor: colors.chat.card,
borderRadius: borderRadius.xl,
width: '100%',
maxWidth: 400,
maxHeight: '80%',
maxWidth: 420,
maxHeight: '85%',
...shadows.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
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.lg,
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.textPrimary,
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: '500',
color: colors.textPrimary,
marginBottom: spacing.sm,
fontWeight: '600',
color: colors.chat.textPrimary,
},
reasonList: {
marginBottom: spacing.lg,
gap: spacing.sm,
},
reasonItem: {
paddingVertical: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: 'transparent',
backgroundColor: colors.chat.surfaceMuted,
},
reasonItemSelected: {
backgroundColor: colors.backgroundSecondary,
marginHorizontal: -spacing.sm,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.sm,
backgroundColor: colors.primary.main + '08',
borderWidth: 1,
},
radioContainer: {
reasonLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: spacing.md,
},
radio: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 2,
borderColor: colors.border,
reasonIcon: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.chat.surfaceRaised,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.sm,
},
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,
borderColor: colors.primary.main,
backgroundColor: colors.primary.main,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.primary,
},
reasonLabel: {
fontSize: fontSizes.md,
color: colors.textPrimary,
backgroundColor: '#FFFFFF',
},
textInput: {
borderWidth: 1,
borderColor: colors.border,
borderColor: colors.chat.border,
borderRadius: borderRadius.md,
padding: spacing.sm,
padding: spacing.md,
fontSize: fontSizes.md,
color: colors.textPrimary,
color: colors.chat.textPrimary,
minHeight: 100,
textAlignVertical: 'top',
backgroundColor: colors.chat.surfaceMuted,
},
textInputError: {
borderColor: colors.error.main,
backgroundColor: colors.error.main + '08',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.textTertiary,
textAlign: 'right',
marginTop: spacing.xs,
color: colors.chat.textSecondary,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
gap: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
gap: spacing.sm,
borderTopColor: colors.chat.borderLight,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
borderColor: colors.chat.border,
alignItems: 'center',
backgroundColor: colors.chat.surfaceMuted,
},
cancelButtonText: {
fontSize: fontSizes.md,
color: colors.textSecondary,
fontWeight: '500',
color: colors.chat.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.sm,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.primary,
backgroundColor: colors.primary.main,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.primary + '60',
backgroundColor: colors.background.disabled,
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '500',
fontWeight: '600',
},
});
export default ReportDialog;
export default ReportDialog;

View File

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