feat(badges): 添加勋章系统和展示功能
实现完整的勋章系统,包括勋章列表展示、自动弹窗展示和分享功能。 - 新增勋章列表页面,支持已获得和待解锁勋章的分类展示 - 在个人中心添加勋章预览模块,显示前3个勋章和总数统计 - 实现勋章展示弹窗,支持动画效果和玻璃态UI - 添加勋章分享功能,可生成分享卡片 - 新增 badgesSlice 管理勋章状态,包括获取、排序和计数逻辑 - 添加勋章服务 API 封装,支持获取勋章列表和标记已展示 - 完善中英文国际化文案
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import ActivityHeatMap from '@/components/ActivityHeatMap';
|
||||
import { BadgeShowcaseModal } from '@/components/badges/BadgeShowcaseModal';
|
||||
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
|
||||
import { ROUTES } from '@/constants/Routes';
|
||||
import { getTabBarBottomPadding } from '@/constants/TabBar';
|
||||
import { useMembershipModal } from '@/contexts/MembershipModalContext';
|
||||
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
|
||||
import { useAuthGuard } from '@/hooks/useAuthGuard';
|
||||
import type { BadgeDto } from '@/services/badges';
|
||||
import { reportBadgeShowcaseDisplayed } from '@/services/badges';
|
||||
import { fetchAvailableBadges, selectBadgeCounts, selectBadgePreview, selectSortedBadges } from '@/store/badgesSlice';
|
||||
import { selectActiveMembershipPlanName } from '@/store/membershipSlice';
|
||||
import { DEFAULT_MEMBER_NAME, fetchActivityHistory, fetchMyProfile } from '@/store/userSlice';
|
||||
import { getItem, setItem } from '@/utils/kvStore';
|
||||
@@ -105,6 +109,10 @@ export default function PersonalScreen() {
|
||||
openMembershipModal();
|
||||
}, [ensureLoggedIn, openMembershipModal]);
|
||||
|
||||
const handleBadgesPress = useCallback(() => {
|
||||
router.push(ROUTES.BADGES);
|
||||
}, [router]);
|
||||
|
||||
// 计算底部间距
|
||||
const bottomPadding = useMemo(() => {
|
||||
return getTabBarBottomPadding(60) + (insets?.bottom ?? 0);
|
||||
@@ -113,6 +121,48 @@ export default function PersonalScreen() {
|
||||
// 直接使用 Redux 中的用户信息,避免重复状态管理
|
||||
const userProfile = useAppSelector((state) => state.user.profile);
|
||||
const activeMembershipPlanName = useAppSelector(selectActiveMembershipPlanName);
|
||||
const badgePreview = useAppSelector(selectBadgePreview);
|
||||
const badgeCounts = useAppSelector(selectBadgeCounts);
|
||||
const sortedBadges = useAppSelector(selectSortedBadges);
|
||||
|
||||
const [showcaseBadge, setShowcaseBadge] = useState<BadgeDto | null>(null);
|
||||
const autoShownBadgeCodes = useRef<Set<string>>(new Set());
|
||||
|
||||
const openBadgeShowcase = useCallback((badge: BadgeDto) => {
|
||||
autoShownBadgeCodes.current.add(badge.code);
|
||||
setShowcaseBadge(badge);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showcaseBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextBadgeToShow = sortedBadges.find(
|
||||
(badge) => badge.isAwarded && badge.isShow === false && !autoShownBadgeCodes.current.has(badge.code)
|
||||
);
|
||||
|
||||
if (nextBadgeToShow) {
|
||||
openBadgeShowcase(nextBadgeToShow);
|
||||
}
|
||||
}, [openBadgeShowcase, showcaseBadge, sortedBadges]);
|
||||
|
||||
const handleBadgeShowcaseClose = useCallback(async () => {
|
||||
if (!showcaseBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeCode = showcaseBadge.code;
|
||||
setShowcaseBadge(null);
|
||||
|
||||
try {
|
||||
await reportBadgeShowcaseDisplayed(badgeCode);
|
||||
} catch (error) {
|
||||
log.warn('report-badge-showcase-failed', error);
|
||||
}
|
||||
}, [showcaseBadge]);
|
||||
|
||||
console.log('badgePreview', badgePreview);
|
||||
|
||||
|
||||
// 页面聚焦时获取最新用户信息
|
||||
@@ -120,6 +170,7 @@ export default function PersonalScreen() {
|
||||
React.useCallback(() => {
|
||||
dispatch(fetchMyProfile());
|
||||
dispatch(fetchActivityHistory());
|
||||
dispatch(fetchAvailableBadges());
|
||||
// 不再需要在这里加载推送偏好设置,因为已移到通知设置页面
|
||||
// 加载开发者模式状态
|
||||
loadDeveloperModeState();
|
||||
@@ -367,6 +418,64 @@ export default function PersonalScreen() {
|
||||
</View>
|
||||
);
|
||||
|
||||
const BadgesPreviewSection = () => {
|
||||
const previewBadges = badgePreview.slice(0, 3);
|
||||
const hasBadges = previewBadges.length > 0;
|
||||
const extraCount = Math.max(0, badgeCounts.total - previewBadges.length);
|
||||
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<TouchableOpacity style={[styles.cardContainer, styles.badgesRowCard]} onPress={handleBadgesPress} activeOpacity={0.85}>
|
||||
<Text style={styles.badgesRowTitle}>{t('personal.badgesPreview.title')}</Text>
|
||||
{hasBadges ? (
|
||||
<View style={styles.badgesRowContent}>
|
||||
<View style={styles.badgesStack}>
|
||||
{previewBadges.map((badge, index) => (
|
||||
<View
|
||||
key={badge.code}
|
||||
style={[
|
||||
styles.badgeCompactBubble,
|
||||
badge.isAwarded ? styles.badgeCompactBubbleEarned : styles.badgeCompactBubbleLocked,
|
||||
{
|
||||
marginLeft: index === 0 ? 0 : -12,
|
||||
zIndex: previewBadges.length - index,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{badge.imageUrl ? (
|
||||
<Image
|
||||
source={{ uri: badge.imageUrl }}
|
||||
style={styles.badgeCompactImage}
|
||||
contentFit="cover"
|
||||
transition={200}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.badgeCompactFallback}>
|
||||
<Text style={styles.badgeCompactFallbackText}>{badge.icon ?? '🏅'}</Text>
|
||||
</View>
|
||||
)}
|
||||
{!badge.isAwarded && (
|
||||
<View style={styles.badgeCompactOverlay}>
|
||||
<Ionicons name="lock-closed" as any size={16} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{extraCount > 0 && (
|
||||
<View style={[styles.badgeCompactBubble, styles.badgeExtraBubble, styles.badgeExtraBubbleInline]}>
|
||||
<Text style={styles.badgeExtraText}>+{extraCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.badgesRowEmpty}>{t('personal.badgesPreview.empty')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 菜单项组件
|
||||
const MenuSection = ({ title, items }: { title: string; items: MenuItem[] }) => (
|
||||
<View style={styles.sectionContainer}>
|
||||
@@ -590,6 +699,7 @@ export default function PersonalScreen() {
|
||||
<UserHeader />
|
||||
{userProfile.isVip ? <VipMembershipCard /> : <MembershipBanner />}
|
||||
<StatsSection />
|
||||
<BadgesPreviewSection />
|
||||
<View style={styles.fishRecordContainer}>
|
||||
{/* <Image
|
||||
source={{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/icons/icon-profile-fish.png' }}
|
||||
@@ -605,6 +715,12 @@ export default function PersonalScreen() {
|
||||
<MenuSection key={index} title={section.title} items={section.items} />
|
||||
))}
|
||||
</ScrollView>
|
||||
<BadgeShowcaseModal
|
||||
badge={showcaseBadge}
|
||||
onClose={handleBadgeShowcaseClose}
|
||||
username={displayName}
|
||||
appName="Out Live"
|
||||
/>
|
||||
<LanguageSelectorModal />
|
||||
</View>
|
||||
);
|
||||
@@ -871,6 +987,87 @@ const styles = StyleSheet.create({
|
||||
color: '#6C757D',
|
||||
fontWeight: '500',
|
||||
},
|
||||
badgesRowCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 6 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 10,
|
||||
elevation: 2,
|
||||
},
|
||||
badgesRowTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#111827',
|
||||
},
|
||||
badgesRowContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badgesStack: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badgeCompactBubble: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 8,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
badgeCompactBubbleEarned: {
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.18)',
|
||||
},
|
||||
badgeCompactBubbleLocked: {
|
||||
backgroundColor: 'rgba(226, 232, 240, 0.9)',
|
||||
},
|
||||
badgeCompactImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
badgeCompactFallback: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeCompactFallbackText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#475467',
|
||||
},
|
||||
badgeCompactOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.45)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
badgeExtraBubble: {
|
||||
backgroundColor: 'rgba(124, 58, 237, 0.12)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(124, 58, 237, 0.35)',
|
||||
},
|
||||
badgeExtraBubbleInline: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
badgeExtraText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#5B21B6',
|
||||
},
|
||||
badgesRowEmpty: {
|
||||
fontSize: 13,
|
||||
color: '#6B7280',
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 菜单项
|
||||
menuItem: {
|
||||
flexDirection: 'row',
|
||||
|
||||
Reference in New Issue
Block a user