Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
370
src/components/business/VoteCard.tsx
Normal file
370
src/components/business/VoteCard.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* VoteCard 投票卡片组件
|
||||
* 显示投票选项列表,支持投票和取消投票
|
||||
* 风格与现代整体UI保持一致
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } 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;
|
||||
}
|
||||
|
||||
const VoteCard: React.FC<VoteCardProps> = ({
|
||||
options,
|
||||
totalVotes,
|
||||
hasVoted,
|
||||
votedOptionId,
|
||||
onVote,
|
||||
onUnvote,
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 动画值
|
||||
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 (
|
||||
<View key={option.id} style={styles.optionContainer}>
|
||||
{/* 进度条背景 */}
|
||||
{showResults && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
width: progressAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', `${percentage}%`],
|
||||
}),
|
||||
backgroundColor: isVotedOption
|
||||
? colors.primary.light + '40'
|
||||
: colors.background.disabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 选项按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
isVotedOption && styles.optionButtonVoted,
|
||||
]}
|
||||
onPress={() => handleVote(option.id)}
|
||||
disabled={isLoading || hasVoted}
|
||||
activeOpacity={hasVoted ? 1 : 0.8}
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
<View style={[
|
||||
styles.optionIndicator,
|
||||
isVotedOption && styles.optionIndicatorVoted,
|
||||
]}>
|
||||
{isVotedOption && (
|
||||
<MaterialCommunityIcons
|
||||
name="check"
|
||||
size={12}
|
||||
color={colors.primary.contrast}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 选项内容 */}
|
||||
<Text
|
||||
variant={compact ? 'caption' : 'body'}
|
||||
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
|
||||
numberOfLines={compact ? 1 : 2}
|
||||
>
|
||||
{option.content}
|
||||
</Text>
|
||||
|
||||
{/* 投票结果 */}
|
||||
{showResults && (
|
||||
<View style={styles.resultContainer}>
|
||||
<Text
|
||||
variant="caption"
|
||||
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
|
||||
>
|
||||
{percentage}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
|
||||
|
||||
// 排序后的选项(已投票的排在前面)
|
||||
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 (
|
||||
<View style={[styles.container, compact && styles.containerCompact]}>
|
||||
{/* 投票图标和标题 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerIcon}>
|
||||
<MaterialCommunityIcons
|
||||
name="vote"
|
||||
size={compact ? 14 : 16}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
|
||||
投票
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 投票选项列表 */}
|
||||
<View style={styles.optionsList}>
|
||||
{sortedOptions.map((option, index) => renderOption(option, index))}
|
||||
</View>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerLeft}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-group-outline"
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
|
||||
{formatVoteCount(totalVotes)} 人参与
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{hasVoted && (
|
||||
<TouchableOpacity
|
||||
style={styles.unvoteButton}
|
||||
onPress={handleUnvote}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="refresh"
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
|
||||
重选
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 加载遮罩 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<MaterialCommunityIcons
|
||||
name="loading"
|
||||
size={24}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = 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,
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteCard;
|
||||
Reference in New Issue
Block a user