feat(challenges): 实现自定义挑战的编辑与删除功能并完善多语言支持

- 新增自定义挑战的编辑模式,支持修改挑战信息
- 在详情页为创建者添加删除(归档)挑战的功能入口
- 全面完善挑战创建页面的国际化(i18n)文案适配
- 优化个人中心页面的字体样式,统一使用 AliBold/Regular
- 更新 Store 逻辑以处理挑战更新、删除及列表数据映射调整
This commit is contained in:
richarjiang
2025-11-26 19:07:19 +08:00
parent 39671ed70f
commit 518282ecb8
6 changed files with 866 additions and 160 deletions

View File

@@ -981,16 +981,20 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliBold',
}, },
userRole: { userRole: {
fontSize: 14, fontSize: 14,
color: '#9370DB', color: '#9370DB',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
userMemberNumber: { userMemberNumber: {
fontSize: 10, fontSize: 10,
color: '#6C757D', color: '#6C757D',
marginTop: 4, marginTop: 4,
fontFamily: 'AliRegular',
}, },
aiUsageContainer: { aiUsageContainer: {
flexDirection: 'row', flexDirection: 'row',
@@ -1002,6 +1006,7 @@ const styles = StyleSheet.create({
color: '#9370DB', color: '#9370DB',
marginLeft: 2, marginLeft: 2,
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
editButton: { editButton: {
backgroundColor: '#9370DB', backgroundColor: '#9370DB',
@@ -1020,6 +1025,7 @@ const styles = StyleSheet.create({
color: 'white', color: 'white',
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
editButtonTextGlass: { editButtonTextGlass: {
color: 'rgba(147, 112, 219, 1)', color: 'rgba(147, 112, 219, 1)',
@@ -1041,11 +1047,13 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#9370DB', color: '#9370DB',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliBold',
}, },
statLabel: { statLabel: {
fontSize: 12, fontSize: 12,
color: '#6C757D', color: '#6C757D',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
badgesRowCard: { badgesRowCard: {
flexDirection: 'row', flexDirection: 'row',
@@ -1065,6 +1073,7 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#111827', color: '#111827',
fontFamily: 'AliBold',
}, },
badgesRowContent: { badgesRowContent: {
flexDirection: 'row', flexDirection: 'row',
@@ -1103,6 +1112,7 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '600', fontWeight: '600',
color: '#475467', color: '#475467',
fontFamily: 'AliBold',
}, },
badgeCompactOverlay: { badgeCompactOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -1122,11 +1132,14 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#5B21B6', color: '#5B21B6',
fontFamily: 'AliRegular',
}, },
badgesRowEmpty: { badgesRowEmpty: {
fontSize: 13, fontSize: 13,
color: '#6B7280', color: '#6B7280',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliBold',
}, },
// 菜单项 // 菜单项
menuItem: { menuItem: {
@@ -1151,6 +1164,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: '#6C757D', color: '#6C757D',
marginRight: 6, marginRight: 6,
fontFamily: 'AliRegular',
}, },
iconContainer: { iconContainer: {
width: 32, width: 32,
@@ -1179,6 +1193,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
marginLeft: 4, marginLeft: 4,
fontFamily: 'AliBold',
}, },
languageModalOverlay: { languageModalOverlay: {
flex: 1, flex: 1,
@@ -1204,11 +1219,13 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: 'bold', fontWeight: 'bold',
color: '#2C3E50', color: '#2C3E50',
fontFamily: 'AliBold',
}, },
languageModalSubtitle: { languageModalSubtitle: {
fontSize: 13, fontSize: 13,
color: '#6C757D', color: '#6C757D',
marginBottom: 4, marginBottom: 4,
fontFamily: 'AliRegular',
}, },
languageOption: { languageOption: {
flexDirection: 'row', flexDirection: 'row',
@@ -1233,11 +1250,13 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: '#2C3E50', color: '#2C3E50',
fontFamily: 'AliBold',
}, },
languageOptionDescription: { languageOptionDescription: {
fontSize: 12, fontSize: 12,
color: '#6C757D', color: '#6C757D',
marginTop: 4, marginTop: 4,
fontFamily: 'AliRegular',
}, },
languageModalClose: { languageModalClose: {
marginTop: 4, marginTop: 4,
@@ -1247,5 +1266,6 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
color: '#9370DB', color: '#9370DB',
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -7,11 +7,15 @@ import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { ChallengeSource } from '@/services/challengesApi'; import { ChallengeSource } from '@/services/challengesApi';
import { import {
archiveCustomChallengeThunk,
fetchChallengeDetail, fetchChallengeDetail,
fetchChallengeRankings, fetchChallengeRankings,
fetchChallenges,
joinChallenge, joinChallenge,
leaveChallenge, leaveChallenge,
reportChallengeProgress, reportChallengeProgress,
selectArchiveError,
selectArchiveStatus,
selectChallengeById, selectChallengeById,
selectChallengeDetailError, selectChallengeDetailError,
selectChallengeDetailStatus, selectChallengeDetailStatus,
@@ -117,6 +121,10 @@ export default function ChallengeDetailScreen() {
const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle')); const leaveStatus = useAppSelector((state) => (leaveStatusSelector ? leaveStatusSelector(state) : 'idle'));
const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]); const leaveErrorSelector = useMemo(() => (id ? selectLeaveError(id) : undefined), [id]);
const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined)); const leaveError = useAppSelector((state) => (leaveErrorSelector ? leaveErrorSelector(state) : undefined));
const archiveStatusSelector = useMemo(() => (id ? selectArchiveStatus(id) : undefined), [id]);
const archiveStatus = useAppSelector((state) => (archiveStatusSelector ? archiveStatusSelector(state) : 'idle'));
const archiveErrorSelector = useMemo(() => (id ? selectArchiveError(id) : undefined), [id]);
const archiveError = useAppSelector((state) => (archiveErrorSelector ? archiveErrorSelector(state) : undefined));
const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]); const progressStatusSelector = useMemo(() => (id ? selectProgressStatus(id) : undefined), [id]);
const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle')); const progressStatus = useAppSelector((state) => (progressStatusSelector ? progressStatusSelector(state) : 'idle'));
@@ -160,9 +168,13 @@ export default function ChallengeDetailScreen() {
}; };
}, [showCelebration]); }, [showCelebration]);
const progress = challenge?.progress; const progress = challenge?.progress;
const isJoined = challenge?.isJoined ?? false; const isJoined = challenge?.isJoined ?? false;
const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM; const isCustomChallenge = challenge?.source === ChallengeSource.CUSTOM;
const isCreator = challenge?.isCreator ?? false;
const isCustomCreator = isCustomChallenge && isCreator;
const canEdit = isCustomChallenge && isCreator;
const lastProgressAt = useMemo(() => { const lastProgressAt = useMemo(() => {
const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined; const progressRecord = challenge?.progress as { lastProgressAt?: string; last_progress_at?: string } | undefined;
return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at; return progressRecord?.lastProgressAt ?? progressRecord?.last_progress_at;
@@ -275,6 +287,20 @@ export default function ChallengeDetailScreen() {
} }
}; };
const handleArchive = async () => {
if (!id || archiveStatus === 'loading') {
return;
}
try {
await dispatch(archiveCustomChallengeThunk(id)).unwrap();
Toast.success(t('challengeDetail.alert.archiveSuccess'));
await dispatch(fetchChallenges());
router.back();
} catch (error) {
Toast.error(t('challengeDetail.alert.archiveFailed'));
}
};
const handleLeaveConfirm = () => { const handleLeaveConfirm = () => {
if (!id || leaveStatus === 'loading') { if (!id || leaveStatus === 'loading') {
return; return;
@@ -295,6 +321,26 @@ export default function ChallengeDetailScreen() {
); );
}; };
const handleArchiveConfirm = () => {
if (!id || archiveStatus === 'loading') {
return;
}
Alert.alert(
t('challengeDetail.alert.archiveConfirm.title'),
t('challengeDetail.alert.archiveConfirm.message'),
[
{ text: t('challengeDetail.alert.archiveConfirm.cancel'), style: 'cancel' },
{
text: t('challengeDetail.alert.archiveConfirm.confirm'),
style: 'destructive',
onPress: () => {
void handleArchive();
},
},
]
);
};
const handleProgressReport = async () => { const handleProgressReport = async () => {
if (!id || progressStatus === 'loading') { if (!id || progressStatus === 'loading') {
return; return;
@@ -391,6 +437,9 @@ export default function ChallengeDetailScreen() {
const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join'); const joinCtaLabel = joinStatus === 'loading' ? t('challengeDetail.cta.joining') : challenge.ctaLabel ?? t('challengeDetail.cta.join');
const isUpcoming = challenge.status === 'upcoming'; const isUpcoming = challenge.status === 'upcoming';
const isExpired = challenge.status === 'expired'; const isExpired = challenge.status === 'expired';
const deleteCtaLabel = archiveStatus === 'loading'
? t('challengeDetail.cta.deleting')
: t('challengeDetail.cta.delete');
const upcomingStartLabel = formatMonthDay(challenge.startAt); const upcomingStartLabel = formatMonthDay(challenge.startAt);
const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title'); const upcomingHighlightTitle = t('challengeDetail.highlight.upcoming.title');
const upcomingHighlightSubtitle = upcomingStartLabel const upcomingHighlightSubtitle = upcomingStartLabel
@@ -420,11 +469,18 @@ export default function ChallengeDetailScreen() {
? `分享码 ${challenge?.shareCode ?? ''}` ? `分享码 ${challenge?.shareCode ?? ''}`
: leaveHighlightTitle; : leaveHighlightTitle;
floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle; floatingHighlightSubtitle = showShareCode ? '' : leaveHighlightSubtitle;
if (isCustomCreator) {
floatingCtaLabel = deleteCtaLabel;
floatingOnPress = handleArchiveConfirm;
floatingDisabled = archiveStatus === 'loading';
floatingError = archiveError;
} else {
floatingCtaLabel = leaveCtaLabel; floatingCtaLabel = leaveCtaLabel;
floatingOnPress = handleLeaveConfirm; floatingOnPress = handleLeaveConfirm;
floatingDisabled = leaveStatus === 'loading'; floatingDisabled = leaveStatus === 'loading';
floatingError = leaveError; floatingError = leaveError;
} }
}
if (isUpcoming) { if (isUpcoming) {
floatingHighlightTitle = upcomingHighlightTitle; floatingHighlightTitle = upcomingHighlightTitle;
@@ -573,7 +629,40 @@ export default function ChallengeDetailScreen() {
transparent transparent
withSafeTop={false} withSafeTop={false}
right={ right={
<View style={styles.headerButtons}>
{canEdit && (
isLiquidGlassAvailable() ? ( isLiquidGlassAvailable() ? (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={styles.editButton}
>
<GlassView
style={styles.editButtonGlass}
glassEffectStyle="clear"
tintColor="rgba(255, 255, 255, 0.3)"
isInteractive={true}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</GlassView>
</TouchableOpacity>
) : (
<TouchableOpacity
onPress={() => router.push({
pathname: '/challenges/create-custom',
params: { id, mode: 'edit' }
})}
activeOpacity={0.7}
style={[styles.editButton, styles.fallbackEditButton]}
>
<Ionicons name="create-outline" size={20} color="#ffffff" />
</TouchableOpacity>
)
)}
{isLiquidGlassAvailable() ? (
<TouchableOpacity <TouchableOpacity
onPress={handleShare} onPress={handleShare}
activeOpacity={0.7} activeOpacity={0.7}
@@ -595,7 +684,8 @@ export default function ChallengeDetailScreen() {
> >
<Ionicons name="share-social-outline" size={20} color="#ffffff" /> <Ionicons name="share-social-outline" size={20} color="#ffffff" />
</TouchableOpacity> </TouchableOpacity>
) )}
</View>
} }
/> />
</View> </View>
@@ -746,8 +836,84 @@ export default function ChallengeDetailScreen() {
</View> </View>
</ScrollView> </ScrollView>
<View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom }]}> <View pointerEvents="box-none" style={[styles.floatingCTAContainer, { paddingBottom: insets.bottom || 20 }]}>
<BlurView intensity={10} tint="light" style={styles.floatingCTABlur}> {isLiquidGlassAvailable() ? (
<View style={styles.glassWrapper}>
{/* 顶部高光线条 */}
<LinearGradient
colors={['rgba(255,255,255,0.9)', 'rgba(255,255,255,0.2)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.glassHighlight}
/>
<GlassView
style={styles.glassContainer}
glassEffectStyle="regular"
tintColor="rgba(243, 244, 251, 0.55)"
isInteractive={true}
>
{/* 内部微光渐变 */}
<LinearGradient
colors={['rgba(255,255,255,0.6)', 'rgba(255,255,255,0.0)']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 0.6 }}
style={StyleSheet.absoluteFill}
pointerEvents="none"
/>
<View style={styles.floatingCTAContent}>
{showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
<View style={styles.shareCodeRow}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<TouchableOpacity
activeOpacity={0.7}
style={styles.shareCodeIconButton}
onPress={handleCopyShareCode}
>
<Ionicons name="copy-outline" size={18} color="#4F5BD5" />
</TouchableOpacity>
</View>
{floatingHighlightSubtitle ? (
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
) : null}
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
) : (
<View style={styles.highlightCopy}>
<Text style={styles.highlightTitle}>{floatingHighlightTitle}</Text>
<Text style={styles.highlightSubtitle}>{floatingHighlightSubtitle}</Text>
{floatingError ? <Text style={styles.ctaErrorText}>{floatingError}</Text> : null}
</View>
)}
<TouchableOpacity
style={styles.highlightButton}
activeOpacity={0.85}
onPress={floatingOnPress}
disabled={floatingDisabled}
>
<LinearGradient
colors={floatingGradientColors}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.highlightButtonBackground}
>
{/* 按钮内部高光 */}
<LinearGradient
colors={['rgba(255,255,255,0.4)', 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 0.5 }}
style={StyleSheet.absoluteFill}
/>
<Text style={[styles.highlightButtonLabel, isDisabledButtonState && styles.highlightButtonLabelDisabled]}>
{floatingCtaLabel}
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
</GlassView>
</View>
) : (
<BlurView intensity={20} tint="light" style={styles.floatingCTABlur}>
<View style={styles.floatingCTAContent}> <View style={styles.floatingCTAContent}>
{showShareCode ? ( {showShareCode ? (
<View style={[styles.highlightCopy, styles.highlightCopyCompact]}> <View style={[styles.highlightCopy, styles.highlightCopyCompact]}>
@@ -792,6 +958,7 @@ export default function ChallengeDetailScreen() {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</BlurView> </BlurView>
)}
</View> </View>
</View> </View>
{showCelebration && ( {showCelebration && (
@@ -850,13 +1017,47 @@ const styles = StyleSheet.create({
right: 0, right: 0,
bottom: 0, bottom: 0,
paddingHorizontal: 20, paddingHorizontal: 20,
zIndex: 100,
}, },
floatingCTABlur: { floatingCTABlur: {
borderRadius: 24, borderRadius: 24,
overflow: 'hidden', overflow: 'hidden',
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255,255,255,0.6)', borderColor: 'rgba(255,255,255,0.6)',
backgroundColor: 'rgba(243, 244, 251, 0.85)', backgroundColor: 'rgba(243, 244, 251, 0.9)',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 5,
},
glassWrapper: {
borderRadius: 24,
overflow: 'hidden',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
shadowColor: '#5E8BFF',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.18,
shadowRadius: 20,
elevation: 10,
},
glassHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
zIndex: 2,
opacity: 0.9,
},
glassContainer: {
borderRadius: 24,
overflow: 'hidden',
}, },
floatingCTAContent: { floatingCTAContent: {
flexDirection: 'row', flexDirection: 'row',
@@ -890,6 +1091,7 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
color: '#596095', color: '#596095',
letterSpacing: 0.2, letterSpacing: 0.2,
fontFamily: 'AliRegular',
}, },
title: { title: {
marginTop: 10, marginTop: 10,
@@ -897,6 +1099,7 @@ const styles = StyleSheet.create({
fontWeight: '800', fontWeight: '800',
color: '#1c1f3a', color: '#1c1f3a',
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliBold'
}, },
summary: { summary: {
marginTop: 12, marginTop: 12,
@@ -904,6 +1107,7 @@ const styles = StyleSheet.create({
lineHeight: 20, lineHeight: 20,
color: '#7080b4', color: '#7080b4',
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliRegular',
}, },
inlineError: { inlineError: {
marginTop: 12, marginTop: 12,
@@ -950,11 +1154,13 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
detailMeta: { detailMeta: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#6f7ba7', color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
avatarRow: { avatarRow: {
flexDirection: 'row', flexDirection: 'row',
@@ -982,6 +1188,7 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
color: '#4F5BD5', color: '#4F5BD5',
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliRegular',
}, },
checkInCard: { checkInCard: {
marginTop: 4, marginTop: 4,
@@ -999,12 +1206,14 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
checkInSubtitle: { checkInSubtitle: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#6f7ba7', color: '#6f7ba7',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
checkInButton: { checkInButton: {
borderRadius: 18, borderRadius: 18,
@@ -1022,6 +1231,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
fontFamily: 'AliBold',
}, },
checkInButtonLabelDisabled: { checkInButtonLabelDisabled: {
color: '#6f7799', color: '#6f7799',
@@ -1037,11 +1247,13 @@ const styles = StyleSheet.create({
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
sectionAction: { sectionAction: {
fontSize: 13, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: '#5F6BF0', color: '#5F6BF0',
fontFamily: 'AliBold',
}, },
sectionSubtitle: { sectionSubtitle: {
marginTop: 8, marginTop: 8,
@@ -1049,6 +1261,7 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: '#6f7ba7', color: '#6f7ba7',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
rankingCard: { rankingCard: {
marginTop: 20, marginTop: 20,
@@ -1069,17 +1282,20 @@ const styles = StyleSheet.create({
emptyRankingText: { emptyRankingText: {
fontSize: 14, fontSize: 14,
color: '#6f7ba7', color: '#6f7ba7',
fontFamily: 'AliRegular',
}, },
highlightTitle: { highlightTitle: {
fontSize: 16, fontSize: 16,
fontWeight: '700', fontWeight: '700',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
highlightSubtitle: { highlightSubtitle: {
marginTop: 4, marginTop: 4,
fontSize: 12, fontSize: 12,
color: '#5f6a97', color: '#5f6a97',
lineHeight: 18, lineHeight: 18,
fontFamily: 'AliRegular',
}, },
shareCodeIconButton: { shareCodeIconButton: {
paddingHorizontal: 4, paddingHorizontal: 4,
@@ -1105,10 +1321,38 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '700', fontWeight: '700',
color: '#ffffff', color: '#ffffff',
fontFamily: 'AliBold',
}, },
highlightButtonLabelDisabled: { highlightButtonLabelDisabled: {
color: '#6f7799', color: '#6f7799',
}, },
headerButtons: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
editButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
editButtonGlass: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fallbackEditButton: {
backgroundColor: 'rgba(255, 255, 255, 0.24)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.45)',
},
shareButton: { shareButton: {
width: 40, width: 40,
height: 40, height: 40,
@@ -1131,6 +1375,7 @@ const styles = StyleSheet.create({
missingText: { missingText: {
fontSize: 16, fontSize: 16,
textAlign: 'center', textAlign: 'center',
fontFamily: 'AliRegular',
}, },
retryButton: { retryButton: {
marginTop: 18, marginTop: 18,
@@ -1142,6 +1387,7 @@ const styles = StyleSheet.create({
retryText: { retryText: {
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
celebrationOverlay: { celebrationOverlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -1185,6 +1431,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 }, textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4, textShadowRadius: 4,
fontFamily: 'AliBold',
}, },
shareCardSummary: { shareCardSummary: {
fontSize: 15, fontSize: 15,
@@ -1195,6 +1442,7 @@ const styles = StyleSheet.create({
textShadowColor: 'rgba(0, 0, 0, 0.25)', textShadowColor: 'rgba(0, 0, 0, 0.25)',
textShadowOffset: { width: 0, height: 1 }, textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3, textShadowRadius: 3,
fontFamily: 'AliRegular',
}, },
shareProgressContainer: { shareProgressContainer: {
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'rgba(255, 255, 255, 0.95)',
@@ -1229,11 +1477,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
shareInfoMeta: { shareInfoMeta: {
fontSize: 12, fontSize: 12,
color: '#707baf', color: '#707baf',
marginTop: 2, marginTop: 2,
fontFamily: 'AliRegular',
}, },
shareProgressHeader: { shareProgressHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -1245,11 +1495,13 @@ const styles = StyleSheet.create({
fontSize: 14, fontSize: 14,
fontWeight: '600', fontWeight: '600',
color: '#1c1f3a', color: '#1c1f3a',
fontFamily: 'AliBold',
}, },
shareProgressValue: { shareProgressValue: {
fontSize: 18, fontSize: 18,
fontWeight: '800', fontWeight: '800',
color: '#5E8BFF', color: '#5E8BFF',
fontFamily: 'AliBold',
}, },
shareProgressTrack: { shareProgressTrack: {
height: 8, height: 8,
@@ -1268,6 +1520,7 @@ const styles = StyleSheet.create({
marginTop: 12, marginTop: 12,
textAlign: 'center', textAlign: 'center',
fontWeight: '500', fontWeight: '500',
fontFamily: 'AliRegular',
}, },
shareCardFooter: { shareCardFooter: {
alignItems: 'center', alignItems: 'center',
@@ -1278,5 +1531,6 @@ const styles = StyleSheet.create({
color: '#ffffff', color: '#ffffff',
opacity: 0.8, opacity: 0.8,
fontWeight: '600', fontWeight: '600',
fontFamily: 'AliBold',
}, },
}); });

View File

@@ -4,7 +4,7 @@ import * as Clipboard from 'expo-clipboard';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Alert, Alert,
@@ -27,22 +27,32 @@ import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux'; import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme'; import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload'; import { useCosUpload } from '@/hooks/useCosUpload';
import { ChallengeType, type CreateCustomChallengePayload } from '@/services/challengesApi'; import { useI18n } from '@/hooks/useI18n';
import {
ChallengeType,
type CreateCustomChallengePayload,
type UpdateCustomChallengePayload,
} from '@/services/challengesApi';
import { store } from '@/store';
import { import {
createCustomChallengeThunk, createCustomChallengeThunk,
fetchChallenges, fetchChallenges,
selectChallengeById,
selectCreateChallengeError, selectCreateChallengeError,
selectCreateChallengeStatus, selectCreateChallengeStatus,
selectUpdateChallengeError,
selectUpdateChallengeStatus,
updateCustomChallengeThunk
} from '@/store/challengesSlice'; } from '@/store/challengesSlice';
import { Toast } from '@/utils/toast.utils'; import { Toast } from '@/utils/toast.utils';
const typeOptions: { value: ChallengeType; label: string; accent: string }[] = [ const getTypeOptions = (t: (key: string) => string): { value: ChallengeType; label: string; accent: string }[] => [
{ value: ChallengeType.WATER, label: '喝水', accent: '#5E8BFF' }, { value: ChallengeType.WATER, label: t('challenges.createCustom.typeLabels.water'), accent: '#5E8BFF' },
{ value: ChallengeType.EXERCISE, label: '运动', accent: '#6B6CFF' }, { value: ChallengeType.EXERCISE, label: t('challenges.createCustom.typeLabels.exercise'), accent: '#6B6CFF' },
{ value: ChallengeType.DIET, label: '饮食', accent: '#38BDF8' }, { value: ChallengeType.DIET, label: t('challenges.createCustom.typeLabels.diet'), accent: '#38BDF8' },
{ value: ChallengeType.SLEEP, label: '睡眠', accent: '#7C3AED' }, { value: ChallengeType.SLEEP, label: t('challenges.createCustom.typeLabels.sleep'), accent: '#7C3AED' },
{ value: ChallengeType.MOOD, label: '心情', accent: '#F97316' }, { value: ChallengeType.MOOD, label: t('challenges.createCustom.typeLabels.mood'), accent: '#F97316' },
{ value: ChallengeType.WEIGHT, label: '体重', accent: '#22C55E' }, { value: ChallengeType.WEIGHT, label: t('challenges.createCustom.typeLabels.weight'), accent: '#22C55E' },
]; ];
const FALLBACK_IMAGE = const FALLBACK_IMAGE =
@@ -51,6 +61,9 @@ const FALLBACK_IMAGE =
type PickerType = 'start' | 'end' | null; type PickerType = 'start' | 'end' | null;
export default function CreateCustomChallengeScreen() { export default function CreateCustomChallengeScreen() {
const { id, mode } = useLocalSearchParams<{ id?: string; mode?: 'edit' }>();
const isEditMode = mode === 'edit' && !!id;
const theme = (useColorScheme() ?? 'light') as 'light' | 'dark'; const theme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const colorTokens = Colors[theme]; const colorTokens = Colors[theme];
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@@ -58,7 +71,14 @@ export default function CreateCustomChallengeScreen() {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const createStatus = useAppSelector(selectCreateChallengeStatus); const createStatus = useAppSelector(selectCreateChallengeStatus);
const createError = useAppSelector(selectCreateChallengeError); const createError = useAppSelector(selectCreateChallengeError);
const updateError = useAppSelector(selectUpdateChallengeError);
const updateStatus = useAppSelector(selectUpdateChallengeStatus);
const inlineError = isEditMode ? updateError : createError;
const isCreating = createStatus === 'loading'; const isCreating = createStatus === 'loading';
const isUpdating = updateStatus === 'loading';
const { t } = useI18n();
const typeOptions = useMemo(() => getTypeOptions(t), [t]);
const today = useMemo(() => dayjs().startOf('day').toDate(), []); const today = useMemo(() => dayjs().startOf('day').toDate(), []);
const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []); const defaultEnd = useMemo(() => dayjs().add(21, 'day').startOf('day').toDate(), []);
@@ -74,7 +94,7 @@ export default function CreateCustomChallengeScreen() {
const [minimumCheckInDays, setMinimumCheckInDays] = useState(''); const [minimumCheckInDays, setMinimumCheckInDays] = useState('');
const [requirementLabel, setRequirementLabel] = useState(''); const [requirementLabel, setRequirementLabel] = useState('');
const [summary, setSummary] = useState(''); const [summary, setSummary] = useState('');
const [progressUnit] = useState(''); const [progressUnit, setProgressUnit] = useState('');
const [periodLabel, setPeriodLabel] = useState(''); const [periodLabel, setPeriodLabel] = useState('');
const [periodEdited, setPeriodEdited] = useState(false); const [periodEdited, setPeriodEdited] = useState(false);
const [rankingDescription] = useState('连续打卡榜'); const [rankingDescription] = useState('连续打卡榜');
@@ -88,6 +108,29 @@ export default function CreateCustomChallengeScreen() {
const [pickerType, setPickerType] = useState<PickerType>(null); const [pickerType, setPickerType] = useState<PickerType>(null);
// 编辑模式下预填充数据
useEffect(() => {
if (isEditMode && id) {
const challengeSelector = selectChallengeById(id);
const challenge = challengeSelector(store.getState());
if (challenge) {
setTitle(challenge.title || '');
setImage(challenge.image);
setType(challenge.type);
setStartDate(new Date(challenge.startAt || Date.now()));
setEndDate(new Date(challenge.endAt || Date.now()));
setTargetValue(String(challenge.progress?.target || ''));
setMinimumCheckInDays(String(challenge.minimumCheckInDays || ''));
setRequirementLabel(challenge.requirementLabel || '');
setSummary(challenge.summary || '');
setProgressUnit(challenge.unit || '');
setPeriodLabel(challenge.periodLabel || '');
setIsPublic(challenge.isPublic ?? true);
setMaxParticipants(challenge.maxParticipants?.toString() || '100');
}
}
}, [isEditMode, id]);
const durationDays = useMemo( const durationDays = useMemo(
() => () =>
Math.max( Math.max(
@@ -96,16 +139,16 @@ export default function CreateCustomChallengeScreen() {
), ),
[startDate, endDate] [startDate, endDate]
); );
const durationLabel = useMemo(() => `持续${durationDays}`, [durationDays]); const durationLabel = useMemo(() => t('challenges.createCustom.durationDays', { days: durationDays }), [durationDays, t]);
useEffect(() => { useEffect(() => {
if (!periodEdited) { if (!periodEdited) {
setPeriodLabel(`${durationDays}天挑战`); setPeriodLabel(t('challenges.createCustom.durationDaysChallenge', { days: durationDays }));
} }
if (!minimumEdited) { if (!minimumEdited) {
setMinimumCheckInDays(String(durationDays)); setMinimumCheckInDays(String(durationDays));
} }
}, [durationDays, minimumEdited, periodEdited]); }, [durationDays, minimumEdited, periodEdited, t]);
const handleConfirmDate = (date: Date) => { const handleConfirmDate = (date: Date) => {
if (!pickerType) return; if (!pickerType) return;
@@ -128,47 +171,47 @@ export default function CreateCustomChallengeScreen() {
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (isCreating) return; if (isCreating || isUpdating) return;
if (!title.trim()) { if (!title.trim()) {
Toast.warning('请填写挑战标题'); Toast.warning(t('challenges.createCustom.alerts.titleRequired'));
return; return;
} }
if (!requirementLabel.trim()) { if (!requirementLabel.trim()) {
Toast.warning('请填写挑战要求说明'); Toast.warning(t('challenges.createCustom.alerts.requirementRequired'));
return; return;
} }
const startTimestamp = dayjs(startDate).valueOf(); const startTimestamp = dayjs(startDate).valueOf();
const endTimestamp = dayjs(endDate).valueOf(); const endTimestamp = dayjs(endDate).valueOf();
if (endTimestamp <= startTimestamp) { if (endTimestamp <= startTimestamp) {
Toast.warning('结束时间需要晚于开始时间'); Toast.warning(t('challenges.createCustom.alerts.endTimeError'));
return; return;
} }
const target = Number(targetValue); const target = Number(targetValue);
if (!Number.isFinite(target) || target < 1 || target > 1000) { if (!Number.isFinite(target) || target < 1 || target > 1000) {
Toast.warning('每日目标值需在 1-1000 之间'); Toast.warning(t('challenges.createCustom.alerts.targetValueError'));
return; return;
} }
const minDays = Number(minimumCheckInDays) || durationDays; const minDays = Number(minimumCheckInDays) || durationDays;
if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) { if (!Number.isFinite(minDays) || minDays < 1 || minDays > 365) {
Toast.warning('最少打卡天数需在 1-365 之间'); Toast.warning(t('challenges.createCustom.alerts.minimumDaysError'));
return; return;
} }
if (minDays > durationDays) { if (minDays > durationDays) {
Toast.warning('最少打卡天数不能超过持续天数'); Toast.warning(t('challenges.createCustom.alerts.minimumDaysExceedError'));
return; return;
} }
const maxP = maxParticipants ? Number(maxParticipants) : null; const maxP = maxParticipants ? Number(maxParticipants) : null;
if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) { if (maxP !== null && (!Number.isFinite(maxP) || maxP < 2 || maxP > 10000)) {
Toast.warning('参与人数需在 2-10000 之间,或留空表示无限制'); Toast.warning(t('challenges.createCustom.alerts.participantsError'));
return; return;
} }
const safeTitle = title.trim() || '自定义挑战'; const safeTitle = title.trim() || t('challenges.createCustom.defaultTitle');
const payload: CreateCustomChallengePayload = { const payload: CreateCustomChallengePayload = {
title: safeTitle, title: safeTitle,
type, type,
@@ -178,24 +221,39 @@ export default function CreateCustomChallengeScreen() {
targetValue: target, targetValue: target,
minimumCheckInDays: minDays, minimumCheckInDays: minDays,
durationLabel, durationLabel,
requirementLabel: requirementLabel.trim() || '请填写挑战要求', requirementLabel: requirementLabel.trim(),
summary: summary.trim() || undefined, summary: summary.trim() || undefined,
progressUnit: progressUnit.trim() || '天', progressUnit: progressUnit.trim(),
periodLabel: periodLabel.trim() || undefined, periodLabel: periodLabel.trim() || undefined,
rankingDescription: rankingDescription.trim() || undefined, rankingDescription: rankingDescription.trim() || undefined,
isPublic, isPublic,
maxParticipants: maxP, maxParticipants: maxP,
}; };
const updatePayload: UpdateCustomChallengePayload = {
title: safeTitle,
image: image?.trim() || undefined,
summary: summary.trim() || undefined,
isPublic,
maxParticipants: maxP ?? undefined,
};
try { try {
if (isEditMode && id) {
await dispatch(updateCustomChallengeThunk({ id, payload: updatePayload })).unwrap();
Toast.success(t('challenges.createCustom.alerts.updateSuccess'));
dispatch(fetchChallenges());
return;
}
const created = await dispatch(createCustomChallengeThunk(payload)).unwrap(); const created = await dispatch(createCustomChallengeThunk(payload)).unwrap();
setShareCode(created.shareCode ?? null); setShareCode(created.shareCode ?? null);
setCreatedChallengeId(created.id); setCreatedChallengeId(created.id);
setShareModalVisible(true); setShareModalVisible(true);
Toast.success('自定义挑战已创建'); Toast.success(t('challenges.createCustom.alerts.createSuccess'));
dispatch(fetchChallenges()); dispatch(fetchChallenges());
} catch (error) { } catch (error) {
const message = typeof error === 'string' ? error : '创建失败,请稍后再试'; const message = typeof error === 'string' ? error : t('challenges.createCustom.alerts.createFailed');
Toast.error(message); Toast.error(message);
} }
}; };
@@ -203,7 +261,7 @@ export default function CreateCustomChallengeScreen() {
const handleCopyShareCode = async () => { const handleCopyShareCode = async () => {
if (!shareCode) return; if (!shareCode) return;
await Clipboard.setStringAsync(shareCode); await Clipboard.setStringAsync(shareCode);
Toast.success('邀请码已复制'); Toast.success(t('challenges.createCustom.shareModal.copyCode'));
}; };
const handleTargetInputChange = (value: string) => { const handleTargetInputChange = (value: string) => {
@@ -235,16 +293,16 @@ export default function CreateCustomChallengeScreen() {
const handlePickImage = useCallback(() => { const handlePickImage = useCallback(() => {
Alert.alert( Alert.alert(
'选择封面图', t('challenges.createCustom.imageUpload.selectSource'),
'请选择封面来源', t('challenges.createCustom.imageUpload.selectMessage'),
[ [
{ {
text: '拍照', text: t('challenges.createCustom.imageUpload.camera'),
onPress: async () => { onPress: async () => {
try { try {
const permission = await ImagePicker.requestCameraPermissionsAsync(); const permission = await ImagePicker.requestCameraPermissionsAsync();
if (permission.status !== 'granted') { if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相机权限以拍摄封面'); Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.cameraPermissionMessage'));
return; return;
} }
const result = await ImagePicker.launchCameraAsync({ const result = await ImagePicker.launchCameraAsync({
@@ -269,21 +327,21 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null); setImagePreview(null);
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 封面上传失败', error); console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试'); Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
} }
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 拍照失败', error); console.error('[CHALLENGE] 拍照失败', error);
Alert.alert('拍照失败', '无法打开相机,请稍后再试'); Alert.alert(t('challenges.createCustom.imageUpload.cameraFailed'), t('challenges.createCustom.imageUpload.cameraFailedMessage'));
} }
}, },
}, },
{ {
text: '从相册选择', text: t('challenges.createCustom.imageUpload.album'),
onPress: async () => { onPress: async () => {
try { try {
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (permission.status !== 'granted') { if (permission.status !== 'granted') {
Alert.alert('权限不足', '需要相册权限以选择封面'); Alert.alert(t('challenges.createCustom.imageUpload.cameraPermission'), t('challenges.createCustom.imageUpload.albumPermissionMessage'));
return; return;
} }
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
@@ -307,19 +365,19 @@ export default function CreateCustomChallengeScreen() {
setImagePreview(null); setImagePreview(null);
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 封面上传失败', error); console.error('[CHALLENGE] 封面上传失败', error);
Alert.alert('上传失败', '封面上传失败,请稍后重试'); Alert.alert(t('challenges.createCustom.imageUpload.uploadFailed'), t('challenges.createCustom.imageUpload.uploadFailedMessage'));
} }
} catch (error) { } catch (error) {
console.error('[CHALLENGE] 选择封面失败', error); console.error('[CHALLENGE] 选择封面失败', error);
Alert.alert('选择失败', '无法打开相册,请稍后再试'); Alert.alert(t('challenges.createCustom.imageUpload.selectFailed'), t('challenges.createCustom.imageUpload.selectFailedMessage'));
} }
}, },
}, },
{ text: '取消', style: 'cancel' }, { text: t('challenges.createCustom.imageUpload.cancel'), style: 'cancel' },
], ],
{ cancelable: true } { cancelable: true }
); );
}, [upload]); }, [upload, t]);
const handleViewChallenge = () => { const handleViewChallenge = () => {
setShareModalVisible(false); setShareModalVisible(false);
@@ -370,7 +428,7 @@ export default function CreateCustomChallengeScreen() {
</View> </View>
); );
const progressMeta = `${durationDays} · ${progressUnit || ''}`; const progressMeta = `${durationDays} ${t('challenges.createCustom.dayUnit')}${progressUnit ? ` · ${progressUnit}` : ''}`;
const heroImageSource = imagePreview || image || FALLBACK_IMAGE; const heroImageSource = imagePreview || image || FALLBACK_IMAGE;
return ( return (
@@ -379,7 +437,7 @@ export default function CreateCustomChallengeScreen() {
colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]} colors={[colorTokens.backgroundGradientStart, colorTokens.backgroundGradientEnd]}
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
<HeaderBar title="新建挑战" transparent /> <HeaderBar title={isEditMode ? t('challenges.createCustom.editTitle') : t('challenges.createCustom.title')} transparent />
<KeyboardAvoidingView <KeyboardAvoidingView
style={{ flex: 1 }} style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined} behavior={Platform.OS === 'ios' ? 'padding' : undefined}
@@ -403,20 +461,20 @@ export default function CreateCustomChallengeScreen() {
style={StyleSheet.absoluteFillObject} style={StyleSheet.absoluteFillObject}
/> />
<View style={styles.heroOverlay}> <View style={styles.heroOverlay}>
<Text style={styles.heroKicker}></Text> <Text style={styles.heroKicker}>{t('challenges.customChallenges')}</Text>
<Text style={styles.heroTitle}>{title || '你的专属挑战'}</Text> <Text style={styles.heroTitle}>{title || t('challenges.createCustom.yourChallenge')}</Text>
<Text style={styles.heroMeta}>{progressMeta}</Text> <Text style={styles.heroMeta}>{progressMeta}</Text>
</View> </View>
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<View style={styles.formHeader}> <View style={styles.formHeader}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.basicInfo')}</Text>
{createError ? <Text style={styles.inlineError}>{createError}</Text> : null} {inlineError ? <Text style={styles.inlineError}>{inlineError}</Text> : null}
</View> </View>
{renderField('标题', title, setTitle, '挑战标题最多100字')} {renderField(t('challenges.createCustom.fields.title'), title, setTitle, t('challenges.createCustom.fields.titlePlaceholder'))}
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.coverImage')}</Text>
<View style={styles.uploadRow}> <View style={styles.uploadRow}>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -424,7 +482,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handlePickImage} onPress={handlePickImage}
disabled={uploading} disabled={uploading}
> >
<Text style={styles.uploadButtonLabel}>{uploading ? '上传中…' : '上传封面'}</Text> <Text style={styles.uploadButtonLabel}>{uploading ? t('challenges.createCustom.imageUpload.uploading') : t('challenges.createCustom.fields.uploadCover')}</Text>
</TouchableOpacity> </TouchableOpacity>
{image || imagePreview ? ( {image || imagePreview ? (
<TouchableOpacity <TouchableOpacity
@@ -434,20 +492,20 @@ export default function CreateCustomChallengeScreen() {
setImage(undefined); setImage(undefined);
}} }}
> >
<Text style={styles.clearUpload}></Text> <Text style={styles.clearUpload}>{t('challenges.createCustom.imageUpload.clear')}</Text>
</TouchableOpacity> </TouchableOpacity>
) : null} ) : null}
</View> </View>
<Text style={styles.helperText}> 16:9</Text> <Text style={styles.helperText}>{t('challenges.createCustom.imageUpload.helper')}</Text>
</View> </View>
{renderTextarea('挑战说明', summary, setSummary, '简单介绍这个挑战的目标与要求')} {renderTextarea(t('challenges.createCustom.fields.challengeDescription'), summary, setSummary, t('challenges.createCustom.fields.descriptionPlaceholder'))}
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<Text style={styles.sectionTitle}></Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.challengeSettings')}</Text>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.challengeType')}</Text>
<View style={styles.chipRow}> <View style={styles.chipRow}>
{typeOptions.map((option) => { {typeOptions.map((option) => {
const active = option.value === type; const active = option.value === type;
@@ -476,14 +534,14 @@ export default function CreateCustomChallengeScreen() {
</View> </View>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.timeRange')}</Text>
<View style={styles.dateRow}> <View style={styles.dateRow}>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
style={styles.datePill} style={styles.datePill}
onPress={() => setPickerType('start')} onPress={() => setPickerType('start')}
> >
<Text style={styles.dateLabel}></Text> <Text style={styles.dateLabel}>{t('challenges.createCustom.fields.start')}</Text>
<Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text> <Text style={styles.dateValue}>{dayjs(startDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@@ -491,7 +549,7 @@ export default function CreateCustomChallengeScreen() {
style={styles.datePill} style={styles.datePill}
onPress={() => setPickerType('end')} onPress={() => setPickerType('end')}
> >
<Text style={styles.dateLabel}></Text> <Text style={styles.dateLabel}>{t('challenges.createCustom.fields.end')}</Text>
<Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text> <Text style={styles.dateValue}>{dayjs(endDate).format('YYYY.MM.DD')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -499,48 +557,60 @@ export default function CreateCustomChallengeScreen() {
<View style={styles.inlineFields}> <View style={styles.inlineFields}>
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.duration')}</Text>
<View style={styles.readonlyPill}> <View style={styles.readonlyPill}>
<Text style={styles.readonlyText}>{durationLabel}</Text> <Text style={styles.readonlyText}>{durationLabel}</Text>
</View> </View>
</View> </View>
{renderField('周期标签', periodLabel, (v) => { {renderField(t('challenges.createCustom.fields.periodLabel'), periodLabel, (v) => {
setPeriodEdited(true); setPeriodEdited(true);
setPeriodLabel(v); setPeriodLabel(v);
}, '如21天挑战')} }, t('challenges.createCustom.fields.periodLabelPlaceholder'))}
</View> </View>
<View style={styles.inlineFields}>
{renderField('每日目标值', targetValue, handleTargetInputChange, '如8', 'numeric')}
<View style={styles.fieldBlock}> <View style={styles.fieldBlock}>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.dailyTargetAndUnit')}</Text>
<View style={styles.readonlyPill}> <View style={styles.targetUnitRow}>
<Text style={styles.readonlyText}>{progressUnit}</Text> <TextInput
</View> value={targetValue}
onChangeText={handleTargetInputChange}
placeholder={t('challenges.createCustom.fields.dailyTargetPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.targetInput]}
keyboardType="numeric"
/>
<TextInput
value={progressUnit}
onChangeText={setProgressUnit}
placeholder={t('challenges.createCustom.fields.unitPlaceholder')}
placeholderTextColor="#9ca3af"
style={[styles.input, styles.unitInput]}
/>
</View> </View>
<Text style={styles.helperText}>{t('challenges.createCustom.fields.unitHelper')}</Text>
</View> </View>
{renderField('最少打卡天数', minimumCheckInDays, handleMinimumDaysChange, '至少1天', 'numeric')} {renderField(t('challenges.createCustom.fields.minimumCheckInDays'), minimumCheckInDays, handleMinimumDaysChange, t('challenges.createCustom.fields.minimumCheckInDaysPlaceholder'), 'numeric')}
{renderField('挑战要求说明', requirementLabel, setRequirementLabel, '例如:每日完成 30 分钟运动')} {renderField(t('challenges.createCustom.fields.challengeRequirement'), requirementLabel, setRequirementLabel, t('challenges.createCustom.fields.requirementPlaceholder'))}
</View> </View>
<View style={styles.formCard}> <View style={styles.formCard}>
<Text style={styles.sectionTitle}>&</Text> <Text style={styles.sectionTitle}>{t('challenges.createCustom.displayInteraction')}</Text>
<View style={styles.inlineFields}> <View style={styles.inlineFields}>
{renderField('参与人数上限', maxParticipants, (v) => { {renderField(t('challenges.createCustom.fields.maxParticipants'), maxParticipants, (v) => {
const digits = v.replace(/\D/g, ''); const digits = v.replace(/\D/g, '');
if (!digits) { if (!digits) {
setMaxParticipants(''); setMaxParticipants('');
return; return;
} }
setMaxParticipants(String(parseInt(digits, 10))); setMaxParticipants(String(parseInt(digits, 10)));
}, '留空表示无限制', 'numeric')} }, t('challenges.createCustom.fields.noLimit'), 'numeric')}
</View> </View>
<View style={styles.switchRow}> <View style={styles.switchRow}>
<View> <View>
<Text style={styles.fieldLabel}></Text> <Text style={styles.fieldLabel}>{t('challenges.createCustom.fields.isPublic')}</Text>
<Text style={styles.switchHint}></Text> <Text style={styles.switchHint}>{t('challenges.createCustom.fields.publicDescription')}</Text>
</View> </View>
<Switch <Switch
value={isPublic} value={isPublic}
@@ -557,14 +627,24 @@ export default function CreateCustomChallengeScreen() {
<BlurView intensity={14} tint="light" style={styles.floatingBlur}> <BlurView intensity={14} tint="light" style={styles.floatingBlur}>
<View style={styles.floatingContent}> <View style={styles.floatingContent}>
<View style={styles.floatingCopy}> <View style={styles.floatingCopy}>
<Text style={styles.floatingTitle}></Text> <Text style={styles.floatingTitle}>
<Text style={styles.floatingSubtitle}></Text> {isEditMode
? t('challenges.createCustom.floatingCTA.editTitle')
: t('challenges.createCustom.floatingCTA.title')
}
</Text>
<Text style={styles.floatingSubtitle}>
{isEditMode
? t('challenges.createCustom.floatingCTA.editSubtitle')
: t('challenges.createCustom.floatingCTA.subtitle')
}
</Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
style={styles.floatingButton} style={styles.floatingButton}
onPress={handleSubmit} onPress={handleSubmit}
disabled={isCreating} disabled={isCreating || isUpdating}
> >
<LinearGradient <LinearGradient
colors={['#5E8BFF', '#6B6CFF']} colors={['#5E8BFF', '#6B6CFF']}
@@ -573,7 +653,14 @@ export default function CreateCustomChallengeScreen() {
style={styles.floatingButtonBackground} style={styles.floatingButtonBackground}
> >
<Text style={styles.floatingButtonLabel}> <Text style={styles.floatingButtonLabel}>
{isCreating ? '创建中…' : '创建并生成邀请码'} {isCreating
? t('challenges.createCustom.buttons.creating')
: isUpdating
? t('challenges.createCustom.buttons.updating')
: isEditMode
? t('challenges.createCustom.buttons.updateAndSave')
: t('challenges.createCustom.buttons.createAndGenerateCode')
}
</Text> </Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
@@ -598,10 +685,10 @@ export default function CreateCustomChallengeScreen() {
> >
<View style={styles.modalOverlay}> <View style={styles.modalOverlay}>
<View style={styles.shareCard}> <View style={styles.shareCard}>
<Text style={styles.shareTitle}></Text> <Text style={styles.shareTitle}>{t('challenges.createCustom.shareModal.title')}</Text>
<Text style={styles.shareSubtitle}></Text> <Text style={styles.shareSubtitle}>{t('challenges.createCustom.shareModal.subtitle')}</Text>
<View style={styles.shareCodeBadge}> <View style={styles.shareCodeBadge}>
<Text style={styles.shareCode}>{shareCode ?? '获取中…'}</Text> <Text style={styles.shareCode}>{shareCode ?? t('challenges.createCustom.shareModal.generatingCode')}</Text>
</View> </View>
<View style={styles.shareActions}> <View style={styles.shareActions}>
<TouchableOpacity <TouchableOpacity
@@ -610,7 +697,7 @@ export default function CreateCustomChallengeScreen() {
onPress={handleCopyShareCode} onPress={handleCopyShareCode}
disabled={!shareCode} disabled={!shareCode}
> >
<Text style={styles.shareButtonGhostLabel}></Text> <Text style={styles.shareButtonGhostLabel}>{t('challenges.createCustom.shareModal.copyCode')}</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.9}
@@ -623,7 +710,7 @@ export default function CreateCustomChallengeScreen() {
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.shareButtonPrimary} style={styles.shareButtonPrimary}
> >
<Text style={styles.shareButtonPrimaryLabel}></Text> <Text style={styles.shareButtonPrimaryLabel}>{t('challenges.createCustom.shareModal.viewChallenge')}</Text>
</LinearGradient> </LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -632,7 +719,7 @@ export default function CreateCustomChallengeScreen() {
activeOpacity={0.8} activeOpacity={0.8}
onPress={() => setShareModalVisible(false)} onPress={() => setShareModalVisible(false)}
> >
<Text style={styles.shareCloseLabel}></Text> <Text style={styles.shareCloseLabel}>{t('challenges.createCustom.shareModal.later')}</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@@ -720,6 +807,16 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
color: '#111827', color: '#111827',
}, },
targetUnitRow: {
flexDirection: 'row',
gap: 12,
},
targetInput: {
flex: 1,
},
unitInput: {
flex: 1,
},
textarea: { textarea: {
minHeight: 90, minHeight: 90,
}, },

View File

@@ -903,6 +903,8 @@ const challengeDetailResources = {
joining: '加入中…', joining: '加入中…',
leave: '退出挑战', leave: '退出挑战',
leaving: '退出中…', leaving: '退出中…',
delete: '删除挑战',
deleting: '删除中…',
upcoming: '挑战即将开始', upcoming: '挑战即将开始',
expired: '挑战已结束', expired: '挑战已结束',
}, },
@@ -935,6 +937,14 @@ const challengeDetailResources = {
}, },
joinFailed: '加入挑战失败', joinFailed: '加入挑战失败',
leaveFailed: '退出挑战失败', leaveFailed: '退出挑战失败',
archiveConfirm: {
title: '确认删除该挑战?',
message: '删除后将无法恢复,参与者也将无法再访问此挑战。',
cancel: '取消',
confirm: '删除挑战',
},
archiveFailed: '删除挑战失败',
archiveSuccess: '挑战已删除',
}, },
ranking: { ranking: {
title: '排行榜', title: '排行榜',
@@ -1011,6 +1021,8 @@ const challengeDetailResourcesEn = {
joining: 'Joining…', joining: 'Joining…',
leave: 'Leave Challenge', leave: 'Leave Challenge',
leaving: 'Leaving…', leaving: 'Leaving…',
delete: 'Delete Challenge',
deleting: 'Deleting…',
upcoming: 'Starting Soon', upcoming: 'Starting Soon',
expired: 'Challenge Ended', expired: 'Challenge Ended',
}, },
@@ -1043,6 +1055,14 @@ const challengeDetailResourcesEn = {
}, },
joinFailed: 'Failed to join challenge', joinFailed: 'Failed to join challenge',
leaveFailed: 'Failed to leave challenge', leaveFailed: 'Failed to leave challenge',
archiveConfirm: {
title: 'Delete this challenge?',
message: 'This cannot be undone and participants will lose access.',
cancel: 'Cancel',
confirm: 'Delete Challenge',
},
archiveFailed: 'Failed to delete challenge',
archiveSuccess: 'Challenge deleted',
}, },
ranking: { ranking: {
title: 'Leaderboard', title: 'Leaderboard',
@@ -1172,6 +1192,103 @@ const resources = {
ongoing: '进行中', ongoing: '进行中',
expired: '已结束', expired: '已结束',
}, },
createCustom: {
title: '新建挑战',
editTitle: '编辑挑战',
yourChallenge: '你的专属挑战',
defaultTitle: '自定义挑战',
basicInfo: '基础信息',
challengeSettings: '挑战设置',
displayInteraction: '展示&互动',
durationDays: '持续{{days}}天',
durationDaysChallenge: '{{days}}天挑战',
dayUnit: '天',
typeLabels: {
water: '喝水',
exercise: '运动',
diet: '饮食',
sleep: '睡眠',
mood: '心情',
weight: '体重',
},
fields: {
title: '标题',
titlePlaceholder: '挑战标题最多100字',
coverImage: '封面图',
uploadCover: '上传封面',
challengeDescription: '挑战说明',
descriptionPlaceholder: '简单介绍这个挑战的目标与要求',
challengeType: '挑战类型',
timeRange: '时间范围',
start: '开始',
end: '结束',
duration: '持续时间',
periodLabel: '周期标签',
periodLabelPlaceholder: '如21天挑战',
dailyTargetAndUnit: '每日目标值与进度单位',
dailyTargetPlaceholder: '如8',
unitPlaceholder: '单位',
unitHelper: '进度单位表示每日目标的计量单位,如:次、杯、分钟、页等',
minimumCheckInDays: '最少打卡天数',
minimumCheckInDaysPlaceholder: '至少1天',
challengeRequirement: '挑战要求说明',
requirementPlaceholder: '例如:每日完成 30 分钟运动',
maxParticipants: '参与人数上限',
noLimit: '留空表示无限制',
isPublic: '是否公开',
publicDescription: '公开后其他用户可通过邀请码加入',
},
buttons: {
createAndGenerateCode: '创建并生成邀请码',
updateAndSave: '保存修改',
creating: '创建中…',
updating: '更新中…',
},
floatingCTA: {
title: '生成自定义挑战',
editTitle: '编辑自定义挑战',
subtitle: '自动创建分享码,邀请好友一起挑战',
editSubtitle: '修改挑战信息,编辑后保存',
},
shareModal: {
title: '邀请码已生成',
subtitle: '分享给好友即可加入挑战',
copyCode: '复制邀请码',
viewChallenge: '查看挑战',
later: '稍后再说',
generatingCode: '获取中…',
},
alerts: {
titleRequired: '请填写挑战标题',
requirementRequired: '请填写挑战要求说明',
endTimeError: '结束时间需要晚于开始时间',
targetValueError: '每日目标值需在 1-1000 之间',
minimumDaysError: '最少打卡天数需在 1-365 之间',
minimumDaysExceedError: '最少打卡天数不能超过持续天数',
participantsError: '参与人数需在 2-10000 之间,或留空表示无限制',
createSuccess: '自定义挑战已创建',
updateSuccess: '挑战已更新',
createFailed: '创建失败,请稍后再试',
},
imageUpload: {
uploading: '上传中…',
clear: '清除',
helper: '建议比例 16:9清晰展示挑战氛围',
selectSource: '选择封面图',
camera: '拍照',
album: '从相册选择',
cancel: '取消',
cameraPermission: '权限不足',
cameraPermissionMessage: '需要相机权限以拍摄封面',
albumPermissionMessage: '需要相册权限以选择封面',
uploadFailed: '上传失败',
uploadFailedMessage: '封面上传失败,请稍后重试',
cameraFailed: '拍照失败',
cameraFailedMessage: '无法打开相机,请稍后再试',
selectFailed: '选择失败',
selectFailedMessage: '无法打开相册,请稍后再试',
},
},
}, },
}, },
}, },
@@ -2030,6 +2147,103 @@ const resources = {
ongoing: 'Ongoing', ongoing: 'Ongoing',
expired: 'Expired', expired: 'Expired',
}, },
createCustom: {
title: 'New Challenge',
editTitle: 'Edit Challenge',
yourChallenge: 'Your Custom Challenge',
defaultTitle: 'Custom Challenge',
basicInfo: 'Basic Info',
challengeSettings: 'Challenge Settings',
displayInteraction: 'Display & Interaction',
durationDays: 'Duration {{days}} days',
durationDaysChallenge: '{{days}}-Day Challenge',
dayUnit: 'day',
typeLabels: {
water: 'Water',
exercise: 'Exercise',
diet: 'Diet',
sleep: 'Sleep',
mood: 'Mood',
weight: 'Weight',
},
fields: {
title: 'Title',
titlePlaceholder: 'Challenge title (max 100 characters)',
coverImage: 'Cover Image',
uploadCover: 'Upload Cover',
challengeDescription: 'Challenge Description',
descriptionPlaceholder: 'Brief introduction to the challenge goals and requirements',
challengeType: 'Challenge Type',
timeRange: 'Time Range',
start: 'Start',
end: 'End',
duration: 'Duration',
periodLabel: 'Period Label',
periodLabelPlaceholder: 'e.g., 21-Day Challenge',
dailyTargetAndUnit: 'Daily Target & Unit',
dailyTargetPlaceholder: 'e.g., 8',
unitPlaceholder: 'Unit',
unitHelper: 'Progress unit represents the measurement unit for daily goals, such as: times, cups, minutes, pages, etc.',
minimumCheckInDays: 'Minimum Check-in Days',
minimumCheckInDaysPlaceholder: 'At least 1 day',
challengeRequirement: 'Challenge Requirement',
requirementPlaceholder: 'e.g., Complete 30 minutes of exercise daily',
maxParticipants: 'Max Participants',
noLimit: 'Leave empty for unlimited',
isPublic: 'Public',
publicDescription: 'Others can join via invite code when public',
},
buttons: {
createAndGenerateCode: 'Create & Generate Code',
updateAndSave: 'Save Changes',
creating: 'Creating…',
updating: 'Updating…',
},
floatingCTA: {
title: 'Generate Custom Challenge',
editTitle: 'Edit Custom Challenge',
subtitle: 'Automatically create share code to invite friends',
editSubtitle: 'Modify challenge information, save after editing',
},
shareModal: {
title: 'Invite Code Generated',
subtitle: 'Share with friends to join the challenge',
copyCode: 'Copy Invite Code',
viewChallenge: 'View Challenge',
later: 'Later',
generatingCode: 'Generating…',
},
alerts: {
titleRequired: 'Please enter challenge title',
requirementRequired: 'Please enter challenge requirement description',
endTimeError: 'End time must be later than start time',
targetValueError: 'Daily target value must be between 1-1000',
minimumDaysError: 'Minimum check-in days must be between 1-365',
minimumDaysExceedError: 'Minimum check-in days cannot exceed duration days',
participantsError: 'Participants must be between 2-10000, or leave empty for unlimited',
createSuccess: 'Custom challenge created',
updateSuccess: 'Challenge updated',
createFailed: 'Creation failed, please try again later',
},
imageUpload: {
uploading: 'Uploading…',
clear: 'Clear',
helper: 'Recommended ratio 16:9, clearly display challenge atmosphere',
selectSource: 'Select Cover Image',
selectMessage: 'Please select image source',
camera: 'Camera',
album: 'From Album',
cancel: 'Cancel',
cameraPermission: 'Permission Denied',
cameraPermissionMessage: 'Camera permission is required to take cover photo',
uploadFailed: 'Upload Failed',
uploadFailedMessage: 'Cover upload failed, please try again later',
cameraFailed: 'Camera Failed',
cameraFailedMessage: 'Unable to open camera, please try again later',
selectFailed: 'Selection Failed',
selectFailedMessage: 'Unable to open album, please try again later',
},
},
}, },
}, },
}, },

View File

@@ -70,8 +70,6 @@ export type ChallengeListItemDto = {
isPublic?: boolean; isPublic?: boolean;
maxParticipants?: number | null; maxParticipants?: number | null;
challengeState?: ChallengeState; challengeState?: ChallengeState;
progressUnit?: string;
targetValue?: number;
summary?: string | null; summary?: string | null;
}; };
@@ -114,6 +112,17 @@ export type CreateCustomChallengePayload = {
maxParticipants?: number | null; maxParticipants?: number | null;
}; };
export type UpdateCustomChallengePayload = {
title?: string;
image?: string;
summary?: string;
isPublic?: boolean;
maxParticipants?: number;
highlightTitle?: string;
highlightSubtitle?: string;
ctaLabel?: string;
};
export async function listChallenges(): Promise<ChallengeListItemDto[]> { export async function listChallenges(): Promise<ChallengeListItemDto[]> {
return api.get<ChallengeListItemDto[]>('/challenges'); return api.get<ChallengeListItemDto[]>('/challenges');
} }
@@ -190,3 +199,14 @@ export async function regenerateChallengeShareCode(
`/challenges/custom/${encodeURIComponent(id)}/regenerate-code` `/challenges/custom/${encodeURIComponent(id)}/regenerate-code`
); );
} }
export async function updateCustomChallenge(
id: string,
payload: UpdateCustomChallengePayload
): Promise<ChallengeDetailDto> {
return api.put<ChallengeDetailDto>(`/challenges/custom/${encodeURIComponent(id)}`, payload);
}
export async function archiveCustomChallenge(id: string): Promise<boolean> {
return api.delete<boolean>(`/challenges/custom/${encodeURIComponent(id)}`);
}

View File

@@ -10,6 +10,8 @@ import {
type ChallengeStatus, type ChallengeStatus,
type CreateCustomChallengePayload, type CreateCustomChallengePayload,
type RankingItemDto, type RankingItemDto,
type UpdateCustomChallengePayload,
archiveCustomChallenge,
createCustomChallenge, createCustomChallenge,
getChallengeByShareCode, getChallengeByShareCode,
getChallengeDetail, getChallengeDetail,
@@ -19,6 +21,7 @@ import {
leaveChallenge as leaveChallengeApi, leaveChallenge as leaveChallengeApi,
listChallenges, listChallenges,
reportChallengeProgress as reportChallengeProgressApi, reportChallengeProgress as reportChallengeProgressApi,
updateCustomChallenge,
} from '@/services/challengesApi'; } from '@/services/challengesApi';
import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit'; import { createAsyncThunk, createSelector, createSlice } from '@reduxjs/toolkit';
import type { RootState } from './index'; import type { RootState } from './index';
@@ -63,6 +66,10 @@ type ChallengesState = {
rankingError: Record<string, string | undefined>; rankingError: Record<string, string | undefined>;
createStatus: AsyncStatus; createStatus: AsyncStatus;
createError?: string; createError?: string;
updateStatus: AsyncStatus;
updateError?: string;
archiveStatus: Record<string, AsyncStatus>;
archiveError: Record<string, string | undefined>;
joinByCodeStatus: AsyncStatus; joinByCodeStatus: AsyncStatus;
joinByCodeError?: string; joinByCodeError?: string;
}; };
@@ -86,6 +93,10 @@ const initialState: ChallengesState = {
rankingError: {}, rankingError: {},
createStatus: 'idle', createStatus: 'idle',
createError: undefined, createError: undefined,
updateStatus: 'idle',
updateError: undefined,
archiveStatus: {},
archiveError: {},
joinByCodeStatus: 'idle', joinByCodeStatus: 'idle',
joinByCodeError: undefined, joinByCodeError: undefined,
}; };
@@ -210,6 +221,31 @@ export const joinChallengeByCode = createAsyncThunk<
} }
}); });
export const updateCustomChallengeThunk = createAsyncThunk<
ChallengeDetail,
{ id: string; payload: UpdateCustomChallengePayload },
{ rejectValue: string }
>('challenges/updateCustom', async ({ id, payload }, { rejectWithValue }) => {
try {
return await updateCustomChallenge(id, payload);
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
export const archiveCustomChallengeThunk = createAsyncThunk<
{ id: string },
string,
{ rejectValue: string }
>('challenges/archiveCustom', async (id, { rejectWithValue }) => {
try {
await archiveCustomChallenge(id);
return { id };
} catch (error) {
return rejectWithValue(toErrorMessage(error));
}
});
const challengesSlice = createSlice({ const challengesSlice = createSlice({
name: 'challenges', name: 'challenges',
initialState, initialState,
@@ -394,6 +430,55 @@ const challengesSlice = createSlice({
state.createError = action.payload ?? toErrorMessage(action.error); state.createError = action.payload ?? toErrorMessage(action.error);
}); });
builder
.addCase(updateCustomChallengeThunk.pending, (state) => {
state.updateStatus = 'loading';
state.updateError = undefined;
})
.addCase(updateCustomChallengeThunk.fulfilled, (state, action) => {
state.updateStatus = 'succeeded';
state.updateError = undefined;
const challenge = action.payload;
const existing = state.entities[challenge.id];
const source = ChallengeSource.CUSTOM;
state.entities[challenge.id] = { ...(existing ?? {}), ...challenge, source };
})
.addCase(updateCustomChallengeThunk.rejected, (state, action) => {
state.updateStatus = 'failed';
state.updateError = action.payload ?? toErrorMessage(action.error);
});
builder
.addCase(archiveCustomChallengeThunk.pending, (state, action) => {
const id = action.meta.arg;
state.archiveStatus[id] = 'loading';
state.archiveError[id] = undefined;
})
.addCase(archiveCustomChallengeThunk.fulfilled, (state, action) => {
const { id } = action.payload;
state.archiveStatus[id] = 'succeeded';
state.archiveError[id] = undefined;
delete state.entities[id];
state.orderedIds = state.orderedIds.filter((itemId) => itemId !== id);
delete state.detailStatus[id];
delete state.detailError[id];
delete state.joinStatus[id];
delete state.joinError[id];
delete state.leaveStatus[id];
delete state.leaveError[id];
delete state.progressStatus[id];
delete state.progressError[id];
delete state.rankingList[id];
delete state.rankingStatus[id];
delete state.rankingLoadMoreStatus[id];
delete state.rankingError[id];
})
.addCase(archiveCustomChallengeThunk.rejected, (state, action) => {
const id = action.meta.arg;
state.archiveStatus[id] = 'failed';
state.archiveError[id] = action.payload ?? toErrorMessage(action.error);
});
builder builder
.addCase(joinChallengeByCode.pending, (state) => { .addCase(joinChallengeByCode.pending, (state) => {
state.joinByCodeStatus = 'loading'; state.joinByCodeStatus = 'loading';
@@ -545,8 +630,8 @@ export const selectChallengeCards = createSelector([selectChallengeList], (chall
source: challenge.source, source: challenge.source,
shareCode: challenge.shareCode ?? null, shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState, challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit, progressUnit: challenge.unit,
targetValue: challenge.targetValue, targetValue: challenge.progress?.target,
isCreator: challenge.isCreator, isCreator: challenge.isCreator,
}; };
}) })
@@ -578,8 +663,8 @@ export const selectCustomChallengeCards = createSelector(
source: challenge.source ?? ChallengeSource.CUSTOM, source: challenge.source ?? ChallengeSource.CUSTOM,
shareCode: challenge.shareCode ?? null, shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState, challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit, progressUnit: challenge.unit,
targetValue: challenge.targetValue, targetValue: challenge.progress?.target,
isCreator: challenge.isCreator, isCreator: challenge.isCreator,
}; };
}) })
@@ -611,8 +696,8 @@ export const selectOfficialChallengeCards = createSelector(
source: challenge.source ?? ChallengeSource.SYSTEM, source: challenge.source ?? ChallengeSource.SYSTEM,
shareCode: challenge.shareCode ?? null, shareCode: challenge.shareCode ?? null,
challengeState: challenge.challengeState, challengeState: challenge.challengeState,
progressUnit: challenge.progressUnit, progressUnit: challenge.unit,
targetValue: challenge.targetValue, targetValue: challenge.progress?.target,
isCreator: challenge.isCreator, isCreator: challenge.isCreator,
}; };
}) })
@@ -676,3 +761,19 @@ export const selectJoinByCodeError = createSelector(
[selectChallengesState], [selectChallengesState],
(state) => state.joinByCodeError (state) => state.joinByCodeError
); );
export const selectUpdateChallengeStatus = createSelector(
[selectChallengesState],
(state) => state.updateStatus
);
export const selectUpdateChallengeError = createSelector(
[selectChallengesState],
(state) => state.updateError
);
export const selectArchiveStatus = (id: string) =>
createSelector([selectChallengesState], (state) => state.archiveStatus[id] ?? 'idle');
export const selectArchiveError = (id: string) =>
createSelector([selectChallengesState], (state) => state.archiveError[id]);