diff --git a/AGENTS.md b/AGENTS.md index 7c1a673..eda0216 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,8 @@ Git history and README use Conventional Commits, for example `feat: update conte Pull requests should include a short summary, validation commands run, linked issues when applicable, and screenshots for visible UI changes. Mention data migration, IndexedDB, localStorage, or bundled question JSON changes explicitly. +Pull request titles and descriptions should be written in Chinese. + ## Security & Configuration Tips Do not commit secrets. Use `.env.example` as the public template, and keep API keys in local browser settings or environment-specific configuration. Treat imported question JSON as untrusted input and validate changes against the existing schema before publishing. diff --git a/scripts/checkExternalServices.ts b/scripts/checkExternalServices.ts index 166bede..55e52f6 100644 --- a/scripts/checkExternalServices.ts +++ b/scripts/checkExternalServices.ts @@ -236,6 +236,14 @@ function createSmokeBackup(now: number): SyncData { updatedAt: now, }, ], + questionAnswerOverrides: [ + { + questionId: 'external-smoke-question', + content: 'External smoke custom answer', + createdAt: now - 1_000, + updatedAt: now, + }, + ], questionFlags: [ { questionId: 'external-smoke-question', @@ -319,7 +327,7 @@ async function runGistExternalSmoke(): Promise { const loaded = await githubFetch(token, `/gists/${gistId}`) const loadedContent = await readGistFile(token, loaded, filename) const parsed = parseGistBackupPayload(loadedContent) - assert('Gist read payload', parsed.version === 6, '真实 Gist 读取到的备份版本不是 v6') + assert('Gist read payload', parsed.version === 7, '真实 Gist 读取到的备份版本不是 v7') assert( 'Gist read data', parsed.questionNotes.some((note) => note.questionId === 'external-smoke-question'), @@ -328,6 +336,7 @@ async function runGistExternalSmoke(): Promise { addEvidence('gist.read', { backupVersion: parsed.version, noteCount: parsed.questionNotes.length, + answerOverrideCount: parsed.questionAnswerOverrides.length, starredCount: parsed.questionFlags.filter((flag) => flag.starred).length, aiSessionCount: parsed.aiSessions.length, customQuestionCount: parsed.customQuestions.length, diff --git a/scripts/checkGistSync.ts b/scripts/checkGistSync.ts index 4137dc4..bcd87ce 100644 --- a/scripts/checkGistSync.ts +++ b/scripts/checkGistSync.ts @@ -10,7 +10,13 @@ import { serializeGistBackup, } from '../src/lib/gistSync.ts' import type { AISession } from '../src/store/useAIStore.ts' -import type { Question, QuestionFlag, QuestionNote, StudyRecord } from '../src/types' +import type { + Question, + QuestionAnswerOverride, + QuestionFlag, + QuestionNote, + StudyRecord, +} from '../src/types' interface Failure { name: string @@ -75,6 +81,14 @@ function note(questionId: string, content: string, updatedAt: number): QuestionN return { questionId, content, createdAt: updatedAt - 100, updatedAt } } +function answerOverride( + questionId: string, + content: string, + updatedAt: number, +): QuestionAnswerOverride { + return { questionId, content, createdAt: updatedAt - 100, updatedAt } +} + function flag(questionId: string, starred: boolean, updatedAt: number): QuestionFlag { return { questionId, starred, createdAt: updatedAt - 100, updatedAt } } @@ -126,6 +140,7 @@ const v3Record = record('v3-001', 'mastered', 1700000100000) const v4Note = note('v4-001', 'v4 note', 1700000200000) const v5Session = session('v5-001', 'v5 session', 1700000300000) const v6Flag = flag('v6-001', true, 1700000400000) +const v7AnswerOverride = answerOverride('v7-001', 'v7 custom answer', 1700000500000) const legacy = parseGistBackupPayload( JSON.stringify({ @@ -148,6 +163,11 @@ assert( 'v1 应只保留 custom_ 题目', ) assert('v1 has no notes', legacy.questionNotes.length === 0, 'v1 不应产生题目笔记') +assert( + 'v1 has no answer overrides', + legacy.questionAnswerOverrides.length === 0, + 'v1 不应产生自定义答案', +) assert('v1 has no flags', legacy.questionFlags.length === 0, 'v1 不应产生题目标记') assert('v1 has no ai sessions', legacy.aiSessions.length === 0, 'v1 不应产生 AI 会话') assert( @@ -169,6 +189,11 @@ const v3 = parseGistBackupPayload( assert('v3 decodes compact records', v3.studyRecords[0]?.questionId === 'v3-001', 'v3 记录解码失败') assert('v3 has no notes', v3.questionNotes.length === 0, 'v3 不应产生题目笔记') +assert( + 'v3 has no answer overrides', + v3.questionAnswerOverrides.length === 0, + 'v3 不应产生自定义答案', +) assert('v3 has no flags', v3.questionFlags.length === 0, 'v3 不应产生题目标记') assert('v3 has no ai sessions', v3.aiSessions.length === 0, 'v3 不应产生 AI 会话') @@ -221,6 +246,27 @@ const v6 = parseGistBackupPayload( assert('v6 keeps flags', v6.questionFlags[0]?.questionId === 'v6-001', 'v6 题目标记未恢复') assert('v6 keeps ai sessions', v6.aiSessions[0]?.questionId === 'v5-001', 'v6 AI 会话未恢复') + +const v7 = parseGistBackupPayload( + JSON.stringify({ + version: 7, + exportedAt: '2026-01-04T00:00:00.000Z', + records: compact([v3Record]), + questionNotes: [v4Note], + questionAnswerOverrides: [v7AnswerOverride], + questionFlags: [v6Flag], + aiSessions: [v5Session], + customQuestions: [question('custom_sync_v7')], + customCategories: {}, + customSources: ['v7-source'], + }), +) + +assert( + 'v7 keeps answer overrides', + v7.questionAnswerOverrides[0]?.content === 'v7 custom answer', + 'v7 自定义答案未恢复', +) assertThrows( 'future version rejected', () => parseGistBackupPayload(JSON.stringify({ version: 999 })), @@ -231,6 +277,10 @@ assertThrows('invalid json rejected', () => parseGistBackupPayload('{'), 'invali const local: SyncData = { studyRecords: [record('same-record', 'mastered', 3000), record('local-record', 'review', 4000)], questionNotes: [note('same-note', 'local newer', 5000), note('local-note', 'local only', 4500)], + questionAnswerOverrides: [ + answerOverride('same-answer', 'local newer', 5500), + answerOverride('local-answer', 'local only', 5600), + ], questionFlags: [flag('same-flag', false, 7000), flag('local-flag', true, 7200)], aiSessions: [ session('same-session', 'local newer', 6000), @@ -251,6 +301,10 @@ const remote = { note('same-note', 'remote older', 4000), note('remote-note', 'remote only', 8000), ], + questionAnswerOverrides: [ + answerOverride('same-answer', 'remote older', 5000), + answerOverride('remote-answer', 'remote only', 8100), + ], questionFlags: [flag('same-flag', true, 6500), flag('remote-flag', true, 8200)], aiSessions: [ session('same-session', 'remote older', 5000), @@ -267,6 +321,9 @@ const remote = { const merged = mergeGistBackupData(local, remote) const mergedRecord = merged.backup.studyRecords.find((item) => item.questionId === 'same-record') const mergedNote = merged.backup.questionNotes.find((item) => item.questionId === 'same-note') +const mergedAnswerOverride = merged.backup.questionAnswerOverrides.find( + (item) => item.questionId === 'same-answer', +) const mergedFlag = merged.backup.questionFlags.find((item) => item.questionId === 'same-flag') const mergedSession = merged.backup.aiSessions.find((item) => item.questionId === 'same-session') @@ -290,6 +347,16 @@ assert( merged.backup.questionNotes.some((item) => item.questionId === 'remote-note'), '云端新笔记未合并', ) +assert( + 'merge keeps newer local answer override', + mergedAnswerOverride?.content === 'local newer', + '较新的本地自定义答案被覆盖', +) +assert( + 'merge adds remote answer override', + merged.backup.questionAnswerOverrides.some((item) => item.questionId === 'remote-answer'), + '云端新自定义答案未合并', +) assert( 'merge keeps newer local flag', mergedFlag?.starred === false, @@ -332,6 +399,11 @@ assert( const stats: SyncMergeStats = merged.stats assert('merge stats records', stats.remoteRecordsApplied === 1, '云端记录合并计数不正确') assert('merge stats notes', stats.remoteNotesApplied === 1, '云端笔记合并计数不正确') +assert( + 'merge stats answer overrides', + stats.remoteAnswerOverridesApplied === 1, + '云端自定义答案合并计数不正确', +) assert('merge stats flags', stats.remoteFlagsApplied === 1, '云端重点题标记合并计数不正确') assert('merge stats ai sessions', stats.remoteAISessionsApplied === 1, '云端 AI 会话合并计数不正确') assert('merge stats questions', stats.remoteQuestionsAdded === 1, '云端自定义题合并计数不正确') @@ -340,7 +412,7 @@ assert('merge stats categories', stats.remoteCategoriesAdded === 2, '云端分 const serialized = serializeGistBackup(local, '2026-01-06T00:00:00.000Z') const serializedPayload = JSON.parse(serialized) -assert('serialized payload version', serializedPayload.version === 6, '写入 payload 版本应为 v6') +assert('serialized payload version', serializedPayload.version === 7, '写入 payload 版本应为 v7') assert( 'serialized payload compacts records', serializedPayload.records?.ids?.includes('same-record') && @@ -352,6 +424,13 @@ assert( serializedPayload.questionFlags?.some((item: QuestionFlag) => item.questionId === 'local-flag'), '写入 payload 未包含重点题标记', ) +assert( + 'serialized payload keeps answer overrides', + serializedPayload.questionAnswerOverrides?.some( + (item: QuestionAnswerOverride) => item.questionId === 'local-answer', + ), + '写入 payload 未包含自定义答案', +) assert( 'serialized payload keeps ai sessions', serializedPayload.aiSessions?.some((session: AISession) => session.questionId === 'same-session'), @@ -561,7 +640,8 @@ const createdPayload = createdContent ? JSON.parse(createdContent) : null assert('gist create uses private gist', createBody?.public === false, '创建 Gist 必须是 private') assert( 'gist create writes backup file', - createdPayload?.version === 6 && + createdPayload?.version === 7 && + createdPayload?.questionAnswerOverrides?.length === 2 && createdPayload?.questionFlags?.length === 2 && createdPayload?.aiSessions?.length === 2, `创建 Gist 文件内容错误:${createdContent}`, @@ -629,5 +709,5 @@ if (failures.length > 0) { } console.log( - 'Gist 同步兼容检查通过:v1-v6 解析、未来版本拒绝、双端合并和 mock GitHub API 读写路径正常', + 'Gist 同步兼容检查通过:v1-v7 解析、未来版本拒绝、双端合并和 mock GitHub API 读写路径正常', ) diff --git a/src/components/layout/SettingsDrawer.tsx b/src/components/layout/SettingsDrawer.tsx index 075028c..bd0447d 100644 --- a/src/components/layout/SettingsDrawer.tsx +++ b/src/components/layout/SettingsDrawer.tsx @@ -3,6 +3,7 @@ import { invalidateQuestionsCache } from '@/hooks/useQuestions' import { bulkPutJdMatchReports, bulkPutMockInterviews, + bulkPutQuestionAnswerOverrides, bulkPutQuestionFlags, bulkPutQuestionNotes, bulkPutQuestions, @@ -12,6 +13,7 @@ import { exportAllData, getAllJdMatchReports, getAllMockInterviews, + getAllQuestionAnswerOverrides, getAllQuestionFlags, getAllQuestionNotes, getAllQuestions, @@ -464,14 +466,16 @@ async function withImportImpact( preview: ImportPreview, existingAISessions: Record, ): Promise { - const [questions, records, notes, flags, mockInterviews, jdMatchReports] = await Promise.all([ - getAllQuestions(), - getAllStudyRecords(), - getAllQuestionNotes(), - getAllQuestionFlags(), - getAllMockInterviews(), - getAllJdMatchReports(), - ]) + const [questions, records, notes, answerOverrides, flags, mockInterviews, jdMatchReports] = + await Promise.all([ + getAllQuestions(), + getAllStudyRecords(), + getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), + getAllQuestionFlags(), + getAllMockInterviews(), + getAllJdMatchReports(), + ]) return { ...preview, @@ -491,6 +495,11 @@ async function withImportImpact( new Set(notes.map((note) => note.questionId)), (note) => note.questionId, ), + questionAnswerOverrides: countImportImpact( + preview.questionAnswerOverrides, + new Set(answerOverrides.map((override) => override.questionId)), + (override) => override.questionId, + ), questionFlags: countImportImpact( preview.questionFlags, new Set(flags.map((flag) => flag.questionId)), @@ -586,6 +595,7 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { questions: number records: number notes: number + answerOverrides: number starred: number aiSessions: number mockInterviews: number @@ -634,20 +644,24 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { getAllQuestions(), getAllStudyRecords(), getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), getAllQuestionFlags(), getAllMockInterviews(), getAllJdMatchReports(), - ]).then(([questions, records, notes, flags, mockInterviews, jdMatchReports]) => { - setDataStats({ - questions: questions.length, - records: records.length, - notes: notes.length, - starred: flags.filter((flag) => flag.starred).length, - aiSessions: Object.keys(sessions).length, - mockInterviews: mockInterviews.length, - jdMatchReports: jdMatchReports.length, - }) - }) + ]).then( + ([questions, records, notes, answerOverrides, flags, mockInterviews, jdMatchReports]) => { + setDataStats({ + questions: questions.length, + records: records.length, + notes: notes.length, + answerOverrides: answerOverrides.length, + starred: flags.filter((flag) => flag.starred).length, + aiSessions: Object.keys(sessions).length, + mockInterviews: mockInterviews.length, + jdMatchReports: jdMatchReports.length, + }) + }, + ) } }, [open, sessions, tab]) @@ -863,7 +877,7 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { document.body.removeChild(a) URL.revokeObjectURL(url) showToast( - `已导出 ${data.questions.length} 题、${data.studyRecords.length} 条记录、${data.questionNotes.length} 条笔记、${data.questionFlags.filter((flag) => flag.starred).length} 个重点题、${aiSessions.length} 个 AI 会话、${data.mockInterviews.length} 场模拟面试、${data.jdMatchReports.length} 份 JD 诊断`, + `已导出 ${data.questions.length} 题、${data.studyRecords.length} 条记录、${data.questionNotes.length} 条笔记、${data.questionAnswerOverrides.length} 个自定义答案、${data.questionFlags.filter((flag) => flag.starred).length} 个重点题、${aiSessions.length} 个 AI 会话、${data.mockInterviews.length} 场模拟面试、${data.jdMatchReports.length} 份 JD 诊断`, ) } catch { showToast('导出失败,请重试', 'error') @@ -900,6 +914,7 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { const qCount = importPreview.questions.length const rCount = importPreview.studyRecords.length const nCount = importPreview.questionNotes.length + const answerOverrideCount = importPreview.questionAnswerOverrides.length const flagCount = importPreview.questionFlags.length const starredCount = importPreview.questionFlags.filter((flag) => flag.starred).length const aiCount = importPreview.aiSessions.length @@ -933,6 +948,10 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { await bulkPutQuestionNotes(importPreview.questionNotes) } + if (answerOverrideCount > 0) { + await bulkPutQuestionAnswerOverrides(importPreview.questionAnswerOverrides) + } + if (flagCount > 0) { await bulkPutQuestionFlags(importPreview.questionFlags) } @@ -950,22 +969,25 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { } showToast( - `导入成功:${qCount} 题、${rCount} 条记录、${nCount} 条笔记、${starredCount} 个重点题、${aiCount} 个 AI 会话、${mockInterviewCount} 场模拟面试、${jdMatchReportCount} 份 JD 诊断、${sourceCount} 个来源、${categoryCount} 个分类`, + `导入成功:${qCount} 题、${rCount} 条记录、${nCount} 条笔记、${answerOverrideCount} 个自定义答案、${starredCount} 个重点题、${aiCount} 个 AI 会话、${mockInterviewCount} 场模拟面试、${jdMatchReportCount} 份 JD 诊断、${sourceCount} 个来源、${categoryCount} 个分类`, ) setImportPreview(null) - const [questions, records, notes, flags, mockInterviews, jdMatchReports] = await Promise.all([ - getAllQuestions(), - getAllStudyRecords(), - getAllQuestionNotes(), - getAllQuestionFlags(), - getAllMockInterviews(), - getAllJdMatchReports(), - ]) + const [questions, records, notes, answerOverrides, flags, mockInterviews, jdMatchReports] = + await Promise.all([ + getAllQuestions(), + getAllStudyRecords(), + getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), + getAllQuestionFlags(), + getAllMockInterviews(), + getAllJdMatchReports(), + ]) setDataStats({ questions: questions.length, records: records.length, notes: notes.length, + answerOverrides: answerOverrides.length, starred: flags.filter((flag) => flag.starred).length, aiSessions: countMergedAISessions(sessions, importPreview.aiSessions), mockInterviews: mockInterviews.length, @@ -994,6 +1016,7 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { questions: 0, records: 0, notes: 0, + answerOverrides: 0, starred: 0, aiSessions: 0, mockInterviews: 0, @@ -2661,6 +2684,11 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { { label: '题目总数', value: dataStats.questions, color: 'var(--primary)' }, { label: '学习记录', value: dataStats.records, color: 'var(--success)' }, { label: '题目笔记', value: dataStats.notes, color: 'var(--warning)' }, + { + label: '自定义答案', + value: dataStats.answerOverrides, + color: 'var(--primary)', + }, { label: '重点题', value: dataStats.starred, color: '#f59e0b' }, { label: 'AI 会话', value: dataStats.aiSessions, color: 'var(--text-2)' }, { label: '模拟面试', value: dataStats.mockInterviews, color: 'var(--primary)' }, @@ -2889,6 +2917,11 @@ export function SettingsDrawer({ open, onClose }: SettingsDrawerProps) { value: importPreview.questionNotes.length, impact: importPreview.impact.questionNotes, }, + { + label: '答案', + value: importPreview.questionAnswerOverrides.length, + impact: importPreview.impact.questionAnswerOverrides, + }, { label: '重点', value: importPreview.questionFlags.filter((flag) => flag.starred).length, diff --git a/src/components/ui/MarkdownRenderer.tsx b/src/components/ui/MarkdownRenderer.tsx index f315d5d..8f7df86 100644 --- a/src/components/ui/MarkdownRenderer.tsx +++ b/src/components/ui/MarkdownRenderer.tsx @@ -1,14 +1,17 @@ import { useCallback, useState } from 'react' import type { Components } from 'react-markdown' -import ReactMarkdown from 'react-markdown' +import ReactMarkdown, { defaultUrlTransform } from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' export interface MarkdownRendererProps { content: string className?: string + resolveImageSrc?: (src: string) => string | undefined } +const LOCAL_NOTE_IMAGE_SRC_PREFIX = 'iface-note-image:' + // ─── Copy Button ────────────────────────────────────────────────────────────── function CopyButton({ code }: { code: string }) { @@ -431,13 +434,71 @@ const components: Components = { }, } -export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) { +function transformMarkdownUrl(value: string, key: string) { + if (key === 'src' && value.startsWith(LOCAL_NOTE_IMAGE_SRC_PREFIX)) return value + return defaultUrlTransform(value) +} + +export function MarkdownRenderer({ + content, + className = '', + resolveImageSrc, +}: MarkdownRendererProps) { + const markdownComponents: Components = { + ...components, + img({ src, alt, ...props }) { + const rawSrc = typeof src === 'string' ? src : '' + const resolvedByResolver = rawSrc ? resolveImageSrc?.(rawSrc) : undefined + const missingLocalImage = + rawSrc.startsWith(LOCAL_NOTE_IMAGE_SRC_PREFIX) && !resolvedByResolver + const resolvedSrc = resolvedByResolver ?? rawSrc + + if (missingLocalImage) { + return ( + + 本地图片不可用 + + ) + } + + return ( + {alt + ) + }, + } + return (
{content} diff --git a/src/lib/db.ts b/src/lib/db.ts index ac0a728..0d6c443 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,18 +3,22 @@ import type { JdMatchReport, MockInterviewSession, Question, + QuestionAnswerOverride, QuestionFlag, QuestionNote, + QuestionNoteImage, StudyRecord, } from '../types' const DB_NAME = 'iface_db' -const DB_VERSION = 5 +const DB_VERSION = 7 export const STORES = { QUESTIONS: 'questions', STUDY_RECORDS: 'study_records', QUESTION_NOTES: 'question_notes', + QUESTION_NOTE_IMAGES: 'question_note_images', + QUESTION_ANSWER_OVERRIDES: 'question_answer_overrides', QUESTION_FLAGS: 'question_flags', MOCK_INTERVIEWS: 'mock_interviews', JD_MATCH_REPORTS: 'jd_match_reports', @@ -77,6 +81,23 @@ function getDB(): Promise { notes.createIndex('updatedAt', 'updatedAt', { unique: false }) } + // Local-only images embedded into question notes. + if (!db.objectStoreNames.contains(STORES.QUESTION_NOTE_IMAGES)) { + const noteImages = db.createObjectStore(STORES.QUESTION_NOTE_IMAGES, { + keyPath: 'id', + }) + noteImages.createIndex('questionId', 'questionId', { unique: false }) + noteImages.createIndex('updatedAt', 'updatedAt', { unique: false }) + } + + // Per-question custom reference answers. The original question stays intact. + if (!db.objectStoreNames.contains(STORES.QUESTION_ANSWER_OVERRIDES)) { + const answerOverrides = db.createObjectStore(STORES.QUESTION_ANSWER_OVERRIDES, { + keyPath: 'questionId', + }) + answerOverrides.createIndex('updatedAt', 'updatedAt', { unique: false }) + } + // Per-question flags such as starred/重点题. if (!db.objectStoreNames.contains(STORES.QUESTION_FLAGS)) { const flags = db.createObjectStore(STORES.QUESTION_FLAGS, { @@ -157,15 +178,19 @@ export async function deleteQuestionsBySource(source: string): Promise { if (deletedIds.length > 0) { const recordTx = db.transaction(STORES.STUDY_RECORDS, 'readwrite') const noteTx = db.transaction(STORES.QUESTION_NOTES, 'readwrite') + const answerOverrideTx = db.transaction(STORES.QUESTION_ANSWER_OVERRIDES, 'readwrite') const flagTx = db.transaction(STORES.QUESTION_FLAGS, 'readwrite') await Promise.all([ ...deletedIds.map((id) => recordTx.store.delete(id)), recordTx.done, ...deletedIds.map((id) => noteTx.store.delete(id)), noteTx.done, + ...deletedIds.map((id) => answerOverrideTx.store.delete(id)), + answerOverrideTx.done, ...deletedIds.map((id) => flagTx.store.delete(id)), flagTx.done, ]) + await Promise.all(deletedIds.map((id) => deleteQuestionNoteImagesByQuestionId(id))) } } @@ -175,6 +200,8 @@ export async function deleteQuestionById(id: string): Promise { db.delete(STORES.QUESTIONS, id), db.delete(STORES.STUDY_RECORDS, id), db.delete(STORES.QUESTION_NOTES, id), + deleteQuestionNoteImagesByQuestionId(id), + db.delete(STORES.QUESTION_ANSWER_OVERRIDES, id), db.delete(STORES.QUESTION_FLAGS, id), ]) } @@ -231,7 +258,10 @@ export async function putQuestionNote(note: QuestionNote): Promise { const trimmed = note.content.trim() if (!trimmed) { - await db.delete(STORES.QUESTION_NOTES, note.questionId) + await Promise.all([ + db.delete(STORES.QUESTION_NOTES, note.questionId), + deleteQuestionNoteImagesByQuestionId(note.questionId), + ]) return } @@ -278,6 +308,103 @@ export async function appendQuestionNoteContent( return next } +// ─── Local-only Question Note Images ──────────────────────────────────────── + +export async function getQuestionNoteImages(questionId: string): Promise { + const db = await getDB() + return db.getAllFromIndex(STORES.QUESTION_NOTE_IMAGES, 'questionId', questionId) +} + +export async function putQuestionNoteImage(image: QuestionNoteImage): Promise { + const db = await getDB() + const now = Date.now() + const next: QuestionNoteImage = { + ...image, + createdAt: image.createdAt || now, + updatedAt: now, + } + await db.put(STORES.QUESTION_NOTE_IMAGES, next) + return next +} + +export async function deleteQuestionNoteImagesByQuestionId(questionId: string): Promise { + const db = await getDB() + const tx = db.transaction(STORES.QUESTION_NOTE_IMAGES, 'readwrite') + const index = tx.store.index('questionId') + let cursor = await index.openCursor(questionId) + while (cursor) { + await cursor.delete() + cursor = await cursor.continue() + } + await tx.done +} + +export async function deleteUnusedQuestionNoteImages( + questionId: string, + keepIds: string[], +): Promise { + const keep = new Set(keepIds) + const db = await getDB() + const tx = db.transaction(STORES.QUESTION_NOTE_IMAGES, 'readwrite') + const index = tx.store.index('questionId') + let cursor = await index.openCursor(questionId) + while (cursor) { + if (!keep.has(cursor.value.id)) await cursor.delete() + cursor = await cursor.continue() + } + await tx.done +} + +// ─── Question Answer Overrides ────────────────────────────────────────────── + +export async function getAllQuestionAnswerOverrides(): Promise { + const db = await getDB() + return db.getAll(STORES.QUESTION_ANSWER_OVERRIDES) +} + +export async function getQuestionAnswerOverride( + questionId: string, +): Promise { + const db = await getDB() + return db.get(STORES.QUESTION_ANSWER_OVERRIDES, questionId) +} + +export async function putQuestionAnswerOverride( + override: QuestionAnswerOverride, +): Promise { + const db = await getDB() + const now = Date.now() + const existing = await getQuestionAnswerOverride(override.questionId) + const trimmed = override.content.trim() + + if (!trimmed) { + await db.delete(STORES.QUESTION_ANSWER_OVERRIDES, override.questionId) + return null + } + + const next: QuestionAnswerOverride = { + questionId: override.questionId, + content: override.content, + createdAt: existing?.createdAt ?? override.createdAt ?? now, + updatedAt: now, + } + await db.put(STORES.QUESTION_ANSWER_OVERRIDES, next) + return next +} + +export async function bulkPutQuestionAnswerOverrides( + overrides: QuestionAnswerOverride[], +): Promise { + const db = await getDB() + const tx = db.transaction(STORES.QUESTION_ANSWER_OVERRIDES, 'readwrite') + await Promise.all([...overrides.map((override) => tx.store.put(override)), tx.done]) +} + +export async function deleteQuestionAnswerOverride(questionId: string): Promise { + const db = await getDB() + await db.delete(STORES.QUESTION_ANSWER_OVERRIDES, questionId) +} + // ─── Question Flags ───────────────────────────────────────────────────────── export async function getAllQuestionFlags(): Promise { @@ -627,11 +754,12 @@ export async function removeCustomSource(source: string): Promise { // ─── Export all data (for backup) ──────────────────────────────────────────── export async function exportAllData(): Promise<{ - formatVersion: 5 + formatVersion: 6 exportedAt: string questions: Question[] studyRecords: StudyRecord[] questionNotes: QuestionNote[] + questionAnswerOverrides: QuestionAnswerOverride[] questionFlags: QuestionFlag[] mockInterviews: MockInterviewSession[] jdMatchReports: JdMatchReport[] @@ -642,6 +770,7 @@ export async function exportAllData(): Promise<{ questions, studyRecords, questionNotes, + questionAnswerOverrides, questionFlags, mockInterviews, jdMatchReports, @@ -651,6 +780,7 @@ export async function exportAllData(): Promise<{ getAllQuestions(), getAllStudyRecords(), getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), getAllQuestionFlags(), getAllMockInterviews(), getAllJdMatchReports(), @@ -663,11 +793,12 @@ export async function exportAllData(): Promise<{ } return { - formatVersion: 5, + formatVersion: 6, exportedAt: new Date().toISOString(), questions, studyRecords, questionNotes, + questionAnswerOverrides, questionFlags, mockInterviews, jdMatchReports, @@ -684,6 +815,8 @@ export async function resetDatabase(): Promise { db.clear(STORES.QUESTIONS), db.clear(STORES.STUDY_RECORDS), db.clear(STORES.QUESTION_NOTES), + db.clear(STORES.QUESTION_NOTE_IMAGES), + db.clear(STORES.QUESTION_ANSWER_OVERRIDES), db.clear(STORES.QUESTION_FLAGS), db.clear(STORES.MOCK_INTERVIEWS), db.clear(STORES.JD_MATCH_REPORTS), diff --git a/src/lib/gistSync.ts b/src/lib/gistSync.ts index afa4093..2eb4d04 100644 --- a/src/lib/gistSync.ts +++ b/src/lib/gistSync.ts @@ -22,6 +22,11 @@ * These are authored content, so they are backed up for * both built-in and custom questions. * + * questionAnswerOverrides + * — User-authored replacement reference answers for built-in + * and custom questions. Original question objects remain + * untouched. + * * aiSessions — AI chat history for each question. API keys and model * settings are NOT included; only conversation content is * synced. @@ -71,17 +76,26 @@ * v4 adds per-question notes * v5 adds AI chat sessions * v6 adds per-question flags + * v7 adds custom per-question reference answers */ import type { AISession } from '@/store/useAIStore' -import type { Question, QuestionFlag, QuestionNote, StudyRecord } from '@/types' +import type { + Question, + QuestionAnswerOverride, + QuestionFlag, + QuestionNote, + StudyRecord, +} from '@/types' import { + bulkPutQuestionAnswerOverrides, bulkPutQuestionFlags, bulkPutQuestionNotes, bulkPutQuestions, bulkPutStudyRecords, type CategoryMap, DEFAULT_CATEGORY_MAP, + getAllQuestionAnswerOverrides, getAllQuestionFlags, getAllQuestionNotes, getAllQuestions, @@ -98,7 +112,7 @@ import { const GIST_FILENAME = 'iface-backup.json' const GIST_DESCRIPTION = 'iFace study progress backup (auto-generated)' -const BACKUP_VERSION = 6 +const BACKUP_VERSION = 7 const MINIMUM_SUPPORTED_VERSION = 1 const GH_API = 'https://api.github.com' @@ -269,6 +283,9 @@ function normalizeSyncData(data: Partial): SyncData { return { studyRecords: Array.isArray(data.studyRecords) ? data.studyRecords : [], questionNotes: Array.isArray(data.questionNotes) ? data.questionNotes : [], + questionAnswerOverrides: Array.isArray(data.questionAnswerOverrides) + ? data.questionAnswerOverrides + : [], questionFlags: Array.isArray(data.questionFlags) ? data.questionFlags : [], aiSessions: Array.isArray(data.aiSessions) ? data.aiSessions : [], customQuestions: Array.isArray(data.customQuestions) ? data.customQuestions : [], @@ -296,6 +313,7 @@ function mergeSyncData( stats: { remoteRecordsApplied: 0, remoteNotesApplied: 0, + remoteAnswerOverridesApplied: 0, remoteFlagsApplied: 0, remoteAISessionsApplied: 0, remoteQuestionsAdded: 0, @@ -317,6 +335,12 @@ function mergeSyncData( (note) => note.questionId, (note) => note.updatedAt, ) + const answerOverrides = mergeByTimestamp( + localData.questionAnswerOverrides, + remoteData.questionAnswerOverrides, + (override) => override.questionId, + (override) => override.updatedAt, + ) const aiSessions = mergeByTimestamp( localData.aiSessions, remoteData.aiSessions, @@ -341,6 +365,7 @@ function mergeSyncData( backup: { studyRecords: records.items, questionNotes: notes.items, + questionAnswerOverrides: answerOverrides.items, questionFlags: flags.items, aiSessions: aiSessions.items, customQuestions: questions.items, @@ -350,6 +375,7 @@ function mergeSyncData( stats: { remoteRecordsApplied: records.remoteApplied, remoteNotesApplied: notes.remoteApplied, + remoteAnswerOverridesApplied: answerOverrides.remoteApplied, remoteFlagsApplied: flags.remoteApplied, remoteAISessionsApplied: aiSessions.remoteApplied, remoteQuestionsAdded: questions.remoteAdded, @@ -370,11 +396,13 @@ function syncResultFromBackup( recordCount: backup.studyRecords.length, questionCount: backup.customQuestions.length, noteCount: backup.questionNotes.length, + answerOverrideCount: backup.questionAnswerOverrides.length, questionFlagCount: backup.questionFlags.filter((flag) => flag.starred).length, aiSessionCount: backup.aiSessions.length, aiSessions: backup.aiSessions, mergedRemoteRecordCount: stats?.remoteRecordsApplied, mergedRemoteNoteCount: stats?.remoteNotesApplied, + mergedRemoteAnswerOverrideCount: stats?.remoteAnswerOverridesApplied, mergedRemoteQuestionFlagCount: stats?.remoteFlagsApplied, mergedRemoteAISessionCount: stats?.remoteAISessionsApplied, mergedRemoteQuestionCount: stats?.remoteQuestionsAdded, @@ -385,7 +413,28 @@ function syncResultFromBackup( // ─── Payload types ──────────────────────────────────────────────────────────── -/** Shape written to / read from Gist (v6) */ +/** Shape written to / read from Gist (v7) */ +interface GistPayloadV7 { + version: 7 + exportedAt: string + /** Compact columnar study records */ + records: CompactRecords + /** User-authored notes for built-in and custom questions */ + questionNotes: QuestionNote[] + /** Custom reference answers for built-in and custom questions */ + questionAnswerOverrides: QuestionAnswerOverride[] + /** Per-question flags such as starred/重点题 */ + questionFlags: QuestionFlag[] + /** AI chat history, without API keys or provider config */ + aiSessions: AISession[] + /** User-imported questions only (no built-in question text) */ + customQuestions: Question[] + /** Non-builtin categories only */ + customCategories: CategoryMap + customSources: string[] +} + +/** Legacy v6 shape */ interface GistPayloadV6 { version: 6 exportedAt: string @@ -470,6 +519,7 @@ export interface GistBackup { exportedAt: string studyRecords: StudyRecord[] questionNotes: QuestionNote[] + questionAnswerOverrides: QuestionAnswerOverride[] questionFlags: QuestionFlag[] aiSessions: AISession[] customQuestions: Question[] @@ -485,11 +535,13 @@ export interface SyncResult { recordCount?: number questionCount?: number noteCount?: number + answerOverrideCount?: number questionFlagCount?: number aiSessionCount?: number aiSessions?: AISession[] mergedRemoteRecordCount?: number mergedRemoteNoteCount?: number + mergedRemoteAnswerOverrideCount?: number mergedRemoteQuestionFlagCount?: number mergedRemoteAISessionCount?: number mergedRemoteQuestionCount?: number @@ -500,6 +552,7 @@ export interface SyncResult { interface SyncMergeStats { remoteRecordsApplied: number remoteNotesApplied: number + remoteAnswerOverridesApplied: number remoteFlagsApplied: number remoteAISessionsApplied: number remoteQuestionsAdded: number @@ -513,13 +566,14 @@ export type { SyncMergeStats } export function buildGistBackupPayload( backup: SyncData, exportedAt = new Date().toISOString(), -): GistPayloadV6 { +): GistPayloadV7 { const data = normalizeSyncData(backup) return { - version: 6, + version: BACKUP_VERSION, exportedAt, records: encodeRecords(data.studyRecords), questionNotes: data.questionNotes, + questionAnswerOverrides: data.questionAnswerOverrides, questionFlags: data.questionFlags, aiSessions: data.aiSessions, customQuestions: data.customQuestions, @@ -693,7 +747,7 @@ async function fetchTruncatedContent( // ─── Payload parser / normaliser ────────────────────────────────────────────── /** - * Parse raw JSON text → GistBackup, handling v1/v2/v3/v4/v5/v6. + * Parse raw JSON text → GistBackup, handling v1 through the current version. * Throws on invalid JSON, unsupported version, or missing required fields. */ function parsePayload(raw: string): GistBackup { @@ -720,9 +774,14 @@ function parsePayload(raw: string): GistBackup { ) } - // ── v3 / v4 / v5 / v6 ── - if (v === 3 || v === 4 || v === 5 || v === 6) { - const p = data as unknown as GistPayloadV3 | GistPayloadV4 | GistPayloadV5 | GistPayloadV6 + // ── v3 / v4 / v5 / v6 / v7 ── + if (v === 3 || v === 4 || v === 5 || v === 6 || v === 7) { + const p = data as unknown as + | GistPayloadV3 + | GistPayloadV4 + | GistPayloadV5 + | GistPayloadV6 + | GistPayloadV7 const compact = p.records const studyRecords = compact && Array.isArray(compact.ids) && compact.ids.length > 0 ? decodeRecords(compact) : [] @@ -734,17 +793,22 @@ function parsePayload(raw: string): GistBackup { questionNotes: v === 4 && Array.isArray((p as GistPayloadV4).questionNotes) ? (p as GistPayloadV4).questionNotes - : (v === 5 || v === 6) && - Array.isArray((p as GistPayloadV5 | GistPayloadV6).questionNotes) - ? (p as GistPayloadV5 | GistPayloadV6).questionNotes + : (v === 5 || v === 6 || v === 7) && + Array.isArray((p as GistPayloadV5 | GistPayloadV6 | GistPayloadV7).questionNotes) + ? (p as GistPayloadV5 | GistPayloadV6 | GistPayloadV7).questionNotes : [], + questionAnswerOverrides: + v === 7 && Array.isArray((p as GistPayloadV7).questionAnswerOverrides) + ? (p as GistPayloadV7).questionAnswerOverrides + : [], questionFlags: - v === 6 && Array.isArray((p as GistPayloadV6).questionFlags) - ? (p as GistPayloadV6).questionFlags + (v === 6 || v === 7) && Array.isArray((p as GistPayloadV6 | GistPayloadV7).questionFlags) + ? (p as GistPayloadV6 | GistPayloadV7).questionFlags : [], aiSessions: - (v === 5 || v === 6) && Array.isArray((p as GistPayloadV5 | GistPayloadV6).aiSessions) - ? (p as GistPayloadV5 | GistPayloadV6).aiSessions + (v === 5 || v === 6 || v === 7) && + Array.isArray((p as GistPayloadV5 | GistPayloadV6 | GistPayloadV7).aiSessions) + ? (p as GistPayloadV5 | GistPayloadV6 | GistPayloadV7).aiSessions : [], customQuestions: Array.isArray(p.customQuestions) ? p.customQuestions : [], customCategories: @@ -780,6 +844,7 @@ function parsePayload(raw: string): GistBackup { exportedAt: typeof p.exportedAt === 'string' ? p.exportedAt : new Date().toISOString(), studyRecords, questionNotes: [], + questionAnswerOverrides: [], questionFlags: [], aiSessions: [], customQuestions, @@ -906,6 +971,7 @@ export async function deleteBackupGist(token: string): Promise { * Only uploads: * • Study records for ALL questions (built-in and custom) * • Question notes for ALL questions (user-authored content) + * • Custom answer overrides for ALL questions * • AI sessions for ALL questions (conversation content only) * • Custom (user-imported) question objects * • Custom (user-created) categories @@ -916,15 +982,23 @@ export async function deleteBackupGist(token: string): Promise { */ export async function pushToGist(token: string, aiSessions: AISession[] = []): Promise { try { - const [studyRecords, questionNotes, questionFlags, allQuestions, customSources, categoryMap] = - await Promise.all([ - getAllStudyRecords(), - getAllQuestionNotes(), - getAllQuestionFlags(), - getAllQuestions(), - getCustomSources(), - getCategoryMap(), - ]) + const [ + studyRecords, + questionNotes, + questionAnswerOverrides, + questionFlags, + allQuestions, + customSources, + categoryMap, + ] = await Promise.all([ + getAllStudyRecords(), + getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), + getAllQuestionFlags(), + getAllQuestions(), + getCustomSources(), + getCategoryMap(), + ]) // Only back up user-imported questions (id starts with "custom_") const customQuestions = allQuestions.filter( @@ -940,6 +1014,7 @@ export async function pushToGist(token: string, aiSessions: AISession[] = []): P const localBackup: SyncData = { studyRecords, questionNotes, + questionAnswerOverrides, questionFlags, aiSessions, customQuestions, @@ -958,6 +1033,7 @@ export async function pushToGist(token: string, aiSessions: AISession[] = []): P await Promise.all([ bulkPutStudyRecords(merged.backup.studyRecords), bulkPutQuestionNotes(merged.backup.questionNotes), + bulkPutQuestionAnswerOverrides(merged.backup.questionAnswerOverrides), bulkPutQuestionFlags(merged.backup.questionFlags), merged.backup.customQuestions.length > 0 ? bulkPutQuestions(merged.backup.customQuestions) @@ -982,6 +1058,7 @@ export async function pushToGist(token: string, aiSessions: AISession[] = []): P * Merge strategy: * studyRecords — merge by lastUpdated (newer wins) * questionNotes — merge by updatedAt (newer wins) + * questionAnswerOverrides — merge by updatedAt (newer wins) * aiSessions — merge by updatedAt (newer wins) * customQuestions — union by id (never delete existing ones) * customSources — union @@ -1000,15 +1077,23 @@ export async function pullFromGist( const ops: Promise[] = [] - const [localRecords, localNotes, localFlags, allQuestions, localSources, currentMap] = - await Promise.all([ - getAllStudyRecords(), - getAllQuestionNotes(), - getAllQuestionFlags(), - getAllQuestions(), - getCustomSources(), - getCategoryMap(), - ]) + const [ + localRecords, + localNotes, + localAnswerOverrides, + localFlags, + allQuestions, + localSources, + currentMap, + ] = await Promise.all([ + getAllStudyRecords(), + getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), + getAllQuestionFlags(), + getAllQuestions(), + getCustomSources(), + getCategoryMap(), + ]) const localCustomQuestions = allQuestions.filter( (q) => typeof q.id === 'string' && q.id.startsWith('custom_'), @@ -1022,6 +1107,7 @@ export async function pullFromGist( { studyRecords: localRecords, questionNotes: localNotes, + questionAnswerOverrides: localAnswerOverrides, questionFlags: localFlags, aiSessions: localAISessions, customQuestions: localCustomQuestions, @@ -1033,6 +1119,7 @@ export async function pullFromGist( ops.push(bulkPutStudyRecords(merged.backup.studyRecords)) ops.push(bulkPutQuestionNotes(merged.backup.questionNotes)) + ops.push(bulkPutQuestionAnswerOverrides(merged.backup.questionAnswerOverrides)) ops.push(bulkPutQuestionFlags(merged.backup.questionFlags)) if (merged.backup.customQuestions.length > 0) { diff --git a/src/lib/localBackup.ts b/src/lib/localBackup.ts index ba88a07..e80ff58 100644 --- a/src/lib/localBackup.ts +++ b/src/lib/localBackup.ts @@ -3,6 +3,7 @@ import type { JdMatchReport, MockInterviewSession, Question, + QuestionAnswerOverride, QuestionFlag, QuestionNote, StudyRecord, @@ -17,6 +18,7 @@ export interface ImportPreview { questions: Question[] studyRecords: StudyRecord[] questionNotes: QuestionNote[] + questionAnswerOverrides: QuestionAnswerOverride[] questionFlags: QuestionFlag[] aiSessions: AISession[] mockInterviews: MockInterviewSession[] @@ -30,6 +32,7 @@ export interface ImportImpact { questions: ImportImpactItem studyRecords: ImportImpactItem questionNotes: ImportImpactItem + questionAnswerOverrides: ImportImpactItem questionFlags: ImportImpactItem aiSessions: ImportImpactItem mockInterviews: ImportImpactItem @@ -83,6 +86,16 @@ function isQuestionNote(value: unknown): value is QuestionNote { ) } +function isQuestionAnswerOverride(value: unknown): value is QuestionAnswerOverride { + return ( + isRecord(value) && + typeof value.questionId === 'string' && + typeof value.content === 'string' && + typeof value.createdAt === 'number' && + typeof value.updatedAt === 'number' + ) +} + function isQuestionFlag(value: unknown): value is QuestionFlag { return ( isRecord(value) && @@ -216,6 +229,7 @@ function parseImportArray( | 'questions' | 'studyRecords' | 'questionNotes' + | 'questionAnswerOverrides' | 'questionFlags' | 'aiSessions' | 'mockInterviews' @@ -328,6 +342,12 @@ export function parseImportPreview(fileName: string, rawText: string): ImportPre const questions = parseImportArray(parsed, 'questions', isQuestion, '题目') const studyRecords = parseImportArray(parsed, 'studyRecords', isStudyRecord, '学习记录') const questionNotes = parseImportArray(parsed, 'questionNotes', isQuestionNote, '题目笔记') + const questionAnswerOverrides = parseImportArray( + parsed, + 'questionAnswerOverrides', + isQuestionAnswerOverride, + '自定义答案', + ) const questionFlags = parseImportArray(parsed, 'questionFlags', isQuestionFlag, '题目标记') const aiSessions = parseImportArray(parsed, 'aiSessions', isAISession, 'AI 会话') const mockInterviews = parseImportArray( @@ -352,6 +372,7 @@ export function parseImportPreview(fileName: string, rawText: string): ImportPre questions.length + studyRecords.length + questionNotes.length + + questionAnswerOverrides.length + questionFlags.length + aiSessions.length + mockInterviews.length + @@ -368,6 +389,7 @@ export function parseImportPreview(fileName: string, rawText: string): ImportPre questions, studyRecords, questionNotes, + questionAnswerOverrides, questionFlags, aiSessions, mockInterviews, @@ -378,6 +400,7 @@ export function parseImportPreview(fileName: string, rawText: string): ImportPre questions: { created: 0, overwritten: 0 }, studyRecords: { created: 0, overwritten: 0 }, questionNotes: { created: 0, overwritten: 0 }, + questionAnswerOverrides: { created: 0, overwritten: 0 }, questionFlags: { created: 0, overwritten: 0 }, aiSessions: { created: 0, overwritten: 0 }, mockInterviews: { created: 0, overwritten: 0 }, diff --git a/src/lib/questionLoader.ts b/src/lib/questionLoader.ts index 86355d3..68ba1d8 100644 --- a/src/lib/questionLoader.ts +++ b/src/lib/questionLoader.ts @@ -2,6 +2,7 @@ import { normalizeQuestionsForImport, validateQuestions } from '../data/schema' import type { Question } from '../types' import { addCustomSource, + bulkPutQuestionAnswerOverrides, bulkPutQuestionFlags, bulkPutQuestionNotes, bulkPutQuestions, @@ -9,6 +10,7 @@ import { type CategoryMap, DEFAULT_CATEGORY_MAP, deleteQuestionById, + getAllQuestionAnswerOverrides, getAllQuestionFlags, getAllQuestionNotes, getAllQuestions, @@ -215,6 +217,7 @@ interface BuiltinReplacementMigrationResult { migratedQuestions: number migratedRecords: number migratedNotes: number + migratedAnswerOverrides: number migratedFlags: number removedSources: number removedCategories: number @@ -224,6 +227,7 @@ const emptyMigrationResult: BuiltinReplacementMigrationResult = { migratedQuestions: 0, migratedRecords: 0, migratedNotes: 0, + migratedAnswerOverrides: 0, migratedFlags: 0, removedSources: 0, removedCategories: 0, @@ -296,6 +300,15 @@ function mergeQuestionNote( } } +function mergeQuestionAnswerOverride( + from: Awaited>[number], + to: Awaited>[number] | undefined, + questionId: string, +) { + if (!to) return { ...from, questionId } + return from.updatedAt > to.updatedAt ? { ...from, questionId } : to +} + function mergeQuestionFlag( from: Awaited>[number], to: Awaited>[number] | undefined, @@ -387,17 +400,22 @@ export async function migrateBuiltinQuestionReplacements(): Promise 0) { - const [records, notes, flags] = await Promise.all([ + const [records, notes, answerOverrides, flags] = await Promise.all([ getAllStudyRecords(), getAllQuestionNotes(), + getAllQuestionAnswerOverrides(), getAllQuestionFlags(), ]) const recordsById = new Map(records.map((record) => [record.questionId, record])) const notesById = new Map(notes.map((note) => [note.questionId, note])) + const answerOverridesById = new Map( + answerOverrides.map((override) => [override.questionId, override]), + ) const flagsById = new Map(flags.map((flag) => [flag.questionId, flag])) const nextRecords = [] const nextNotes = [] + const nextAnswerOverrides = [] const nextFlags = [] for (const [fromId, toId] of replacements) { @@ -411,6 +429,13 @@ export async function migrateBuiltinQuestionReplacements(): Promise 0 ? bulkPutStudyRecords(nextRecords) : Promise.resolve(), nextNotes.length > 0 ? bulkPutQuestionNotes(nextNotes) : Promise.resolve(), + nextAnswerOverrides.length > 0 + ? bulkPutQuestionAnswerOverrides(nextAnswerOverrides) + : Promise.resolve(), nextFlags.length > 0 ? bulkPutQuestionFlags(nextFlags) : Promise.resolve(), ]) result.migratedRecords = nextRecords.length result.migratedNotes = nextNotes.length + result.migratedAnswerOverrides = nextAnswerOverrides.length result.migratedFlags = nextFlags.length for (const customId of replacements.keys()) { diff --git a/src/pages/QuestionDetail.tsx b/src/pages/QuestionDetail.tsx index 8d0ab45..4c0c209 100644 --- a/src/pages/QuestionDetail.tsx +++ b/src/pages/QuestionDetail.tsx @@ -9,9 +9,15 @@ import { useQuestion, useQuestions } from '@/hooks/useQuestions' import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' import { appendQuestionNoteContent, + deleteQuestionAnswerOverride, + deleteUnusedQuestionNoteImages, + getQuestionAnswerOverride, getQuestionFlag, getQuestionNote, + getQuestionNoteImages, + putQuestionAnswerOverride, putQuestionNote, + putQuestionNoteImage, setQuestionStarred, } from '@/lib/db' import { buildReviewNoteMarkdown, formatReviewNoteTime } from '@/lib/feedbackNote' @@ -26,7 +32,9 @@ import { DIFFICULTY_LABELS, DIFFICULTY_STYLES, type Question, + type QuestionAnswerOverride, type QuestionNote, + type QuestionNoteImage, STATUS_LABELS, STATUS_STYLES, type StudyStatus, @@ -1623,13 +1631,58 @@ interface QuestionNotesProps { type NoteSaveStatus = 'idle' | 'saving' | 'saved' | 'error' -const NOTE_TEMPLATES = [ - { id: 'understanding', label: '理解', text: '## 我的理解\n- ' }, - { id: 'pitfall', label: '易错', text: '## 易错点\n- ' }, - { id: 'speech', label: '口述', text: '## 面试口述\n- ' }, - { id: 'followup', label: '追问', text: '## 追问\n- ' }, +const NOTE_IMAGE_SRC_PREFIX = 'iface-note-image:' +const MAX_NOTE_IMAGE_SIZE_BYTES = 5 * 1024 * 1024 + +const NOTE_FORMAT_ACTIONS = [ + { id: 'bold', label: 'B', title: '加粗' }, + { id: 'heading', label: 'H2', title: '二级标题' }, + { id: 'bullet', label: '- ', title: '无序列表' }, + { id: 'todo', label: '[ ]', title: '待办项' }, + { id: 'quote', label: '>', title: '引用' }, + { id: 'code', label: '', title: '代码块' }, + { id: 'link', label: 'link', title: '链接' }, + { id: 'table', label: 'table', title: '表格' }, ] as const +type NoteFormatActionId = (typeof NOTE_FORMAT_ACTIONS)[number]['id'] + +function createLocalNoteImageId() { + const cryptoId = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + return `note-image-${Date.now()}-${cryptoId}` +} + +function sanitizeImageAlt(name: string) { + const baseName = name.replace(/\.[^.]+$/, '').trim() + return (baseName || '本地图片').replace(/[[\]\n\r]/g, ' ') +} + +function extractNoteImageIds(content: string): string[] { + const ids = new Set() + const regex = /!\[[^\]]*]\(iface-note-image:([^)]+)\)/g + let match = regex.exec(content) + while (match) { + if (match[1]) ids.add(match[1]) + match = regex.exec(content) + } + return Array.from(ids) +} + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + if (typeof reader.result === 'string') resolve(reader.result) + else reject(new Error('invalid image data')) + } + reader.onerror = () => reject(reader.error ?? new Error('failed to read image')) + reader.readAsDataURL(file) + }) +} + function buildNoteInsertion( content: string, insertText: string, @@ -1648,6 +1701,131 @@ function buildNoteInsertion( } } +function getSelectedLineRange(content: string, start: number, end: number) { + const lineStart = content.lastIndexOf('\n', Math.max(0, start - 1)) + 1 + const nextNewline = content.indexOf('\n', end) + const lineEnd = nextNewline === -1 ? content.length : nextNewline + return { lineStart, lineEnd } +} + +function prefixSelectedLines( + content: string, + start: number, + end: number, + prefix: string, +): { nextContent: string; nextCursor: number } { + const { lineStart, lineEnd } = getSelectedLineRange(content, start, end) + const block = content.slice(lineStart, lineEnd) + const nextBlock = block + .split('\n') + .map((line) => + line.trim() + ? `${prefix}${line.replace(/^(- \[ \] |- |\* |\d+\. |> )/, '')}` + : prefix.trimEnd(), + ) + .join('\n') + + return { + nextContent: `${content.slice(0, lineStart)}${nextBlock}${content.slice(lineEnd)}`, + nextCursor: lineStart + nextBlock.length, + } +} + +function wrapSelection( + content: string, + start: number, + end: number, + before: string, + after: string, + fallback: string, +): { nextContent: string; nextCursor: number } { + const selected = content.slice(start, end) + const value = selected || fallback + const insertion = `${before}${value}${after}` + + return { + nextContent: `${content.slice(0, start)}${insertion}${content.slice(end)}`, + nextCursor: selected ? start + insertion.length : start + before.length + value.length, + } +} + +function applyNoteFormat( + content: string, + actionId: NoteFormatActionId, + start: number, + end: number, +): { nextContent: string; nextCursor: number } { + switch (actionId) { + case 'bold': + return wrapSelection(content, start, end, '**', '**', '重点') + case 'heading': + return prefixSelectedLines(content, start, end, '## ') + case 'bullet': + return prefixSelectedLines(content, start, end, '- ') + case 'todo': + return prefixSelectedLines(content, start, end, '- [ ] ') + case 'quote': + return prefixSelectedLines(content, start, end, '> ') + case 'code': { + const selected = content.slice(start, end).trim() + return buildNoteInsertion(content, `\`\`\`ts\n${selected || '代码'}\n\`\`\``, start, end) + } + case 'link': + return wrapSelection(content, start, end, '[', '](https://)', '链接文字') + case 'table': + return buildNoteInsertion(content, '| 项目 | 说明 |\n| --- | --- |\n| | |', start, end) + } +} + +function NoteToolbarButton({ + label, + title, + onClick, + disabled, +}: { + label: string + title: string + onClick: () => void + disabled?: boolean +}) { + return ( + + ) +} + function QuestionNotes({ questionId, refreshKey, @@ -1658,15 +1836,19 @@ function QuestionNotes({ const [content, setContent] = useState('') const [createdAt, setCreatedAt] = useState(null) const [updatedAt, setUpdatedAt] = useState(null) + const [noteImages, setNoteImages] = useState>({}) const [loading, setLoading] = useState(true) const [saveStatus, setSaveStatus] = useState('idle') - const [mode, setMode] = useState<'edit' | 'preview'>('edit') + const [mode, setMode] = useState<'edit' | 'preview'>('preview') + const [editorFocused, setEditorFocused] = useState(false) const [speechError, setSpeechError] = useState(null) + const [imageImportError, setImageImportError] = useState(null) const loadedContentRef = useRef('') const saveTimerRef = useRef(null) const statusTimerRef = useRef(null) const textareaRef = useRef(null) + const imageInputRef = useRef(null) const focusEditor = useCallback(() => { window.setTimeout(() => textareaRef.current?.focus(), 0) @@ -1693,15 +1875,17 @@ function QuestionNotes({ setLoading(true) setSaveStatus('idle') setSpeechError(null) + setImageImportError(null) - getQuestionNote(questionId) - .then((note: QuestionNote | undefined) => { + Promise.all([getQuestionNote(questionId), getQuestionNoteImages(questionId)]) + .then(([note, images]: [QuestionNote | undefined, QuestionNoteImage[]]) => { if (cancelled) return const nextContent = note?.content ?? '' loadedContentRef.current = nextContent setContent(nextContent) setCreatedAt(note?.createdAt ?? null) setUpdatedAt(note?.updatedAt ?? null) + setNoteImages(Object.fromEntries(images.map((image) => [image.id, image]))) onContentStateChange?.(nextContent.trim().length > 0) setLoading(false) }) @@ -1711,6 +1895,7 @@ function QuestionNotes({ setContent('') setCreatedAt(null) setUpdatedAt(null) + setNoteImages({}) onContentStateChange?.(false) setSaveStatus('error') setLoading(false) @@ -1752,14 +1937,20 @@ function QuestionNotes({ createdAt: createdAt ?? now, updatedAt: now, }) + const keepImageIds = extractNoteImageIds(nextContent) + await deleteUnusedQuestionNoteImages(questionId, keepImageIds) loadedContentRef.current = nextContent if (nextContent.trim()) { setCreatedAt((prev) => prev ?? now) setUpdatedAt(now) + setNoteImages((prev) => + Object.fromEntries(keepImageIds.flatMap((id) => (prev[id] ? [[id, prev[id]]] : []))), + ) } else { setCreatedAt(null) setUpdatedAt(null) + setNoteImages({}) } onContentStateChange?.(nextContent.trim().length > 0) setSaveStatus('saved') @@ -1782,12 +1973,12 @@ function QuestionNotes({ [focusEditor], ) - const handleInsertTemplate = useCallback( - (insertText: string) => { + const handleApplyFormat = useCallback( + (actionId: NoteFormatActionId) => { const editor = textareaRef.current const start = editor?.selectionStart ?? content.length const end = editor?.selectionEnd ?? content.length - const { nextContent, nextCursor } = buildNoteInsertion(content, insertText, start, end) + const { nextContent, nextCursor } = applyNoteFormat(content, actionId, start, end) setMode('edit') setContent(nextContent) @@ -1799,6 +1990,81 @@ function QuestionNotes({ [content], ) + const handlePickImage = useCallback(() => { + setMode('edit') + setImageImportError(null) + imageInputRef.current?.click() + }, []) + + const handleImageFileChange = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + + if (!file.type.startsWith('image/')) { + setImageImportError('请选择图片文件') + return + } + if (file.size > MAX_NOTE_IMAGE_SIZE_BYTES) { + setImageImportError('图片不能超过 5MB') + return + } + + try { + setImageImportError(null) + const dataUrl = await readFileAsDataUrl(file) + const now = Date.now() + const image: QuestionNoteImage = { + id: createLocalNoteImageId(), + questionId, + name: file.name || 'local-image', + mimeType: file.type || 'image/*', + size: file.size, + dataUrl, + createdAt: now, + updatedAt: now, + } + const saved = await putQuestionNoteImage(image) + const editor = textareaRef.current + const start = editor?.selectionStart ?? content.length + const end = editor?.selectionEnd ?? content.length + const markdown = `![${sanitizeImageAlt(file.name)}](${NOTE_IMAGE_SRC_PREFIX}${saved.id})` + const { nextContent, nextCursor } = buildNoteInsertion(content, markdown, start, end) + + setNoteImages((prev) => ({ ...prev, [saved.id]: saved })) + setMode('edit') + setContent(nextContent) + window.setTimeout(() => { + textareaRef.current?.focus() + textareaRef.current?.setSelectionRange(nextCursor, nextCursor) + }, 0) + } catch { + setImageImportError('图片导入失败') + } + }, + [content, questionId], + ) + + const handleNoteKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') { + event.preventDefault() + handleApplyFormat('bold') + } + }, + [handleApplyFormat], + ) + + const resolveNoteImageSrc = useCallback( + (src: string) => { + if (!src.startsWith(NOTE_IMAGE_SRC_PREFIX)) return undefined + const id = src.slice(NOTE_IMAGE_SRC_PREFIX.length) + return noteImages[id]?.dataUrl + }, + [noteImages], + ) + const noteLength = content.trim().length const statusText = @@ -1820,7 +2086,10 @@ function QuestionNotes({ display: 'flex', flexDirection: 'column', gap: 10, + height: embedded ? '100%' : undefined, minHeight: embedded ? '100%' : undefined, + minWidth: 0, + overflow: embedded ? (mode === 'preview' ? 'hidden' : 'auto') : undefined, }} >
-
handleModeChange(mode === 'edit' ? 'preview' : 'edit')} + disabled={loading} style={{ - display: 'flex', + display: 'inline-flex', alignItems: 'center', - gap: 2, - padding: 2, + gap: 5, + padding: '5px 10px', borderRadius: 8, - background: 'var(--surface-2)', border: '1px solid var(--border-subtle)', + background: mode === 'edit' ? 'var(--primary)' : 'var(--surface-2)', + color: mode === 'edit' ? 'white' : 'var(--text-2)', + fontSize: 12, + fontWeight: 500, + cursor: loading ? 'default' : 'pointer', + opacity: loading ? 0.55 : 1, flexShrink: 0, }} + title={mode === 'edit' ? '完成编辑' : '编辑题目笔记'} > - {(['edit', 'preview'] as const).map((item) => { - const active = mode === item - return ( - - ) - })} -
+ + {mode === 'edit' ? ( + + ) : ( + <> + + + + )} + + {mode === 'edit' ? '完成' : '编辑'} +
- {mode === 'edit' && ( -
-
- {NOTE_TEMPLATES.map((template) => ( - - ))} -
-
- {(speech.interimTranscript || speechError) && ( - - {speech.interimTranscript ? `正在识别:${speech.interimTranscript}` : speechError} - - )} - +
setEditorFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setEditorFocused(false) + } + }} + style={{ + width: '100%', + overflow: 'hidden', + borderRadius: 10, + border: `1px solid ${editorFocused ? 'var(--primary)' : 'var(--border-subtle)'}`, + background: 'var(--surface-2)', + boxShadow: editorFocused ? '0 0 0 3px var(--primary-light)' : 'none', + transition: 'border-color 0.15s, box-shadow 0.15s', + }} + > + +
+
+ {NOTE_FORMAT_ACTIONS.map((action) => ( + handleApplyFormat(action.id)} + /> + ))} + +
+
+ {(speech.interimTranscript || speechError || imageImportError) && ( + + {speech.interimTranscript + ? `正在识别:${speech.interimTranscript}` + : speechError || imageImportError} + + )} + +
+
+