Files
frontend/src/components/business/VoteCard.tsx
lafay 4ee3079b9f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
feat(Theme): enhance theming and UI consistency across components
- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability.
- Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage.
- Introduced SystemChrome component to manage system UI background color based on theme.
- Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure.
- Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system.
- Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
2026-03-25 05:16:54 +08:00

374 lines
10 KiB
TypeScript

/**
* 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<VoteCardProps> = ({
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 (
<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, 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 (
<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>
);
};
export default VoteCard;