/** * VoteCard 投票卡片组件 * 显示投票选项列表,支持投票和取消投票 * 风格与现代整体UI保持一致 */ import React, { useCallback, useMemo } from 'react'; import { View, TouchableOpacity, StyleSheet, Animated, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; import { VoteOptionDTO } from '../../types'; import Text from '../common/Text'; interface VoteCardProps { postId?: string; options: VoteOptionDTO[]; totalVotes: number; hasVoted: boolean; votedOptionId?: string; onVote: (optionId: string) => void; onUnvote: () => void; isLoading?: boolean; compact?: boolean; } function createVoteCardStyles(colors: AppColors) { return StyleSheet.create({ container: { backgroundColor: colors.background.paper, borderRadius: borderRadius.lg, padding: spacing.md, marginVertical: spacing.sm, borderWidth: 1, borderColor: colors.divider, }, containerCompact: { padding: spacing.sm, marginVertical: spacing.xs, }, header: { flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm, }, headerIcon: { width: 24, height: 24, borderRadius: borderRadius.sm, backgroundColor: colors.primary.light + '20', justifyContent: 'center', alignItems: 'center', marginRight: spacing.sm, }, headerTitle: { fontWeight: '600', color: colors.text.primary, }, optionsList: { gap: spacing.sm, }, optionContainer: { position: 'relative', borderRadius: borderRadius.md, overflow: 'hidden', }, progressBar: { position: 'absolute', top: 0, left: 0, bottom: 0, borderRadius: borderRadius.md, }, optionButton: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.sm, paddingHorizontal: spacing.md, borderRadius: borderRadius.md, backgroundColor: colors.background.default, minHeight: 44, borderWidth: 1, borderColor: 'transparent', }, optionButtonVoted: { borderColor: colors.primary.main, backgroundColor: 'transparent', }, optionIndicator: { width: 18, height: 18, borderRadius: borderRadius.full, borderWidth: 2, borderColor: colors.divider, marginRight: spacing.sm, justifyContent: 'center', alignItems: 'center', }, optionIndicatorVoted: { backgroundColor: colors.primary.main, borderColor: colors.primary.main, }, optionText: { flex: 1, fontSize: fontSizes.md, color: colors.text.primary, lineHeight: 20, }, optionTextCompact: { fontSize: fontSizes.sm, lineHeight: 18, }, resultContainer: { flexDirection: 'row', alignItems: 'center', marginLeft: spacing.sm, minWidth: 40, justifyContent: 'flex-end', }, percentage: { color: colors.text.secondary, fontWeight: '500', }, percentageVoted: { color: colors.primary.main, fontWeight: '700', }, footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: spacing.md, paddingTop: spacing.sm, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, }, footerLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, }, footerText: { fontSize: fontSizes.sm, }, unvoteButton: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, borderRadius: borderRadius.sm, backgroundColor: colors.background.default, }, unvoteText: { fontSize: fontSizes.sm, }, loadingOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.background.paper + 'CC', justifyContent: 'center', alignItems: 'center', borderRadius: borderRadius.lg, }, }); } const VoteCard: React.FC = ({ options, totalVotes, hasVoted, votedOptionId, onVote, onUnvote, isLoading = false, compact = false, }) => { const colors = useAppColors(); const styles = useMemo(() => createVoteCardStyles(colors), [colors]); const progressAnim = React.useRef(new Animated.Value(0)).current; React.useEffect(() => { Animated.timing(progressAnim, { toValue: 1, duration: 400, useNativeDriver: false, }).start(); }, [hasVoted, totalVotes]); // 计算百分比 const calculatePercentage = useCallback((votes: number): number => { if (totalVotes === 0) return 0; return Math.round((votes / totalVotes) * 100); }, [totalVotes]); // 格式化票数 const formatVoteCount = useCallback((count: number): string => { if (count >= 10000) { return (count / 10000).toFixed(1) + '万'; } if (count >= 1000) { return (count / 1000).toFixed(1) + 'k'; } return count.toString(); }, []); // 处理投票 const handleVote = useCallback((optionId: string) => { if (isLoading || hasVoted) return; onVote(optionId); }, [isLoading, hasVoted, onVote]); // 处理取消投票 const handleUnvote = useCallback(() => { if (isLoading || !hasVoted) return; onUnvote(); }, [isLoading, hasVoted, onUnvote]); // 渲染投票选项 const renderOption = useCallback((option: VoteOptionDTO, index: number) => { const isVotedOption = votedOptionId === option.id; const percentage = calculatePercentage(option.votes_count); const showResults = hasVoted; return ( {/* 进度条背景 */} {showResults && ( )} {/* 选项按钮 */} handleVote(option.id)} disabled={isLoading || hasVoted} activeOpacity={hasVoted ? 1 : 0.8} > {/* 选择指示器 */} {isVotedOption && ( )} {/* 选项内容 */} {option.content} {/* 投票结果 */} {showResults && ( {percentage}% )} ); }, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]); // 排序后的选项(已投票的排在前面) const sortedOptions = React.useMemo(() => { if (!hasVoted) return options; return [...options].sort((a, b) => { if (a.id === votedOptionId) return -1; if (b.id === votedOptionId) return 1; return b.votes_count - a.votes_count; }); }, [options, hasVoted, votedOptionId]); return ( {/* 投票图标和标题 */} 投票 {/* 投票选项列表 */} {sortedOptions.map((option, index) => renderOption(option, index))} {/* 底部信息栏 */} {formatVoteCount(totalVotes)} 人参与 {hasVoted && ( 重选 )} {/* 加载遮罩 */} {isLoading && ( )} ); }; export default VoteCard;