diff --git a/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts b/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts
index 29a2bf3f..f8a155a3 100644
--- a/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts
+++ b/apps/readest-app/src/__tests__/components/ReviewModeController.test.ts
@@ -1,4 +1,8 @@
-import { describe, expect, test } from 'vitest';
+import { describe, expect, test, vi } from 'vitest';
+
+vi.mock('@/context/EnvContext', () => ({
+ useEnv: () => ({ appService: null }),
+}));
import { __testing } from '@/app/reader/components/ReviewModeController';
import type { BookDoc } from '@/libs/document';
@@ -144,4 +148,29 @@ describe('ReviewModeController marks', () => {
expect(target.classList.contains('readest-review-active')).toBe(false);
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
});
+
+ test('clears the target paragraph when a saved translation is empty', () => {
+ const doc = document.implementation.createHTMLDocument('chapter');
+ doc.body.innerHTML = '
原文
旧译文
';
+
+ __testing.applyReviewMarks(
+ doc,
+ 'chapter.xhtml',
+ [
+ {
+ id: 'R00001',
+ file: 'chapter.xhtml',
+ ja_p_index: 0,
+ cn_p_index: 1,
+ jp_html: '原文',
+ jp_text: '原文',
+ cn_html: '旧译文',
+ current_html: '',
+ },
+ ],
+ 'R00001',
+ );
+
+ expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
+ });
});
diff --git a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts
index b7b06234..5ee74670 100644
--- a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts
+++ b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts
@@ -1,9 +1,14 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
+import type { ReviewRow } from '@/services/reviewEditorService';
import {
+ canLaunchInlineReviewEditor,
createReviewSessionFromPath,
+ downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
+ isReviewEditDirty,
+ launchInlineReviewEditor,
loadInlineReviewData,
loadReviewGlossary,
saveReviewGptConfig,
@@ -38,6 +43,133 @@ const firstFetchUrl = (fetchMock: ReturnType) => {
};
describe('reviewEditorService', () => {
+ test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
+ expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
+ expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
+ expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
+ });
+
+ test('rejects a launch response when the Next route returns an HTTP error', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () =>
+ Response.json(
+ { ok: false, error: '本地 Web 审校入口未启用' },
+ { status: 404 },
+ ),
+ ),
+ );
+
+ await expect(
+ launchInlineReviewEditor(
+ {
+ resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
+ } as never,
+ { format: 'EPUB' } as never,
+ false,
+ ),
+ ).rejects.toThrow('本地 Web 审校入口未启用');
+ });
+
+ test('uploads the current web book to the local sidecar before creating a session', async () => {
+ const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
+ const url = input.toString();
+ if (url === '/api/review-editor/launch') {
+ return Response.json({ ok: true, url: 'http://127.0.0.1:5177' });
+ }
+ return Response.json({ id: 'session-web' });
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const launched = await launchInlineReviewEditor(
+ {
+ resolveNativeBookFilePath: async () => null,
+ loadBookContent: async () => ({
+ file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }),
+ }),
+ } as never,
+ { format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never,
+ false,
+ );
+
+ expect(launched.sessionId).toBe('session-web');
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const uploadCall = fetchMock.mock.calls[1] as
+ | [string | URL | Request, RequestInit | undefined]
+ | undefined;
+ expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
+ expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
+ expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
+ expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
+ });
+
+ test('downloads an exported EPUB in web mode with its server filename', async () => {
+ const click = vi.fn();
+ const remove = vi.fn();
+ const anchor = { click, remove, href: '', download: '' };
+ vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
+ vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
+
+ await downloadReviewedEpub(
+ 'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
+ );
+
+ expect(anchor.href).toContain('/downloads/export/reviewed.epub');
+ expect(anchor.download).toBe('reviewed.epub');
+ expect(click).toHaveBeenCalledOnce();
+ expect(remove).toHaveBeenCalledOnce();
+ });
+
+ test('detects unsaved row fields against the selected row baseline', () => {
+ const row = {
+ current_html: '译文',
+ cn_html: '译文',
+ marked: false,
+ issue_type: '',
+ severity: '',
+ tags: '',
+ comment: '',
+ learn_note: '',
+ } as ReviewRow;
+
+ expect(
+ isReviewEditDirty(row, {
+ current_html: '译文',
+ marked: false,
+ issue_type: '',
+ severity: '',
+ tags: '',
+ comment: '',
+ learn_note: '',
+ }),
+ ).toBe(false);
+ expect(
+ isReviewEditDirty(row, {
+ current_html: '修改后译文',
+ marked: false,
+ issue_type: '',
+ severity: '',
+ tags: '',
+ comment: '',
+ learn_note: '',
+ }),
+ ).toBe(true);
+ expect(
+ isReviewEditDirty(
+ { ...row, current_html: '' },
+ {
+ current_html: '',
+ marked: false,
+ issue_type: '',
+ severity: '',
+ tags: '',
+ comment: '',
+ learn_note: '',
+ },
+ ),
+ ).toBe(false);
+ });
+
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
const fetchMock = stubJsonFetch();
diff --git a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
index 8ade035d..bd6a22ad 100644
--- a/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
+++ b/apps/readest-app/src/__tests__/store/review-mode-store.test.ts
@@ -48,6 +48,19 @@ describe('reviewModeStore', () => {
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
});
+ test('reopens the panel for the selected row without clearing its unsaved flag', () => {
+ useReviewModeStore.getState().setBookEnabled('book-a', true);
+ useReviewModeStore.getState().selectRow('book-a', 'R00001');
+ useReviewModeStore.getState().setBookUnsavedEdit('book-a', true);
+ useReviewModeStore.getState().setPanelVisible(false);
+
+ useReviewModeStore.getState().selectRow('book-a', 'R00001');
+
+ const state = useReviewModeStore.getState();
+ expect(state.isPanelVisible).toBe(true);
+ expect(state.books['book-a']?.hasUnsavedEdit).toBe(true);
+ });
+
test('updates one review row after save', () => {
useReviewModeStore.getState().setBookData('book-a', {
rows: [
diff --git a/apps/readest-app/src/app/api/review-editor/launch/route.ts b/apps/readest-app/src/app/api/review-editor/launch/route.ts
index 816b01af..1fcbb906 100644
--- a/apps/readest-app/src/app/api/review-editor/launch/route.ts
+++ b/apps/readest-app/src/app/api/review-editor/launch/route.ts
@@ -128,10 +128,16 @@ const requestJson = (port: number, pathname: string): Promise {
const expectedVersion = readExpectedVersion();
+ const expectedReviewRoot = path.resolve(reviewRoot());
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
const payload = await requestJson(port, '/api/version');
if (!payload) continue;
- if (!expectedVersion || payload['version'] === expectedVersion) {
+ const runningReviewRoot =
+ typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
+ if (
+ (!expectedVersion || payload['version'] === expectedVersion) &&
+ runningReviewRoot === expectedReviewRoot
+ ) {
return `http://127.0.0.1:${port}`;
}
}
diff --git a/apps/readest-app/src/app/reader/components/ReaderContent.tsx b/apps/readest-app/src/app/reader/components/ReaderContent.tsx
index 75044b5a..4b5252a7 100644
--- a/apps/readest-app/src/app/reader/components/ReaderContent.tsx
+++ b/apps/readest-app/src/app/reader/components/ReaderContent.tsx
@@ -212,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateBackToLibrary();
};
- const handleCloseReaderToLibrary = () => {
+ const handleCloseReaderToLibrary = async () => {
+ const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
+ (book) => book.hasUnsavedEdit,
+ );
+ if (
+ dirtyReview &&
+ appService &&
+ !(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
+ ) {
+ return;
+ }
return handleCloseBooks(true);
};
@@ -230,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// SPA navigation in the main window (or on web) keeps the webview alive:
// TTS may continue headless. Non-main Tauri windows close their webview
// below, but their per-window TTS dies with the window either way.
+ const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
+ (book) => book.hasUnsavedEdit,
+ );
+ if (
+ dirtyReview &&
+ appService &&
+ !(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
+ ) {
+ return;
+ }
handleCloseBooks(true);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
@@ -250,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// Header X / pane close: an SPA-side close on web and the main window.
// The Tauri reader-window branches below destroy their webview, which
// takes the per-window TTS with it either way.
+ const review = useReviewModeStore.getState().books[bookKey];
+ if (
+ review?.hasUnsavedEdit &&
+ appService &&
+ !(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
+ ) {
+ return;
+ }
saveConfigAndCloseBook(bookKey, true);
if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey));
diff --git a/apps/readest-app/src/app/reader/components/ReviewModeController.tsx b/apps/readest-app/src/app/reader/components/ReviewModeController.tsx
index 9eea06b6..5d13d5a3 100644
--- a/apps/readest-app/src/app/reader/components/ReviewModeController.tsx
+++ b/apps/readest-app/src/app/reader/components/ReviewModeController.tsx
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react';
+import { useEnv } from '@/context/EnvContext';
import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore';
@@ -147,7 +148,7 @@ const markParagraph = (
htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active);
- if (role === 'target' && row.current_html) {
+ if (role === 'target') {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
@@ -194,6 +195,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey,
bookDoc,
}) => {
+ const { appService } = useEnv();
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
@@ -205,6 +207,24 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]);
+ const selectReviewRow = async (rowId: string) => {
+ const state = useReviewModeStore.getState();
+ const previousBookKey = state.activeBookKey;
+ const current = state.books[previousBookKey || ''];
+ if (
+ current?.hasUnsavedEdit &&
+ (state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
+ appService &&
+ !(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
+ ) {
+ return;
+ }
+ if (previousBookKey && current?.hasUnsavedEdit) {
+ state.setBookUnsavedEdit(previousBookKey, false);
+ }
+ selectRow(bookKey, rowId);
+ };
+
useEffect(() => {
if (!view) return;
@@ -235,7 +255,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
- selectRow(bookKey, rowId);
+ void selectReviewRow(rowId);
};
const handleSelectionEnd = (event: Event) => {
@@ -244,7 +264,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
- if (rowId) selectRow(bookKey, rowId);
+ if (rowId) void selectReviewRow(rowId);
}, 0);
};
@@ -292,7 +312,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
clearReviewMarks(doc);
}
};
- }, [bookDoc, bookKey, enabled, rows, selectRow, view]);
+ }, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
diff --git a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx
index 5dc594fc..7bea2bdc 100644
--- a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx
+++ b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx
@@ -8,7 +8,11 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { eventDispatcher } from '@/utils/event';
-import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
+import {
+ canLaunchInlineReviewEditor,
+ launchInlineReviewEditor,
+ loadInlineReviewData,
+} from '@/services/reviewEditorService';
interface ReviewModeTogglerProps {
bookKey: string;
@@ -32,7 +36,7 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => {
const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading;
- if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
+ if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => {
if (appService?.isMobile) {
@@ -40,6 +44,13 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => {
}
if (enabled) {
+ if (
+ reviewState?.hasUnsavedEdit &&
+ appService &&
+ !(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
+ ) {
+ return;
+ }
setBookEnabled(bookKey, false);
return;
}
@@ -49,6 +60,20 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => {
return;
}
+ const activeState = useReviewModeStore.getState();
+ const activeReview = activeState.books[activeState.activeBookKey || ''];
+ if (
+ activeState.activeBookKey !== bookKey &&
+ activeReview?.hasUnsavedEdit &&
+ appService &&
+ !(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
+ ) {
+ return;
+ }
+ if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
+ activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
+ }
+
setActiveBookKey(bookKey);
setPanelVisible(true);
setBookLoading(bookKey, true);
@@ -91,7 +116,7 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => {
onClick={handleToggleReviewMode}
disabled={loading}
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
- className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
+ className={clsx(enabled && 'bg-base-300/50')}
/>
);
};
diff --git a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
index 107ce81f..d8426f50 100644
--- a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
+++ b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx
@@ -29,8 +29,10 @@ import { openExternalUrl } from '@/utils/open';
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
import {
+ downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
+ isReviewEditDirty,
loadReviewGlossary,
retranslateReviewRow,
saveReviewGlossary,
@@ -488,6 +490,7 @@ const ReviewPanel: React.FC = () => {
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
+ const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) =>
@@ -508,6 +511,7 @@ const ReviewPanel: React.FC = () => {
const [exportInfo, setExportInfo] = useState('');
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
+ const [exporting, setExporting] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({
@@ -527,6 +531,19 @@ const ReviewPanel: React.FC = () => {
[bookState?.rows, bookState?.selectedRowId],
);
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
+ const hasUnsavedEdit = Boolean(selectedRow && isReviewEditDirty(selectedRow, edit));
+
+ useEffect(() => {
+ if (!activeBookKey) return;
+ setBookUnsavedEdit(activeBookKey, hasUnsavedEdit);
+ }, [activeBookKey, hasUnsavedEdit, setBookUnsavedEdit]);
+
+ useEffect(() => {
+ if (!hasUnsavedEdit) return;
+ const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault();
+ window.addEventListener('beforeunload', warnBeforeUnload);
+ return () => window.removeEventListener('beforeunload', warnBeforeUnload);
+ }, [hasUnsavedEdit]);
const bookTitle = activeBookKey
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
@@ -544,7 +561,7 @@ const ReviewPanel: React.FC = () => {
return;
}
setEdit({
- current_html: selectedRow.current_html || selectedRow.cn_html || '',
+ current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '',
marked: Boolean(selectedRow.marked),
issue_type: selectedRow.issue_type || '',
severity: selectedRow.severity || '',
@@ -574,6 +591,17 @@ const ReviewPanel: React.FC = () => {
togglePanelPin();
};
+ const closePanel = async () => {
+ if (
+ hasUnsavedEdit &&
+ appService &&
+ !(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?'))
+ ) {
+ return;
+ }
+ setPanelVisible(false);
+ };
+
const floatingPanelEnabled = !isPanelPinned && !isMobile;
const {
panelPosition,
@@ -666,7 +694,7 @@ const ReviewPanel: React.FC = () => {
setMessage('');
try {
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
- const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html);
+ const savedHtml = payload.current_html ?? sanitizeInlineHtml(edit.current_html);
const nextRow = {
...selectedRow,
...edit,
@@ -725,6 +753,7 @@ const ReviewPanel: React.FC = () => {
const exportEpub = async () => {
if (!baseUrl) return;
+ setExporting(true);
setExportInfo('');
setMessage('');
try {
@@ -733,9 +762,14 @@ const ReviewPanel: React.FC = () => {
? new URL(payload.download_url, baseUrl).toString()
: '';
setExportInfo(downloadUrl || payload.output || '');
+ if (downloadUrl && !appService?.isDesktopApp) {
+ await downloadReviewedEpub(downloadUrl);
+ }
setMessage(`已导出:${payload.output || downloadUrl}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
+ } finally {
+ setExporting(false);
}
};
@@ -806,7 +840,7 @@ const ReviewPanel: React.FC = () => {
{isMobile && (
setPanelVisible(false)}
+ onDismiss={() => void closePanel()}
/>
)}