diff --git a/app-expo/src/app/(tabs)/memoir.tsx b/app-expo/src/app/(tabs)/memoir.tsx
index 816bdc9..1b6045e 100644
--- a/app-expo/src/app/(tabs)/memoir.tsx
+++ b/app-expo/src/app/(tabs)/memoir.tsx
@@ -28,7 +28,16 @@ import {
buildFrameworkChapterPlaceholders,
mergeFrameworkChaptersWithFetched,
} from '@/features/memoir/framework-chapter-keys';
-import { useChapters, useCheckCoverGeneration } from '@/features/memoir/hooks';
+import {
+ memoirDraftCharsRemaining,
+ memoirDraftHasStarted,
+ resolvedChapterCategory,
+} from '@/features/memoir/draft-progress';
+import {
+ useChapters,
+ useCheckCoverGeneration,
+ useMemoirState,
+} from '@/features/memoir/hooks';
import type { ChapterViewModel } from '@/features/memoir/types';
type ChapterVariant = 'completed' | 'drafting';
@@ -69,11 +78,15 @@ function ChapterCard({
variant,
t,
onReadPress,
+ draftStarted = false,
+ draftRemainingChars = 0,
}: {
item: ChapterViewModel;
variant: ChapterVariant;
t: (key: string) => string;
onReadPress: () => void;
+ draftStarted?: boolean;
+ draftRemainingChars?: number;
}) {
const typography = useTypography();
const { width } = useWindowDimensions();
@@ -225,7 +238,7 @@ function ChapterCard({
{item.title}
-
+
{t('statusDrafting')}
+ {!draftStarted ? (
+
+ {t('draftingInviteChat')}
+
+ ) : (
+
+
+ {t('draftingGenerating')}
+
+ {draftRemainingChars > 0 ? (
+
+ {t('draftingWordsRemaining').replace(
+ '{{count}}',
+ String(draftRemainingChars),
+ )}
+
+ ) : null}
+
+ )}
@@ -267,6 +312,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
export default function MemoirScreen() {
const { t } = useTranslation('memoir');
const { viewModels: chapters, isLoading, isError, refetch } = useChapters();
+ const { data: memoirState, refetch: refetchMemoirState } = useMemoirState();
const checkCover = useCheckCoverGeneration();
const [refreshing, setRefreshing] = useState(false);
const didRunInitialCoverCheckRef = useRef(false);
@@ -291,11 +337,11 @@ export default function MemoirScreen() {
setRefreshing(true);
try {
await checkCover.mutateAsync(undefined);
- await refetch();
+ await Promise.all([refetch(), refetchMemoirState()]);
} finally {
setRefreshing(false);
}
- }, [checkCover, refetch]);
+ }, [checkCover, refetch, refetchMemoirState]);
const handleReadChapter = useCallback((chapterId: string) => {
router.push(`/(main)/chapter/${chapterId}`);
@@ -329,15 +375,34 @@ export default function MemoirScreen() {
) : isError ? (
void refetch()} />
) : (
- displayChapters.map((item) => (
- string}
- onReadPress={() => handleReadChapter(item.id)}
- />
- ))
+ displayChapters.map((item) => {
+ const variant = getChapterVariant(item);
+ const cat = resolvedChapterCategory(item);
+ const started =
+ variant === 'drafting'
+ ? memoirDraftHasStarted(
+ memoirState?.slots,
+ cat,
+ item.wordCount,
+ )
+ : false;
+ const remaining =
+ variant === 'drafting'
+ ? memoirDraftCharsRemaining(item.wordCount)
+ : 0;
+
+ return (
+ string}
+ onReadPress={() => handleReadChapter(item.id)}
+ />
+ );
+ })
)}
diff --git a/app-expo/src/features/memoir/draft-progress.ts b/app-expo/src/features/memoir/draft-progress.ts
new file mode 100644
index 0000000..34d04ff
--- /dev/null
+++ b/app-expo/src/features/memoir/draft-progress.ts
@@ -0,0 +1,53 @@
+import type { MemoirState } from './types';
+
+import { CHAPTER_CATEGORY_BY_ORDER_INDEX } from './framework-chapter-keys';
+
+/** 与后端 `reading_segment_materialize.MIN_STORY_CHARS_IN_CHAPTER` 一致:章节可读成稿阈值 */
+export const MIN_CHAPTER_DISPLAY_CHARS = 300;
+
+/**
+ * Chat 口述槽的阶段键(MemoirState.slots);多章 career_* 共用 `career`,beliefs/summary 与 `belief` 槽对齐。
+ */
+export function chapterCategoryToInterviewStage(category: string): string {
+ const c = (category ?? '').trim();
+ if (!c) return 'childhood';
+ if (c.startsWith('career_')) return 'career';
+ if (c === 'beliefs' || c === 'summary') return 'belief';
+ return c;
+}
+
+export function resolvedChapterCategory(vm: {
+ category: string;
+ orderIndex: number;
+}): string {
+ const raw = vm.category?.trim();
+ if (raw) return raw;
+ return CHAPTER_CATEGORY_BY_ORDER_INDEX[vm.orderIndex] ?? 'childhood';
+}
+
+export function interviewStageHasSnippetMaterial(
+ slots: MemoirState['slots'] | undefined,
+ stage: string,
+): boolean {
+ if (!slots) return false;
+ const block = slots[stage];
+ if (!block) return false;
+ return Object.values(block).some(
+ (cell) => (cell?.snippet ?? '').trim().length > 0,
+ );
+}
+
+export function memoirDraftHasStarted(
+ slots: MemoirState['slots'] | undefined,
+ chapterCategory: string,
+ chapterWordCount: number,
+): boolean {
+ const stage = chapterCategoryToInterviewStage(chapterCategory);
+ if (chapterWordCount > 0) return true;
+ return interviewStageHasSnippetMaterial(slots, stage);
+}
+
+export function memoirDraftCharsRemaining(chapterWordCount: number): number {
+ const n = typeof chapterWordCount === 'number' ? chapterWordCount : 0;
+ return Math.max(0, MIN_CHAPTER_DISPLAY_CHARS - n);
+}
diff --git a/app-expo/src/features/memoir/framework-chapter-keys.ts b/app-expo/src/features/memoir/framework-chapter-keys.ts
index 798dc8b..e1835a0 100644
--- a/app-expo/src/features/memoir/framework-chapter-keys.ts
+++ b/app-expo/src/features/memoir/framework-chapter-keys.ts
@@ -16,6 +16,18 @@ export const FRAMEWORK_CHAPTER_KEYS = [
export type FrameworkChapterKey = (typeof FRAMEWORK_CHAPTER_KEYS)[number];
+/** 与后端 `CHAPTER_ORDER` 一致(用于占位章节缺省 category) */
+export const CHAPTER_CATEGORY_BY_ORDER_INDEX = [
+ 'childhood',
+ 'education',
+ 'career_early',
+ 'career_achievement',
+ 'career_challenge',
+ 'family',
+ 'beliefs',
+ 'summary',
+] as const;
+
export function buildFrameworkChapterPlaceholders(
tr: (key: string) => string,
): ChapterViewModel[] {
diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts
index 06a321b..a1481f9 100644
--- a/app-expo/src/i18n/generated/resources.ts
+++ b/app-expo/src/i18n/generated/resources.ts
@@ -150,6 +150,9 @@ interface Resources {
"settings": "Settings",
"typography": "Typography"
},
+ "draftingGenerating": "Writing your memoir…",
+ "draftingInviteChat": "Chat about this chapter to get started",
+ "draftingWordsRemaining": "{{count}} characters to go",
"emptySubtitle": "Chat with your companion to record your stories",
"emptyTitle": "No memoir yet",
"frameworkChapters": {
diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json
index 7c072ec..ede9f08 100644
--- a/app-expo/src/i18n/locales/en/memoir.json
+++ b/app-expo/src/i18n/locales/en/memoir.json
@@ -40,5 +40,8 @@
"statusDrafting": "Drafting",
"statusLocked": "Locked",
"statusPending": "Pending",
+ "draftingInviteChat": "Chat about this chapter to get started",
+ "draftingGenerating": "Writing your memoir…",
+ "draftingWordsRemaining": "{{count}} characters to go",
"wordsCount": "{{count}} words"
}
diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json
index b69c8f6..382d72c 100644
--- a/app-expo/src/i18n/locales/zh/memoir.json
+++ b/app-expo/src/i18n/locales/zh/memoir.json
@@ -40,5 +40,8 @@
"statusDrafting": "撰写中",
"statusLocked": "已锁定",
"statusPending": "待解锁",
+ "draftingInviteChat": "聊聊这部分内容吧",
+ "draftingGenerating": "正在生成回忆录…",
+ "draftingWordsRemaining": "还差 {{count}} 字",
"wordsCount": "{{count}} 字"
}
diff --git a/app-expo/tests/features/memoir/draft-progress.test.ts b/app-expo/tests/features/memoir/draft-progress.test.ts
new file mode 100644
index 0000000..7916f79
--- /dev/null
+++ b/app-expo/tests/features/memoir/draft-progress.test.ts
@@ -0,0 +1,49 @@
+import {
+ chapterCategoryToInterviewStage,
+ memoirDraftCharsRemaining,
+ memoirDraftHasStarted,
+ MIN_CHAPTER_DISPLAY_CHARS,
+ resolvedChapterCategory,
+} from '@/features/memoir/draft-progress';
+
+describe('draft-progress', () => {
+ test('chapterCategoryToInterviewStage maps career chapters to career', () => {
+ expect(chapterCategoryToInterviewStage('career_early')).toBe('career');
+ expect(chapterCategoryToInterviewStage('career_achievement')).toBe('career');
+ });
+
+ test('chapterCategoryToInterviewStage maps beliefs and summary to belief', () => {
+ expect(chapterCategoryToInterviewStage('beliefs')).toBe('belief');
+ expect(chapterCategoryToInterviewStage('summary')).toBe('belief');
+ });
+
+ test('resolvedChapterCategory falls back to order index', () => {
+ expect(
+ resolvedChapterCategory({ category: '', orderIndex: 2 }),
+ ).toBe('career_early');
+ });
+
+ test('memoirDraftHasStarted when interview slots have snippet', () => {
+ const slots = {
+ childhood: { place: { snippet: '老家在小城', segment_ids: [] } },
+ };
+ expect(
+ memoirDraftHasStarted(slots, 'childhood', 0),
+ ).toBe(true);
+ });
+
+ test('memoirDraftHasStarted when word count positive', () => {
+ expect(memoirDraftHasStarted({}, 'childhood', 12)).toBe(true);
+ });
+
+ test('memoirDraftCharsRemaining caps at zero', () => {
+ expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS)).toBe(0);
+ expect(memoirDraftCharsRemaining(MIN_CHAPTER_DISPLAY_CHARS + 50)).toBe(0);
+ });
+
+ test('memoirDraftCharsRemaining subtracts from threshold', () => {
+ expect(memoirDraftCharsRemaining(100)).toBe(
+ MIN_CHAPTER_DISPLAY_CHARS - 100,
+ );
+ });
+});