feat: 章节软删除、对话左滑删除,移除已读状态

- 章节:详情页增加删除按钮,软删除(is_active=False),AI 不再修改但保留供参考
- 章节:get_chapter 增加 is_active 校验,已删除章节返回 404
- 章节:AI 生成时参考同类别已删除章节摘要
- 对话:左滑显示删除,调用 hard delete API,删除前二次确认
- 对话:根布局包裹 GestureHandlerRootView 以支持 Swipeable
- 对话:移除已读/未读状态展示及相关 i18n
This commit is contained in:
Kevin
2026-03-19 10:44:35 +08:00
parent 1aa3d8593c
commit 9a1d31c71f
12 changed files with 223 additions and 80 deletions

View File

@@ -1,10 +1,11 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams } from 'expo-router';
import { Settings, X } from 'lucide-react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { Settings, Trash2, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Platform,
Pressable,
@@ -20,7 +21,7 @@ import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { ScreenHeader } from '@/components/screen-header';
import { ScreenGutter } from '@/constants/layout';
import { useChapterDetail } from '@/features/memoir/hooks';
import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks';
// Life-Echo reading colors (from HTML reference)
const READING_COLORS = {
@@ -536,6 +537,7 @@ export default function ChapterScreen() {
const insets = useSafeAreaInsets();
const { t } = useTranslation('memoir');
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
const deleteChapter = useDeleteChapter();
const [settingsVisible, setSettingsVisible] = useState(false);
const [fontSize, setFontSize] = useState<FontSize>('default');
@@ -568,6 +570,7 @@ export default function ChapterScreen() {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: bgColor,
gap: 16,
}}
>
<Text
@@ -576,6 +579,25 @@ export default function ChapterScreen() {
>
{t('chapterReading.chapterNotFound')}
</Text>
<Pressable
onPress={() => router.back()}
style={({ pressed }) => ({
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
backgroundColor: READING_COLORS.primary,
opacity: pressed ? 0.8 : 1,
})}
accessibilityRole="button"
accessibilityLabel={t('chapterReading.back')}
>
<Text
style={{ color: '#fff', fontSize: 16, fontWeight: '600' }}
selectable={false}
>
{t('chapterReading.back')}
</Text>
</Pressable>
</View>
);
}
@@ -583,6 +605,25 @@ export default function ChapterScreen() {
const sections = chapter.sections ?? [];
const coverImageUrl = chapter.cover_image?.url ?? null;
const handleDeletePress = () => {
Alert.alert(
t('chapterReading.deleteChapter'),
t('chapterReading.confirmDeleteMessage'),
[
{ text: t('chapterReading.cancel'), style: 'cancel' },
{
text: t('chapterReading.deleteChapterAction'),
style: 'destructive',
onPress: () => {
deleteChapter.mutate(chapter.id, {
onSuccess: () => router.back(),
});
},
},
],
);
};
return (
<View style={{ flex: 1, backgroundColor: bgColor }}>
<ScreenHeader
@@ -592,19 +633,38 @@ export default function ChapterScreen() {
title={chapter.title}
backAccessibilityLabel={t('chapterReading.back')}
right={
<Pressable
onPress={() => setSettingsVisible(true)}
style={({ pressed }) => ({
padding: 8,
marginRight: -8,
borderRadius: 9999,
opacity: pressed ? 0.7 : 1,
})}
accessibilityLabel={t('chapterReading.settings')}
accessibilityRole="button"
>
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
</Pressable>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
<Pressable
onPress={handleDeletePress}
disabled={deleteChapter.isPending}
style={({ pressed }) => ({
padding: 8,
borderRadius: 9999,
opacity: pressed ? 0.7 : 1,
})}
accessibilityLabel={t('chapterReading.deleteChapter')}
accessibilityRole="button"
>
<Icon
as={Trash2}
size={22}
color={READING_COLORS.onSurfaceVariant}
/>
</Pressable>
<Pressable
onPress={() => setSettingsVisible(true)}
style={({ pressed }) => ({
padding: 8,
marginRight: -8,
borderRadius: 9999,
opacity: pressed ? 0.7 : 1,
})}
accessibilityLabel={t('chapterReading.settings')}
accessibilityRole="button"
>
<Icon as={Settings} size={24} color={READING_COLORS.primary} />
</Pressable>
</View>
}
/>

View File

@@ -3,7 +3,8 @@ import React from 'react';
import { Alert, Pressable, ScrollView, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { CheckCheck, MessageCirclePlus, User } from 'lucide-react-native';
import { Swipeable } from 'react-native-gesture-handler';
import { MessageCirclePlus, Trash2, User } from 'lucide-react-native';
import { Icon } from '@/components/ui/icon';
import { Skeleton } from '@/components/ui/skeleton';
@@ -12,6 +13,7 @@ import { NetworkError } from '@/core/api/types';
import {
useConversations,
useCreateConversation,
useDeleteConversation,
} from '@/features/conversation/hooks';
import type { ConversationListItem } from '@/features/conversation/types';
@@ -65,43 +67,13 @@ function GreetingCardSkeleton() {
);
}
function StatusBadge({
status,
label,
}: {
status: 'read' | 'unread';
label: string;
}) {
const isPrimary = status === 'unread';
return (
<View className="flex-row items-center gap-1.5">
{status === 'read' && (
<Icon as={CheckCheck} className="text-primary" size={14} />
)}
{status === 'unread' && (
<View className="h-2 w-2 rounded-full bg-primary" />
)}
<Text
className={`text-xs font-semibold uppercase tracking-wider ${
isPrimary ? 'text-primary' : 'text-muted-foreground'
}`}
>
{label}
</Text>
</View>
);
}
function ConversationCard({
item,
statusLabel,
onPress,
}: {
item: ConversationListItem;
statusLabel: string;
onPress: () => void;
}) {
const status = item.unreadCount > 0 ? ('unread' as const) : ('read' as const);
const avatarBg = item.isDefaultAssistant ? 'bg-primary' : 'bg-secondary';
const avatarIconClass = item.isDefaultAssistant
? 'text-primary-foreground'
@@ -136,14 +108,56 @@ function ConversationCard({
>
{item.latestMessagePreview || ''}
</Text>
<View className="mt-2">
<StatusBadge status={status} label={statusLabel} />
</View>
</View>
</Pressable>
);
}
function SwipeableConversationCard({
item,
onPress,
}: {
item: ConversationListItem;
onPress: () => void;
}) {
const { t } = useTranslation('conversation');
const deleteConversation = useDeleteConversation();
const handleDelete = () => {
Alert.alert(t('deleteConversation'), t('confirmDeleteConversation'), [
{ text: t('cancel'), style: 'cancel' },
{
text: t('delete'),
style: 'destructive',
onPress: () => deleteConversation.mutate(item.id),
},
]);
};
const renderRightActions = () => (
<Pressable
onPress={handleDelete}
disabled={deleteConversation.isPending}
className="w-20 items-center justify-center rounded-xl bg-destructive active:opacity-80"
style={{ marginLeft: 4 }}
>
<Icon as={Trash2} className="text-destructive-foreground" size={24} />
<Text
className="mt-1 text-xs font-semibold text-destructive-foreground"
selectable={false}
>
{t('delete')}
</Text>
</Pressable>
);
return (
<Swipeable renderRightActions={renderRightActions} friction={2}>
<ConversationCard item={item} onPress={onPress} />
</Swipeable>
);
}
const SKELETON_COUNT = 3;
export default function ConversationsScreen() {
@@ -277,12 +291,9 @@ export default function ConversationsScreen() {
</View>
<View className="gap-4">
{conversations.map((item) => (
<ConversationCard
<SwipeableConversationCard
key={item.id}
item={item}
statusLabel={
item.unreadCount > 0 ? t('unread') : t('read')
}
onPress={() => handleConversationPress(item.id)}
/>
))}

View File

@@ -2,6 +2,7 @@ import { PortalHost } from '@rn-primitives/portal';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import {
SafeAreaProvider,
initialWindowMetrics,
@@ -43,23 +44,25 @@ export default function RootLayout() {
}, [setColorScheme]);
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppProviders>
<TypographyProvider>
<ThemeVariablesProvider>
<NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
</Stack>
<PortalHost />
</NavigationThemeProvider>
</ThemeVariablesProvider>
</TypographyProvider>
</AppProviders>
</SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppProviders>
<TypographyProvider>
<ThemeVariablesProvider>
<NavigationThemeProvider>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<AnimatedSplashOverlay />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(main)" />
</Stack>
<PortalHost />
</NavigationThemeProvider>
</ThemeVariablesProvider>
</TypographyProvider>
</AppProviders>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

View File

@@ -15,7 +15,6 @@ interface Resources {
profile: 'Profile';
};
theme: {
brand: 'Brand';
default: 'Default';
};
};
@@ -64,17 +63,22 @@ interface Resources {
conversation: {
addMore: 'More';
agentName: 'Life Echo';
cancel: 'Cancel';
cancelRecording: 'Cancel recording';
chatTitle: 'Conversation';
confirm: 'OK';
confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.';
connectionConnected: 'Connected';
connectionConnecting: 'Connecting...';
connectionDisconnected: 'Disconnected';
createError: 'Unable to create conversation. Please check your network and try again.';
delete: 'Delete';
deleteConversation: 'Delete Conversation';
emptyGreetingSubtitle: 'Chat with Echo and record your stories.';
greetingTitle: 'Say Hello';
inputPlaceholder: 'Type a message...';
inputPlaceholderVoice: 'Type here or hold the mic to speak...';
me: 'Me';
read: 'Read';
recentChats: 'Recent Chats';
recordingPermissionDenied: 'Microphone permission is required to record';
send: 'Send';
@@ -83,8 +87,8 @@ interface Resources {
switchToVoice: 'Switch to voice input';
tapToEndRecording: 'Tap to end';
tapToStartRecording: 'Tap to start recording';
unread: 'Unread';
viewAll: 'View All';
voiceMessagePreview: 'Voice message';
};
explore: {};
home: {};
@@ -95,8 +99,12 @@ interface Resources {
backgroundColor: 'Background';
bgPureWhite: 'White';
bgSepia: 'Sepia';
cancel: 'Cancel';
chapterNotFound: 'Chapter not found';
close: 'Close';
confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.';
deleteChapter: 'Delete Chapter';
deleteChapterAction: 'Delete';
fontSans: 'Sans';
fontSerif: 'Serif';
fontSize: 'Font Size';

View File

@@ -1,6 +1,10 @@
{
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
"createError": "Unable to create conversation. Please check your network and try again.",
"confirm": "OK",
"cancel": "Cancel",
"delete": "Delete",
"deleteConversation": "Delete Conversation",
"addMore": "More",
"agentName": "Life Echo",
"cancelRecording": "Cancel recording",
@@ -13,7 +17,6 @@
"inputPlaceholder": "Type a message...",
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
"me": "Me",
"read": "Read",
"recentChats": "Recent Chats",
"recordingPermissionDenied": "Microphone permission is required to record",
"send": "Send",
@@ -22,7 +25,6 @@
"switchToVoice": "Switch to voice input",
"tapToEndRecording": "Tap to end",
"tapToStartRecording": "Tap to start recording",
"unread": "Unread",
"viewAll": "View All",
"voiceMessagePreview": "Voice message"
}

View File

@@ -2,6 +2,10 @@
"chapterLabel": "Chapter {{index}}",
"chapterReading": {
"back": "Back",
"cancel": "Cancel",
"confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.",
"deleteChapter": "Delete Chapter",
"deleteChapterAction": "Delete",
"backgroundColor": "Background",
"bgPureWhite": "White",
"bgSepia": "Sepia",

View File

@@ -1,6 +1,10 @@
{
"confirmDeleteConversation": "确定要删除此对话吗?删除后无法恢复。",
"createError": "无法创建对话,请检查网络连接或稍后重试",
"confirm": "知道了",
"cancel": "取消",
"delete": "删除",
"deleteConversation": "删除对话",
"addMore": "更多功能",
"agentName": "岁月知己",
"cancelRecording": "取消录音发送",
@@ -13,7 +17,6 @@
"inputPlaceholder": "输入消息...",
"inputPlaceholderVoice": "点击这里输入,或者按住左边说话...",
"me": "我",
"read": "已读",
"recentChats": "最近对话",
"recordingPermissionDenied": "需要麦克风权限才能录音",
"send": "发送",
@@ -22,7 +25,6 @@
"switchToVoice": "切换到语音输入",
"tapToEndRecording": "点击结束",
"tapToStartRecording": "点击开始录音",
"unread": "未读",
"viewAll": "查看全部",
"voiceMessagePreview": "语音消息"
}

View File

@@ -2,6 +2,10 @@
"chapterLabel": "第 {{index}} 章",
"chapterReading": {
"back": "返回",
"cancel": "取消",
"confirmDeleteMessage": "确定要删除本章节吗?删除后您将无法再查看,但内容会保留供后续参考。",
"deleteChapter": "删除章节",
"deleteChapterAction": "删除",
"backgroundColor": "背景色",
"bgPureWhite": "白色",
"bgSepia": "护眼",