fix/various fixes
This commit is contained in:
@@ -307,6 +307,9 @@ export default function ChapterScreen() {
|
||||
const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim();
|
||||
const renderedAssets = chapter.rendered_assets ?? chapter.images ?? [];
|
||||
|
||||
/** 与 ScreenHeader(reading、useSafeArea)可视高度对齐,避免返回栏与首屏内容之间出现空隙 */
|
||||
const headerOccupiedHeight = Math.max(insets.top, 12) + 56;
|
||||
|
||||
const handleDeletePress = () => {
|
||||
Alert.alert(
|
||||
t('chapterReading.deleteChapter'),
|
||||
@@ -374,7 +377,7 @@ export default function ChapterScreen() {
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="never"
|
||||
contentContainerStyle={{
|
||||
paddingTop: insets.top + 72,
|
||||
paddingTop: headerOccupiedHeight,
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
} from 'react-native';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
@@ -57,6 +61,10 @@ const CHAT_COLORS = {
|
||||
/** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */
|
||||
const SPLIT_DELIMITER = '[SPLIT]';
|
||||
|
||||
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
|
||||
const CHAT_INPUT_LINE_H = 22;
|
||||
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
|
||||
|
||||
function splitContent(content: string): string[] {
|
||||
return content
|
||||
.split(SPLIT_DELIMITER)
|
||||
@@ -424,6 +432,7 @@ function ChatInputBar({
|
||||
tapToEndLabel,
|
||||
cancelRecordingLabel,
|
||||
disabled,
|
||||
textInputKey = 0,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
@@ -446,8 +455,29 @@ function ChatInputBar({
|
||||
tapToEndLabel: string;
|
||||
cancelRecordingLabel: string;
|
||||
disabled?: boolean;
|
||||
/** 发送后递增,强制重建 TextInput,避免多行高度卡在 4 行 */
|
||||
textInputKey?: number;
|
||||
}) {
|
||||
const colors = useThemeColors();
|
||||
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
|
||||
/** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */
|
||||
const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight;
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
setTextHeight(CHAT_INPUT_LINE_H);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const onInputContentSizeChange = useCallback(
|
||||
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
|
||||
const h = e.nativeEvent.contentSize.height;
|
||||
const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H);
|
||||
setTextHeight(next);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const hasText = value.trim().length > 0;
|
||||
const showSend = inputMode === 'text' && hasText;
|
||||
|
||||
@@ -487,15 +517,18 @@ function ChatInputBar({
|
||||
{inputMode === 'text' ? (
|
||||
<View style={styles.inputCenter}>
|
||||
<TextInput
|
||||
key={`chat-input-${textInputKey}`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
|
||||
style={styles.textInput}
|
||||
style={[styles.textInput, { height: inputDisplayHeight }]}
|
||||
multiline
|
||||
scrollEnabled
|
||||
textAlignVertical="top"
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
onContentSizeChange={onInputContentSizeChange}
|
||||
onSubmitEditing={onSend}
|
||||
returnKeyType="send"
|
||||
/>
|
||||
@@ -600,6 +633,7 @@ export default function ConversationScreen() {
|
||||
} = useRecorder(handleRecordingComplete);
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [inputResetKey, setInputResetKey] = useState(0);
|
||||
const [inputMode, setInputMode] = useState<InputMode>('text');
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
@@ -609,6 +643,30 @@ export default function ConversationScreen() {
|
||||
const onShow = (e: { endCoordinates: { height: number } }) => {
|
||||
setIsKeyboardVisible(true);
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
// #region agent log
|
||||
void fetch(
|
||||
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Debug-Session-Id': 'a82a4c',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a82a4c',
|
||||
hypothesisId: 'H3',
|
||||
location: 'ConversationScreen:keyboardShow',
|
||||
message: 'keyboard open; composer uses insetBottom 0 when text+kb',
|
||||
data: {
|
||||
kbH: e.endCoordinates.height,
|
||||
insetBottom: insets.bottom,
|
||||
os: Platform.OS,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
});
|
||||
@@ -617,13 +675,18 @@ export default function ConversationScreen() {
|
||||
setIsKeyboardVisible(false);
|
||||
setKeyboardHeight(0);
|
||||
};
|
||||
const subShow = Keyboard.addListener('keyboardDidShow', onShow);
|
||||
const subHide = Keyboard.addListener('keyboardDidHide', onHide);
|
||||
// iOS:Will* 与系统动画同步;KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626)
|
||||
const showEvt =
|
||||
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvt =
|
||||
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
const subShow = Keyboard.addListener(showEvt, onShow);
|
||||
const subHide = Keyboard.addListener(hideEvt, onHide);
|
||||
return () => {
|
||||
subShow.remove();
|
||||
subHide.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [insets.bottom]);
|
||||
|
||||
const flattenedData = flattenMessagesForList(messages ?? []);
|
||||
|
||||
@@ -642,6 +705,27 @@ export default function ConversationScreen() {
|
||||
if (!text) return;
|
||||
sendText(text);
|
||||
setInput('');
|
||||
setInputResetKey((k) => k + 1);
|
||||
// #region agent log
|
||||
void fetch(
|
||||
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Debug-Session-Id': 'a82a4c',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionId: 'a82a4c',
|
||||
hypothesisId: 'H4',
|
||||
location: 'ConversationScreen:handleSend',
|
||||
message: 'cleared input + bumped textInputKey',
|
||||
data: { sentLen: text.length },
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
},
|
||||
).catch(() => {});
|
||||
// #endregion
|
||||
};
|
||||
|
||||
const connectionLabel =
|
||||
@@ -651,129 +735,142 @@ export default function ConversationScreen() {
|
||||
? t('connectionConnecting')
|
||||
: t('connectionDisconnected');
|
||||
|
||||
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
|
||||
const kavEnabled = inputMode === 'text' && isKeyboardVisible;
|
||||
const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height';
|
||||
/** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */
|
||||
const keyboardLift =
|
||||
Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible
|
||||
? keyboardHeight
|
||||
: 0;
|
||||
const androidKavOn =
|
||||
Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible;
|
||||
|
||||
const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text';
|
||||
|
||||
const screen = (
|
||||
<View style={styles.column}>
|
||||
<ScreenHeader
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
{playerStatus === 'playing' && (
|
||||
<Icon
|
||||
as={Volume2}
|
||||
size={18}
|
||||
color={CHAT_COLORS.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' && styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
/>
|
||||
|
||||
{/* Message list - flex 1, takes remaining space */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
data={flattenedData}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
item={item}
|
||||
agentName={t('agentName')}
|
||||
meLabel={t('me')}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
})
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* WeChat-style input bar - 贴在底部,非浮空 */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputBarWrapper,
|
||||
{
|
||||
paddingBottom: composerZeroBottomInset ? 0 : insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
textInputKey={inputResetKey}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() => {
|
||||
setInputMode((m) => {
|
||||
if (m === 'text') {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
return m === 'text' ? 'voice' : 'text';
|
||||
});
|
||||
}}
|
||||
onAddPress={() => {}}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={() => void stopRecording()}
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
sendLabel={t('send')}
|
||||
switchToVoiceLabel={t('switchToVoice')}
|
||||
switchToTextLabel={t('switchToText')}
|
||||
tapToStartLabel={t('tapToStartRecording')}
|
||||
tapToEndLabel={t('tapToEndRecording')}
|
||||
cancelRecordingLabel={t('cancelRecording')}
|
||||
disabled={connectionState !== 'connected'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<View style={[styles.container, { paddingBottom: keyboardLift }]}>
|
||||
{screen}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={kavEnabled ? kavBehavior : undefined}
|
||||
keyboardVerticalOffset={keyboardOffset}
|
||||
behavior={androidKavOn ? 'height' : undefined}
|
||||
>
|
||||
<View style={styles.column}>
|
||||
<ScreenHeader
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
{playerStatus === 'playing' && (
|
||||
<Icon
|
||||
as={Volume2}
|
||||
size={18}
|
||||
color={CHAT_COLORS.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
styles.statusBadge,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeConnected,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
>
|
||||
{connectionLabel}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
}
|
||||
backAccessibilityLabel={t('chatTitle')}
|
||||
/>
|
||||
|
||||
{/* Message list - flex 1, takes remaining space */}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
style={styles.list}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
inputMode === 'text' &&
|
||||
isKeyboardVisible && { paddingBottom: 12 + keyboardHeight },
|
||||
]}
|
||||
data={flattenedData}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
keyExtractor={(item) => item.listKey}
|
||||
renderItem={({ item }) => (
|
||||
<MessageBubble
|
||||
item={item}
|
||||
agentName={t('agentName')}
|
||||
meLabel={t('me')}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
listRef.current?.scrollToEnd({ animated: true });
|
||||
})
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{/* WeChat-style input bar - 贴在底部,非浮空 */}
|
||||
<View
|
||||
style={[
|
||||
styles.inputBarWrapper,
|
||||
{
|
||||
paddingBottom: insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ChatInputBar
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() => {
|
||||
setInputMode((m) => {
|
||||
if (m === 'text') {
|
||||
Keyboard.dismiss();
|
||||
}
|
||||
return m === 'text' ? 'voice' : 'text';
|
||||
});
|
||||
}}
|
||||
onAddPress={() => {}}
|
||||
onStartRecording={handleStartRecording}
|
||||
onStopRecording={() => void stopRecording()}
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
sendLabel={t('send')}
|
||||
switchToVoiceLabel={t('switchToVoice')}
|
||||
switchToTextLabel={t('switchToText')}
|
||||
tapToStartLabel={t('tapToStartRecording')}
|
||||
tapToEndLabel={t('tapToEndRecording')}
|
||||
cancelRecordingLabel={t('cancelRecording')}
|
||||
disabled={connectionState !== 'connected'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{screen}
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
@@ -966,7 +1063,7 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(141, 140, 144, 0.14)',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
inputCenterFlex: {
|
||||
flex: 1,
|
||||
@@ -1037,11 +1134,11 @@ const styles = StyleSheet.create({
|
||||
} as const,
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
lineHeight: CHAT_INPUT_LINE_H,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
padding: 0,
|
||||
minHeight: 22, // 保持空内容时至少一行高度
|
||||
maxHeight: 88, // 4 lines * 22 lineHeight
|
||||
maxHeight: CHAT_INPUT_MAX_H,
|
||||
width: '100%',
|
||||
},
|
||||
sendButton: {
|
||||
height: 44,
|
||||
|
||||
@@ -1,29 +1,157 @@
|
||||
import React from 'react';
|
||||
import { ScrollView, View } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { PURGE_USER_DATA_CONFIRMATION } from '@/features/profile/constants';
|
||||
import { usePurgeUserData } from '@/features/profile/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** ScreenHeader 可视高度近似:安全区顶 + 上下 padding + minHeight 行 */
|
||||
function headerKeyboardOffset(topInset: number) {
|
||||
return Platform.OS === 'ios' ? topInset + 72 : 0;
|
||||
}
|
||||
|
||||
export default function DeleteDataScreen() {
|
||||
const { t } = useTranslation('profile');
|
||||
const insets = useSafeAreaInsets();
|
||||
const [phrase, setPhrase] = useState('');
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const purge = usePurgeUserData();
|
||||
|
||||
const phraseOk = phrase.trim() === PURGE_USER_DATA_CONFIRMATION;
|
||||
|
||||
const runPurge = () => {
|
||||
purge.mutate(
|
||||
{ confirmation: PURGE_USER_DATA_CONFIRMATION },
|
||||
{
|
||||
onSettled: () => setConfirmOpen(false),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const scrollBottomPad = Math.max(insets.bottom, 16) + 120;
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-background">
|
||||
<ScreenHeader title={t('dataPrivacy.deleteAll')} />
|
||||
<ScrollView
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{ padding: 16, gap: 16 }}
|
||||
<KeyboardAvoidingView
|
||||
className="flex-1"
|
||||
behavior={Platform.OS === 'web' ? undefined : 'padding'}
|
||||
keyboardVerticalOffset={headerKeyboardOffset(insets.top)}
|
||||
>
|
||||
<View className="rounded-xl border border-border bg-card p-4">
|
||||
<Text variant="large" className="text-foreground">
|
||||
{t('dataPrivacy.deleteAll')}
|
||||
</Text>
|
||||
<Text className="mt-2 text-sm text-muted-foreground">
|
||||
{t('dataPrivacy.deleteUnderDevelopment')}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="interactive"
|
||||
showsVerticalScrollIndicator={false}
|
||||
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios'}
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
paddingBottom: scrollBottomPad,
|
||||
}}
|
||||
>
|
||||
<View className="gap-5 px-screen-gutter pt-2">
|
||||
<View className="rounded-xl border border-border bg-card p-5">
|
||||
<Text className="text-base font-semibold leading-snug text-foreground">
|
||||
{t('dataPrivacy.purgeWarningTitle')}
|
||||
</Text>
|
||||
<Text className="mt-3 text-sm leading-6 text-muted-foreground">
|
||||
{t('dataPrivacy.purgeWarningBody')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="gap-2.5">
|
||||
<Text className="text-sm leading-5 text-muted-foreground">
|
||||
{t('dataPrivacy.purgePhraseHint')}
|
||||
</Text>
|
||||
<View className="rounded-lg border border-border bg-muted/25 px-3 py-3">
|
||||
<Text
|
||||
className="text-sm leading-relaxed text-foreground"
|
||||
selectable
|
||||
>
|
||||
{PURGE_USER_DATA_CONFIRMATION}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="gap-2">
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
{t('dataPrivacy.purgeInputLabel')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('dataPrivacy.purgeInputPlaceholder')}
|
||||
value={phrase}
|
||||
onChangeText={setPhrase}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
multiline
|
||||
/>
|
||||
</View>
|
||||
|
||||
{purge.error && (
|
||||
<Text className="text-sm text-destructive/90">
|
||||
{purge.error.message}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!phraseOk || purge.isPending}
|
||||
onPress={() => setConfirmOpen(true)}
|
||||
className="border-destructive/25 active:bg-destructive/5"
|
||||
>
|
||||
<Text className="font-medium text-destructive">
|
||||
{t('dataPrivacy.purgeOpenConfirm')}
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t('dataPrivacy.purgeDialogTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('dataPrivacy.purgeDialogDescription')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={purge.isPending}>
|
||||
<Text>{t('dataPrivacy.purgeDialogCancel')}</Text>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={purge.isPending}
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onPress={runPurge}
|
||||
>
|
||||
<Text className="font-medium text-destructive-foreground">
|
||||
{purge.isPending
|
||||
? t('dataPrivacy.purgeSubmitting')
|
||||
: t('dataPrivacy.purgeDialogConfirm')}
|
||||
</Text>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
@@ -400,12 +399,13 @@ export default function MemoirScreen() {
|
||||
const createConversation = useCreateConversation();
|
||||
const checkCover = useCheckCoverGeneration();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const didRunInitialCoverCheckRef = useRef(false);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
checkCover.mutate(undefined);
|
||||
}, [checkCover]),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (didRunInitialCoverCheckRef.current) return;
|
||||
didRunInitialCoverCheckRef.current = true;
|
||||
checkCover.mutate(undefined);
|
||||
}, [checkCover]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
|
||||
@@ -48,7 +48,7 @@ export function AppSettingsProvider({ children }: PropsWithChildren) {
|
||||
const { t } = useTranslation('app');
|
||||
|
||||
const [language, setLanguageState] = useState<AppLanguage | null>(null);
|
||||
const [largeText, setLargeTextState] = useState(false);
|
||||
const [largeText, setLargeTextState] = useState(true);
|
||||
const [darkMode, setDarkModeState] = useState(false);
|
||||
const [themeName, setThemeNameState] = useState<ThemeName>('default');
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function clearAppLanguage(): Promise<void> {
|
||||
|
||||
export async function getLargeText(): Promise<boolean> {
|
||||
const v = await getStored(KEY_LARGE_TEXT);
|
||||
if (v == null || v === '') return true;
|
||||
return v === 'true';
|
||||
}
|
||||
|
||||
|
||||
459
app-expo/src/features/memoir/markdown-renderer.tsx
Normal file
459
app-expo/src/features/memoir/markdown-renderer.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Markdown 渲染器:使用 react-native-markdown-display 渲染 canonical_markdown。
|
||||
* 线上正文以 asset:// 或已解析的 https 为准;遗留 {{IMAGE:...}} 仅从展示层剥离,不作为协议。
|
||||
*/
|
||||
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import React from 'react';
|
||||
import { Platform, StyleSheet, Text, View } from 'react-native';
|
||||
import Markdown from 'react-native-markdown-display';
|
||||
|
||||
import type { ImageAsset } from './types';
|
||||
|
||||
const PLACEHOLDER_RE = /\{\{\{\{IMAGE:(.*?)\}\}\}\}|\{\{IMAGE:(.*?)\}\}/g;
|
||||
|
||||
function buildPlaceholderToAssetMap(
|
||||
assets: ImageAsset[],
|
||||
): Map<string, ImageAsset> {
|
||||
const map = new Map<string, ImageAsset>();
|
||||
for (const a of assets) {
|
||||
if (a.placeholder?.trim()) {
|
||||
map.set(a.placeholder.trim(), a);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 移除遗留 IMAGE 占位符(不参与正文协议)。 */
|
||||
export function stripLegacyImagePlaceholders(markdown: string): string {
|
||||
return markdown.replace(PLACEHOLDER_RE, '').replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理正文:先用 assets 替换可匹配的遗留占位符,再剥离剩余占位符。
|
||||
*/
|
||||
export function replaceImagePlaceholders(
|
||||
markdown: string,
|
||||
assets: ImageAsset[],
|
||||
): string {
|
||||
const assetMap = buildPlaceholderToAssetMap(assets);
|
||||
let out = markdown;
|
||||
if (assetMap.size > 0) {
|
||||
out = markdown.replace(PLACEHOLDER_RE, (match) => {
|
||||
const asset = assetMap.get(match.trim());
|
||||
if (!asset?.url) return '';
|
||||
const caption = (asset.description ?? '').replace(/[\]\\]/g, '\\$&');
|
||||
return ``;
|
||||
});
|
||||
}
|
||||
return stripLegacyImagePlaceholders(out);
|
||||
}
|
||||
|
||||
/** 顶层正文段落(body 直属,非列表/引用内)用于首行缩进 */
|
||||
interface AstNodeLite {
|
||||
type: string;
|
||||
key: string;
|
||||
children?: AstNodeLite[];
|
||||
}
|
||||
|
||||
function isTopLevelBodyParagraph(parentNodes: AstNodeLite[]): boolean {
|
||||
const pi = parentNodes.findIndex((n) => n.type === 'paragraph');
|
||||
if (pi < 0) return false;
|
||||
return parentNodes[pi + 1]?.type === 'body';
|
||||
}
|
||||
|
||||
function isFirstParagraphUnderBody(
|
||||
paragraph: AstNodeLite,
|
||||
body: AstNodeLite,
|
||||
): boolean {
|
||||
if (!body.children?.length) return false;
|
||||
for (const c of body.children) {
|
||||
if (c.type === 'paragraph') return c.key === paragraph.key;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function firstGrapheme(s: string): string {
|
||||
if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) {
|
||||
const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
||||
for (const { segment } of seg.segment(s)) {
|
||||
return segment;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
const cp = s.codePointAt(0);
|
||||
if (cp === undefined) return '';
|
||||
return String.fromCodePoint(cp);
|
||||
}
|
||||
|
||||
const PARA_FIRST_LINE_INDENT = '\u3000\u3000';
|
||||
|
||||
const READING_COLORS = {
|
||||
primary: '#8177A6',
|
||||
onSurface: '#1b1b1f',
|
||||
onSurfaceVariant: '#48454e',
|
||||
divider: 'rgba(141, 140, 144, 0.2)',
|
||||
/** 故事间 `---` 分隔线:需明显高于正文底色的对比度,否则几乎不可见 */
|
||||
horizontalRule: 'rgba(121, 117, 127, 0.42)',
|
||||
};
|
||||
|
||||
const FONT_FAMILIES = {
|
||||
serif:
|
||||
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
|
||||
'serif',
|
||||
sans:
|
||||
Platform.select({
|
||||
ios: 'System',
|
||||
android: 'sans-serif',
|
||||
default: 'sans-serif',
|
||||
}) ?? 'sans-serif',
|
||||
};
|
||||
|
||||
const FONT_SIZES = { small: 16, default: 20, large: 24 };
|
||||
const LINE_HEIGHTS = { small: 30, default: 38, large: 44 };
|
||||
|
||||
export interface MarkdownRendererProps {
|
||||
markdown: string;
|
||||
renderedAssets: ImageAsset[];
|
||||
coverImageUrl: string | null;
|
||||
fontSize: 'small' | 'default' | 'large';
|
||||
fontFamily: 'serif' | 'sans';
|
||||
backgroundColor: string;
|
||||
contentWidth: number;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
markdown,
|
||||
renderedAssets,
|
||||
coverImageUrl,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
backgroundColor,
|
||||
contentWidth,
|
||||
}: MarkdownRendererProps) {
|
||||
const processedMarkdown = React.useMemo(
|
||||
() => replaceImagePlaceholders(markdown, renderedAssets),
|
||||
[markdown, renderedAssets],
|
||||
);
|
||||
|
||||
const [heroLoadFailed, setHeroLoadFailed] = React.useState(false);
|
||||
/** 每段第一个 text 节点只缩进一次;全文仅首段首字下沉 */
|
||||
const paragraphFirstTextKeysRef = React.useRef<Set<string>>(new Set());
|
||||
const dropCapConsumedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHeroLoadFailed(false);
|
||||
}, [coverImageUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
paragraphFirstTextKeysRef.current = new Set();
|
||||
dropCapConsumedRef.current = false;
|
||||
}, [processedMarkdown]);
|
||||
|
||||
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
|
||||
const bodySize = FONT_SIZES[fontSize];
|
||||
const lineHeight = LINE_HEIGHTS[fontSize];
|
||||
const fontFam = FONT_FAMILIES[fontFamily];
|
||||
|
||||
const markdownStyles = React.useMemo(
|
||||
() =>
|
||||
StyleSheet.create({
|
||||
body: {
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
fontFamily: fontFam,
|
||||
},
|
||||
heading1: {
|
||||
color: READING_COLORS.primary,
|
||||
fontSize: bodySize * 1.5,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFam,
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading2: {
|
||||
color: READING_COLORS.primary,
|
||||
fontSize: bodySize * 1.2,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFam,
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
heading3: {
|
||||
color: READING_COLORS.primary,
|
||||
fontSize: bodySize * 1.1,
|
||||
fontWeight: '700',
|
||||
fontFamily: fontFam,
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
},
|
||||
paragraph: {
|
||||
marginTop: 0,
|
||||
marginBottom: 16,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
fontFamily: fontFam,
|
||||
},
|
||||
blockquote: {
|
||||
backgroundColor: 'transparent',
|
||||
borderLeftColor: READING_COLORS.primary,
|
||||
borderLeftWidth: 4,
|
||||
marginLeft: 0,
|
||||
paddingLeft: 16,
|
||||
marginVertical: 16,
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
fontFamily: fontFam,
|
||||
},
|
||||
hr: {
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
backgroundColor: READING_COLORS.horizontalRule,
|
||||
height: Platform.OS === 'android' ? 2 : StyleSheet.hairlineWidth,
|
||||
marginVertical: 28,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
borderRadius: 12,
|
||||
marginVertical: 16,
|
||||
},
|
||||
text: {
|
||||
color: READING_COLORS.onSurfaceVariant,
|
||||
fontSize: bodySize,
|
||||
lineHeight,
|
||||
fontFamily: fontFam,
|
||||
},
|
||||
}),
|
||||
[bodySize, lineHeight, fontFam],
|
||||
);
|
||||
|
||||
const rules = React.useMemo(() => {
|
||||
const dropCapFontSize = bodySize * 2.15;
|
||||
/** 首行必须容纳下沉字高,否则父级 lineHeight 会裁切嵌套大字 */
|
||||
const dropCapLineHeight = Math.max(
|
||||
lineHeight,
|
||||
Math.ceil(dropCapFontSize * 1.12),
|
||||
);
|
||||
const dropCapTextStyle = {
|
||||
color: READING_COLORS.primary,
|
||||
fontSize: dropCapFontSize,
|
||||
lineHeight: Math.ceil(dropCapFontSize * 1.08),
|
||||
fontFamily: fontFam,
|
||||
fontWeight: '600' as const,
|
||||
};
|
||||
|
||||
return {
|
||||
hr: (node: { key: string }) => (
|
||||
<View
|
||||
key={node.key}
|
||||
style={{
|
||||
width: '100%',
|
||||
alignSelf: 'stretch',
|
||||
height: Platform.OS === 'android' ? 2 : StyleSheet.hairlineWidth,
|
||||
backgroundColor: READING_COLORS.horizontalRule,
|
||||
marginVertical: 28,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
image: (
|
||||
node: { key: string; attributes: Record<string, string | undefined> },
|
||||
_children: React.ReactNode[],
|
||||
_parent: unknown[],
|
||||
_styles: unknown,
|
||||
) => {
|
||||
const src = node.attributes.src;
|
||||
const alt = node.attributes.alt;
|
||||
if (!src || (!src.startsWith('http') && !src.startsWith('https')))
|
||||
return null;
|
||||
return (
|
||||
<Image
|
||||
key={node.key}
|
||||
source={{ uri: src }}
|
||||
alt={alt ?? 'Chapter image'}
|
||||
style={markdownStyles.image}
|
||||
/>
|
||||
);
|
||||
},
|
||||
text: (
|
||||
node: { key: string; content: string },
|
||||
_children: React.ReactNode[],
|
||||
parentNodes: AstNodeLite[],
|
||||
styles: { text: object },
|
||||
inheritedStyles: Record<string, unknown> = {},
|
||||
) => {
|
||||
const baseStyle = [inheritedStyles, styles.text];
|
||||
const content = node.content ?? '';
|
||||
|
||||
if (!isTopLevelBodyParagraph(parentNodes)) {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const paragraph = parentNodes.find((n) => n.type === 'paragraph');
|
||||
const body = parentNodes.find((n) => n.type === 'body');
|
||||
if (!paragraph || !body) {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const seen = paragraphFirstTextKeysRef.current.has(paragraph.key);
|
||||
if (!seen && content.trim() === '') {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (seen) {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
paragraphFirstTextKeysRef.current.add(paragraph.key);
|
||||
|
||||
const leadingWs = content.match(/^\s*/)?.[0] ?? '';
|
||||
const afterWs = content.slice(leadingWs.length);
|
||||
const docFirstParagraph = isFirstParagraphUnderBody(paragraph, body);
|
||||
const wantDropCap =
|
||||
docFirstParagraph &&
|
||||
!dropCapConsumedRef.current &&
|
||||
afterWs.length > 0;
|
||||
|
||||
if (!wantDropCap) {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{PARA_FIRST_LINE_INDENT}
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const g = firstGrapheme(afterWs);
|
||||
if (!g) {
|
||||
return (
|
||||
<Text key={node.key} style={baseStyle}>
|
||||
{PARA_FIRST_LINE_INDENT}
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
dropCapConsumedRef.current = true;
|
||||
const tail = afterWs.slice(g.length);
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={node.key}
|
||||
style={[
|
||||
baseStyle,
|
||||
{
|
||||
lineHeight: dropCapLineHeight,
|
||||
...(Platform.OS === 'android'
|
||||
? { includeFontPadding: false }
|
||||
: {}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{PARA_FIRST_LINE_INDENT}
|
||||
{leadingWs}
|
||||
<Text style={dropCapTextStyle}>{g}</Text>
|
||||
{tail}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [bodySize, fontFam, lineHeight, markdownStyles.image]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasCoverImage && (
|
||||
<View
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: 4 / 5,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: coverImageUrl! }}
|
||||
alt="Chapter hero"
|
||||
onError={() => setHeroLoadFailed(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['transparent', backgroundColor]}
|
||||
locations={[0.3, 1]}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: 20,
|
||||
marginTop: hasCoverImage ? -28 : 0,
|
||||
paddingTop: hasCoverImage ? 40 : 12,
|
||||
paddingBottom: 48,
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: contentWidth,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{processedMarkdown ? (
|
||||
<Markdown style={markdownStyles} rules={rules} mergeStyle>
|
||||
{processedMarkdown}
|
||||
</Markdown>
|
||||
) : null}
|
||||
|
||||
{processedMarkdown.trim().length > 0 && (
|
||||
<View
|
||||
style={{
|
||||
paddingTop: 24,
|
||||
paddingBottom: 24,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 96,
|
||||
height: 1,
|
||||
backgroundColor: READING_COLORS.divider,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
LegalDocType,
|
||||
Plan,
|
||||
QuotaCheck,
|
||||
PurgeUserDataRequest,
|
||||
PurgeUserDataResponse,
|
||||
SubmitFeedbackRequest,
|
||||
UpdateProfileRequest,
|
||||
UserProfile,
|
||||
@@ -42,6 +44,10 @@ export const profileApi = {
|
||||
return api.post<FeedbackResponse>('/api/feedback', { body });
|
||||
},
|
||||
|
||||
purgeUserData(body: PurgeUserDataRequest) {
|
||||
return api.post<PurgeUserDataResponse>('/api/user/data/purge', { body });
|
||||
},
|
||||
|
||||
/**
|
||||
* Legal docs are HTML responses, not JSON.
|
||||
* Fetched as raw text from the API base URL.
|
||||
|
||||
6
app-expo/src/features/profile/constants.ts
Normal file
6
app-expo/src/features/profile/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 与后端 `api/app/features/user/schemas.py` 中
|
||||
* `PURGE_USER_DATA_CONFIRMATION` 必须完全一致。
|
||||
*/
|
||||
export const PURGE_USER_DATA_CONFIRMATION =
|
||||
'我确认永久删除我的全部回忆与对话数据' as const;
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
import { tokenManager } from '@/core/auth/token-manager';
|
||||
import { authKeys } from '@/features/auth/hooks';
|
||||
|
||||
import { profileApi } from './api';
|
||||
import type {
|
||||
LegalDocType,
|
||||
PurgeUserDataRequest,
|
||||
SubmitFeedbackRequest,
|
||||
UpdateProfileRequest,
|
||||
} from './types';
|
||||
@@ -82,6 +87,24 @@ export function useSubmitFeedback() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 永久清空服务端业务数据;成功后服务端会吊销所有 refresh token,
|
||||
* 因此仅清本地会话并跳转登录(不再调用 logout 接口)。
|
||||
*/
|
||||
export function usePurgeUserData() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: PurgeUserDataRequest) => profileApi.purgeUserData(body),
|
||||
onSuccess: async () => {
|
||||
await tokenManager.clearTokens();
|
||||
queryClient.clear();
|
||||
queryClient.setQueryData(authKeys.tokenCheck, false);
|
||||
router.replace('/(auth)/login');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Legal ───
|
||||
|
||||
export function useLegalDoc(type: LegalDocType) {
|
||||
|
||||
@@ -86,6 +86,17 @@ export interface FeedbackResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── Purge user data ───
|
||||
|
||||
export interface PurgeUserDataRequest {
|
||||
confirmation: string;
|
||||
}
|
||||
|
||||
export interface PurgeUserDataResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ─── Legal ───
|
||||
|
||||
export type LegalDocType = 'terms' | 'privacy';
|
||||
|
||||
@@ -140,13 +140,13 @@ interface Resources {
|
||||
};
|
||||
appExperience: {
|
||||
language: 'Language';
|
||||
languageDesc: 'App display language';
|
||||
languageDesc: 'Display language';
|
||||
largeText: 'Large Text';
|
||||
largeTextDesc: 'Make reading easier';
|
||||
nightMode: 'Night Mode';
|
||||
nightModeDesc: 'Use dark theme';
|
||||
theme: 'Theme';
|
||||
themeDesc: 'App color theme';
|
||||
themeDesc: 'Color theme';
|
||||
title: 'App Experience';
|
||||
};
|
||||
dataPrivacy: {
|
||||
@@ -154,6 +154,17 @@ interface Resources {
|
||||
deleteUnderDevelopment: 'Delete data feature is under development.';
|
||||
exportAll: 'Export All Data';
|
||||
exportUnderDevelopment: 'Export feature is under development.';
|
||||
purgeDialogCancel: 'Cancel';
|
||||
purgeDialogConfirm: 'Delete permanently';
|
||||
purgeDialogDescription: 'This cannot be undone. Your data will be removed immediately.';
|
||||
purgeDialogTitle: 'Final confirmation';
|
||||
purgeInputLabel: 'Confirmation phrase';
|
||||
purgeInputPlaceholder: 'Type the phrase shown above';
|
||||
purgeOpenConfirm: 'I understand, continue';
|
||||
purgePhraseHint: 'Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:';
|
||||
purgeSubmitting: 'Deleting…';
|
||||
purgeWarningBody: 'This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.';
|
||||
purgeWarningTitle: 'Before you continue';
|
||||
title: 'Data & Privacy';
|
||||
};
|
||||
editAvatar: 'Edit Profile Picture';
|
||||
|
||||
@@ -19,6 +19,17 @@
|
||||
"deleteUnderDevelopment": "Delete data feature is under development.",
|
||||
"exportAll": "Export All Data",
|
||||
"exportUnderDevelopment": "Export feature is under development.",
|
||||
"purgeDialogCancel": "Cancel",
|
||||
"purgeDialogConfirm": "Delete permanently",
|
||||
"purgeDialogDescription": "This cannot be undone. Your data will be removed immediately.",
|
||||
"purgeDialogTitle": "Final confirmation",
|
||||
"purgeInputLabel": "Confirmation phrase",
|
||||
"purgeInputPlaceholder": "Type the phrase shown above",
|
||||
"purgeOpenConfirm": "I understand, continue",
|
||||
"purgePhraseHint": "Type the following Chinese sentence exactly (every character and punctuation). The server only accepts this exact phrase:",
|
||||
"purgeSubmitting": "Deleting…",
|
||||
"purgeWarningBody": "This permanently deletes your conversations, memory, stories, chapters, orders, and related files in cloud storage. All devices will be signed out.\nYou can still log in with the same phone number, but your previous content cannot be restored.",
|
||||
"purgeWarningTitle": "Before you continue",
|
||||
"title": "Data & Privacy"
|
||||
},
|
||||
"editAvatar": "Edit Profile Picture",
|
||||
|
||||
@@ -19,6 +19,17 @@
|
||||
"deleteUnderDevelopment": "删除数据功能开发中,敬请期待。",
|
||||
"exportAll": "导出所有数据",
|
||||
"exportUnderDevelopment": "导出功能开发中,敬请期待。",
|
||||
"purgeDialogCancel": "取消",
|
||||
"purgeDialogConfirm": "确认永久删除",
|
||||
"purgeDialogDescription": "确定要执行吗?删除后立即生效,无法撤销。",
|
||||
"purgeDialogTitle": "最后确认",
|
||||
"purgeInputLabel": "确认口令",
|
||||
"purgeInputPlaceholder": "在此输入完整确认句",
|
||||
"purgeOpenConfirm": "我了解后果,继续删除",
|
||||
"purgePhraseHint": "请在下方输入框中完整输入以下句子(须一字不差,含标点):",
|
||||
"purgeSubmitting": "正在删除…",
|
||||
"purgeWarningBody": "将永久删除账号下的对话、记忆、故事、章节、订单等业务数据,并删除云端已关联的图片等文件。所有设备将立即退出登录。\n您的手机号与账号仍可登录,但此前内容无法恢复。",
|
||||
"purgeWarningTitle": "清空数据前请知悉",
|
||||
"title": "数据与隐私"
|
||||
},
|
||||
"editAvatar": "编辑头像",
|
||||
|
||||
Reference in New Issue
Block a user