Files
life-echo/app-expo/src/app/(main)/chapter/[id].tsx
Kevin 9a1d31c71f feat: 章节软删除、对话左滑删除,移除已读状态
- 章节:详情页增加删除按钮,软删除(is_active=False),AI 不再修改但保留供参考
- 章节:get_chapter 增加 is_active 校验,已删除章节返回 404
- 章节:AI 生成时参考同类别已删除章节摘要
- 对话:左滑显示删除,调用 hard delete API,删除前二次确认
- 对话:根布局包裹 GestureHandlerRootView 以支持 Swipeable
- 对话:移除已读/未读状态展示及相关 i18n
2026-03-19 10:45:07 +08:00

703 lines
20 KiB
TypeScript

import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
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,
ScrollView,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
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, useDeleteChapter } from '@/features/memoir/hooks';
// Life-Echo reading colors (from HTML reference)
const READING_COLORS = {
background: '#F8F9FA',
backgroundSepia: '#F2E8CF',
primary: '#8177A6',
onSurface: '#1b1b1f',
onSurfaceVariant: '#48454e',
divider: 'rgba(141, 140, 144, 0.2)',
surfaceContainerHigh: '#e9e7ec',
outlineVariant: 'rgba(121, 117, 127, 0.3)',
};
type FontSize = 'small' | 'default' | 'large';
type FontFamily = 'serif' | 'sans';
type BackgroundTheme = 'white' | 'sepia';
const FONT_SIZES: Record<FontSize, number> = {
small: 16,
default: 20,
large: 24,
};
const LINE_HEIGHTS: Record<FontSize, number> = {
small: 30,
default: 38,
large: 44,
};
const FONT_FAMILIES: Record<FontFamily, string> = {
serif:
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
'serif',
sans:
Platform.select({
ios: 'System',
android: 'sans-serif',
default: 'sans-serif',
}) ?? 'sans-serif',
};
const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
white: READING_COLORS.background,
sepia: READING_COLORS.backgroundSepia,
};
function ChapterContent({
sections,
coverImageUrl,
fontSize,
fontFamily,
backgroundColor,
}: {
sections: { content: string; image: { url: string | null } | null }[];
coverImageUrl: string | null;
fontSize: FontSize;
fontFamily: FontFamily;
backgroundColor: BackgroundTheme;
}) {
const { width } = useWindowDimensions();
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
const heroAspectRatio = 4 / 5;
const bodySize = FONT_SIZES[fontSize];
const lineHeight = LINE_HEIGHTS[fontSize];
const fontFam = FONT_FAMILIES[fontFamily];
const bgColor = BACKGROUND_COLORS[backgroundColor];
const [heroLoadFailed, setHeroLoadFailed] = useState(false);
useEffect(() => {
setHeroLoadFailed(false);
}, [coverImageUrl]);
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
return (
<>
{/* Hero Image: 仅在有封面图且加载成功时显示,避免无图或加载失败时大片空白 */}
{hasCoverImage && (
<View
style={{
width: '100%',
aspectRatio: heroAspectRatio,
overflow: 'hidden',
}}
>
<Image
source={{ uri: coverImageUrl! }}
alt="Chapter hero"
onError={() => setHeroLoadFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<LinearGradient
colors={['transparent', bgColor]}
locations={[0.3, 1]}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
</View>
)}
{/* Memoir Content */}
<View
style={{
paddingHorizontal: ScreenGutter,
marginTop: hasCoverImage ? -60 : 0,
paddingTop: hasCoverImage ? 0 : 12,
paddingBottom: 48,
backgroundColor: bgColor,
}}
>
<View
style={{ maxWidth: contentWidth, alignSelf: 'center', width: '100%' }}
>
{(() => {
const indent = '\u3000\u3000';
let isFirstParagraph = true;
return sections.flatMap((section, i) => {
const text = section.content?.trim() ?? '';
const hasImage = !!section.image?.url;
if (!text && !hasImage) return [];
const paragraphs = text
? text
.split(/\n\n+/)
.map((p) => p.trim())
.filter(Boolean)
: [];
const nodes: React.ReactNode[] = [];
paragraphs.forEach((para, pIdx) => {
const firstChar = para.charAt(0);
const rest = para.slice(1);
const isLastInSection =
pIdx === paragraphs.length - 1 && !hasImage;
const mb = isLastInSection ? 20 : 16;
if (isFirstParagraph && firstChar) {
const firstParaLineHeight = Math.round(lineHeight * 1.35);
nodes.push(
<Text
key={`${i}-${pIdx}`}
selectable
style={{
fontSize: bodySize,
lineHeight: firstParaLineHeight,
color: READING_COLORS.onSurfaceVariant,
fontFamily: fontFam,
marginBottom: mb,
}}
>
<Text
style={{
fontSize: bodySize * 2.2,
fontWeight: '700',
color: READING_COLORS.primary,
lineHeight: firstParaLineHeight,
fontFamily: fontFam,
}}
>
{firstChar}
</Text>
{rest}
</Text>,
);
isFirstParagraph = false;
} else {
nodes.push(
<Text
key={`${i}-${pIdx}`}
selectable
style={{
fontSize: bodySize,
lineHeight,
color: READING_COLORS.onSurfaceVariant,
fontFamily: fontFam,
marginBottom: mb,
}}
>
{indent}
{para}
</Text>,
);
}
});
if (hasImage) {
nodes.push(
<Image
key={`${i}-img`}
source={{ uri: section.image!.url }}
style={{
width: '100%',
aspectRatio: 16 / 9,
borderRadius: 12,
marginTop: paragraphs.length > 0 ? 12 : 0,
marginBottom: 20,
}}
/>,
);
}
return nodes;
});
})()}
{sections.length > 0 && (
<View
style={{
paddingTop: 24,
paddingBottom: 24,
alignItems: 'center',
}}
>
<View
style={{
width: 96,
height: 1,
backgroundColor: READING_COLORS.divider,
}}
/>
</View>
)}
</View>
</View>
</>
);
}
function ReadingSettingsModal({
visible,
onClose,
fontSize,
fontFamily,
backgroundColor,
onFontSizeChange,
onFontFamilyChange,
onBackgroundChange,
t,
}: {
visible: boolean;
onClose: () => void;
fontSize: FontSize;
fontFamily: FontFamily;
backgroundColor: BackgroundTheme;
onFontSizeChange: (v: FontSize) => void;
onFontFamilyChange: (v: FontFamily) => void;
onBackgroundChange: (v: BackgroundTheme) => void;
t: (key: string) => string;
}) {
const insets = useSafeAreaInsets();
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={onClose}
>
<Pressable style={readingSettingsStyles.backdrop} onPress={onClose}>
<Pressable
style={[
readingSettingsStyles.sheet,
{ paddingBottom: insets.bottom + 24 },
]}
onPress={() => {}}
>
<View style={readingSettingsStyles.handle} />
<View style={readingSettingsStyles.header}>
<Text style={readingSettingsStyles.title}>
{t('chapterReading.readingSettings')}
</Text>
<Pressable
onPress={onClose}
hitSlop={12}
style={({ pressed }) => [
readingSettingsStyles.closeBtn,
pressed && { opacity: 0.6 },
]}
accessibilityLabel={t('chapterReading.close')}
accessibilityRole="button"
>
<Icon as={X} size={22} color={READING_COLORS.onSurfaceVariant} />
</Pressable>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
{t('chapterReading.fontSize')}
</Text>
<View style={readingSettingsStyles.segmented}>
{(['small', 'default', 'large'] as const).map((s) => (
<Pressable
key={s}
onPress={() => onFontSizeChange(s)}
style={({ pressed }) => [
readingSettingsStyles.segItem,
fontSize === s && readingSettingsStyles.segItemActive,
pressed && { opacity: 0.8 },
]}
>
<Text
style={[
readingSettingsStyles.segText,
fontSize === s && readingSettingsStyles.segTextActive,
]}
>
{t(
`chapterReading.fontSize${s.charAt(0).toUpperCase() + s.slice(1)}`,
)}
</Text>
</Pressable>
))}
</View>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
{t('chapterReading.typography')}
</Text>
<View style={readingSettingsStyles.segmented}>
{(['serif', 'sans'] as const).map((f) => (
<Pressable
key={f}
onPress={() => onFontFamilyChange(f)}
style={({ pressed }) => [
readingSettingsStyles.segItem,
fontFamily === f && readingSettingsStyles.segItemActive,
pressed && { opacity: 0.8 },
]}
>
<Text
style={[
readingSettingsStyles.segText,
fontFamily === f && readingSettingsStyles.segTextActive,
{ fontFamily: FONT_FAMILIES[f] },
]}
>
{t(
`chapterReading.font${f.charAt(0).toUpperCase() + f.slice(1)}`,
)}
</Text>
</Pressable>
))}
</View>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
{t('chapterReading.backgroundColor')}
</Text>
<View style={readingSettingsStyles.bgRow}>
{(['white', 'sepia'] as const).map((theme) => (
<Pressable
key={theme}
onPress={() => onBackgroundChange(theme)}
style={({ pressed }) => [
readingSettingsStyles.bgOption,
{ backgroundColor: BACKGROUND_COLORS[theme] },
backgroundColor === theme &&
readingSettingsStyles.bgOptionActive,
pressed && { opacity: 0.9 },
]}
>
<View
style={[
readingSettingsStyles.bgSwatch,
{ backgroundColor: BACKGROUND_COLORS[theme] },
theme === 'sepia' && {
borderColor: 'rgba(91,77,62,0.5)',
},
]}
/>
<Text
style={[
readingSettingsStyles.bgLabel,
theme === 'sepia' && { color: '#5B4D3E' },
backgroundColor === theme &&
readingSettingsStyles.bgLabelActive,
]}
>
{t(
`chapterReading.bg${theme === 'white' ? 'PureWhite' : 'Sepia'}`,
)}
</Text>
</Pressable>
))}
</View>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
const readingSettingsStyles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingTop: 8,
paddingHorizontal: ScreenGutter,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.06,
shadowRadius: 12,
},
android: { elevation: 12 },
}),
},
handle: {
alignSelf: 'center',
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(0,0,0,0.2)',
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
},
title: {
fontSize: 17,
fontWeight: '700',
color: READING_COLORS.onSurface,
},
closeBtn: { padding: 8 },
label: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
color: READING_COLORS.onSurfaceVariant,
marginBottom: 10,
textTransform: 'uppercase',
},
section: { marginBottom: 20 },
segmented: {
flexDirection: 'row',
backgroundColor: '#eeeef0',
borderRadius: 10,
padding: 4,
},
segItem: {
flex: 1,
minHeight: 44,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
},
segItemActive: {
backgroundColor: READING_COLORS.primary,
},
segText: {
fontSize: 14,
fontWeight: '500',
color: READING_COLORS.onSurfaceVariant,
},
segTextActive: {
fontWeight: '700',
color: '#fff',
},
bgRow: { flexDirection: 'row', gap: 12 },
bgOption: {
flex: 1,
minHeight: 64,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
borderRadius: 10,
borderWidth: 2,
borderColor: 'transparent',
},
bgOptionActive: {
borderColor: READING_COLORS.primary,
},
bgSwatch: {
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.1)',
},
bgLabel: {
fontSize: 14,
fontWeight: '500',
color: READING_COLORS.onSurface,
},
bgLabelActive: { fontWeight: '700' },
});
export default function ChapterScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
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');
const [fontFamily, setFontFamily] = useState<FontFamily>('serif');
const [backgroundColor, setBackgroundColor] =
useState<BackgroundTheme>('white');
const bgColor = BACKGROUND_COLORS[backgroundColor];
if (isLoading) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: bgColor,
}}
>
<ActivityIndicator size="large" color={READING_COLORS.primary} />
</View>
);
}
if (!chapter) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: bgColor,
gap: 16,
}}
>
<Text
selectable
style={{ color: READING_COLORS.onSurfaceVariant, fontSize: 16 }}
>
{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>
);
}
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
variant="reading"
absolute
backgroundColor={bgColor}
title={chapter.title}
backAccessibilityLabel={t('chapterReading.back')}
right={
<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>
}
/>
{/* Scrollable content */}
<ScrollView
contentInsetAdjustmentBehavior="never"
contentContainerStyle={{
paddingTop: insets.top + 72,
}}
showsVerticalScrollIndicator={false}
style={{ backgroundColor: bgColor }}
>
<ChapterContent
sections={sections}
coverImageUrl={coverImageUrl}
fontSize={fontSize}
fontFamily={fontFamily}
backgroundColor={backgroundColor}
/>
</ScrollView>
<ReadingSettingsModal
visible={settingsVisible}
onClose={() => setSettingsVisible(false)}
fontSize={fontSize}
fontFamily={fontFamily}
backgroundColor={backgroundColor}
onFontSizeChange={setFontSize}
onFontFamilyChange={setFontFamily}
onBackgroundChange={setBackgroundColor}
t={t as (key: string) => string}
/>
</View>
);
}