From e3b4a00ed116893dc86a64e914c4174dacb7f181 Mon Sep 17 00:00:00 2001 From: akai Date: Wed, 8 Jul 2026 21:26:40 +0800 Subject: [PATCH 01/16] feat: migrate epub review editor into readest --- .gitignore | 1 + .../memory/epub-review-editor-migration.md | 25 + apps/readest-app/.gitignore | 3 + apps/readest-app/next.config.mjs | 2 +- apps/readest-app/package.json | 1 + .../readest-app/scripts/epub-reviewer-dev.mjs | 139 + apps/readest-app/src-tauri/tauri.conf.json | 2 +- .../src/__tests__/middleware.test.ts | 1 + .../src/app/api/review-editor/launch/route.ts | 296 ++ .../app/library/components/SettingsMenu.tsx | 10 +- .../review-editor/ReviewEditorLauncher.tsx | 192 + .../src/app/review-editor/page.tsx | 5 + apps/readest-app/src/utils/nav.ts | 4 + .../tools/epub-review-editor/.gitignore | 12 + .../tools/epub-review-editor/MAINTENANCE.md | 281 ++ .../epub-review-editor/READEST_MIGRATION.md | 51 + .../tools/epub-review-editor/README.md | 218 + .../tools/epub-review-editor/open_editor.ps1 | 134 + .../tools/epub-review-editor/requirements.txt | 1 + .../tools/epub-review-editor/server.py | 3979 +++++++++++++++++ .../tools/epub-review-editor/static/app.js | 3214 +++++++++++++ .../epub-review-editor/static/index.html | 514 +++ .../tools/epub-review-editor/static/style.css | 2023 +++++++++ .../tools/epub-review-editor/version.py | 7 + package.json | 1 + 25 files changed, 11113 insertions(+), 3 deletions(-) create mode 100644 apps/readest-app/.claude/memory/epub-review-editor-migration.md create mode 100644 apps/readest-app/scripts/epub-reviewer-dev.mjs create mode 100644 apps/readest-app/src/app/api/review-editor/launch/route.ts create mode 100644 apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx create mode 100644 apps/readest-app/src/app/review-editor/page.tsx create mode 100644 apps/readest-app/tools/epub-review-editor/.gitignore create mode 100644 apps/readest-app/tools/epub-review-editor/MAINTENANCE.md create mode 100644 apps/readest-app/tools/epub-review-editor/READEST_MIGRATION.md create mode 100644 apps/readest-app/tools/epub-review-editor/README.md create mode 100644 apps/readest-app/tools/epub-review-editor/open_editor.ps1 create mode 100644 apps/readest-app/tools/epub-review-editor/requirements.txt create mode 100644 apps/readest-app/tools/epub-review-editor/server.py create mode 100644 apps/readest-app/tools/epub-review-editor/static/app.js create mode 100644 apps/readest-app/tools/epub-review-editor/static/index.html create mode 100644 apps/readest-app/tools/epub-review-editor/static/style.css create mode 100644 apps/readest-app/tools/epub-review-editor/version.py diff --git a/.gitignore b/.gitignore index 658a4d42..36fab600 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ docker/.env npm-debug.log* yarn-debug.log* yarn-error.log* +readest-dev.log # local env files .env*.local diff --git a/apps/readest-app/.claude/memory/epub-review-editor-migration.md b/apps/readest-app/.claude/memory/epub-review-editor-migration.md new file mode 100644 index 00000000..d2199321 --- /dev/null +++ b/apps/readest-app/.claude/memory/epub-review-editor-migration.md @@ -0,0 +1,25 @@ +# EPUB review editor migration + +Date: 2026-07-08 + +This fork includes a first-stage migration of the Chinese/Japanese EPUB review editor into Readest. + +Current shape: +- Bundled sidecar lives at `apps/readest-app/tools/epub-review-editor`. +- Readest route is `/review-editor`. +- Library menu entry is `Settings Menu -> Advanced Settings -> EPUB 审校器`. +- Dev launcher API is `POST /api/review-editor/launch`. +- Dev script is `pnpm epub-reviewer:dev`. +- Default runtime data is `apps/readest-app/epub_review_sessions/`; it is ignored by git. Override with `READEST_REVIEW_ROOT`. +- The automatic launcher is deliberately gated to local `dev-web`; packaged Tauri/static export needs a Rust command or packaged sidecar. + +Important boundary: +- This is not a full native rewrite yet. The migrated tool still uses the proven Flask backend and static UI from the previous long-term translation project. +- Next/Tauri production static export will need a Tauri desktop command or packaged sidecar before this becomes a production desktop feature. +- Do not remove existing review-editor behavior while migrating: bookshelf, upload, bilingual review, glossary editing, GPT retranslate, full-book AI translation, soft delete, duplicate translation layer prevention, ruby preservation. +- If `/review-editor` shows `ERR_BLOCKED_BY_RESPONSE`, check both sides of cross-origin isolation: the Readest route keeps COEP `require-corp`, and the sidecar response should include local `frame-ancestors`, `Cross-Origin-Embedder-Policy: require-corp`, and `Cross-Origin-Resource-Policy: cross-origin`. + +Next recommended steps: +1. Move launching from the Next dev API route into a Tauri desktop command. +2. Add per-book "Open in EPUB Reviewer" actions from Readest bookshelf/detail views. +3. Gradually port the review UI into React after the sidecar entry is stable. diff --git a/apps/readest-app/.gitignore b/apps/readest-app/.gitignore index 3c6ac646..e60b187c 100644 --- a/apps/readest-app/.gitignore +++ b/apps/readest-app/.gitignore @@ -72,6 +72,9 @@ docs/superpowers /public/swe-worker-*.js /dist/ +/epub_review_sessions/ +/tools/epub-review-editor/.venv/ +/tools/epub-review-editor/__pycache__/ .context/ .claude/settings.local.json diff --git a/apps/readest-app/next.config.mjs b/apps/readest-app/next.config.mjs index 7412b1f4..c8e987cb 100644 --- a/apps/readest-app/next.config.mjs +++ b/apps/readest-app/next.config.mjs @@ -49,7 +49,7 @@ const nextConfig = { assetPrefix: '', reactStrictMode: true, serverExternalPackages: ['isows'], - allowedDevOrigins: ['192.168.2.120'], + allowedDevOrigins: ['192.168.2.120', '127.0.0.1', 'localhost'], webpack: (config) => { config.resolve.alias = { ...config.resolve.alias, diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 67248471..bb99808a 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -8,6 +8,7 @@ "build": "dotenv -e .env.tauri -- next build", "start": "dotenv -e .env.tauri -- next start", "dev-web": "dotenv -e .env.web -- next dev", + "epub-reviewer:dev": "node scripts/epub-reviewer-dev.mjs", "build-web": "dotenv -e .env.web -- next build", "start-web": "dotenv -e .env.web -- next start", "dev-web:vinext": "dotenv -e .env.web -- vinext dev", diff --git a/apps/readest-app/scripts/epub-reviewer-dev.mjs b/apps/readest-app/scripts/epub-reviewer-dev.mjs new file mode 100644 index 00000000..a22601dc --- /dev/null +++ b/apps/readest-app/scripts/epub-reviewer-dev.mjs @@ -0,0 +1,139 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.resolve(__dirname, '..'); +const toolRoot = path.join(appRoot, 'tools', 'epub-review-editor'); +const reviewRoot = path.resolve(process.env.READEST_REVIEW_ROOT || path.join(appRoot, 'epub_review_sessions')); +const defaultPort = Number(process.env.READEST_REVIEW_PORT || 5177); +const venvRoot = path.join(toolRoot, '.venv'); +const venvPython = + process.platform === 'win32' + ? path.join(venvRoot, 'Scripts', 'python.exe') + : path.join(venvRoot, 'bin', 'python'); + +const run = (command, args, options = {}) => + spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + timeout: options.timeout ?? 30_000, + windowsHide: true, + }); + +const pythonCandidates = + process.platform === 'win32' + ? [ + ['py', ['-3']], + ['python', []], + ['python3', []], + ] + : [ + ['python3', []], + ['python', []], + ]; + +const findPython = () => { + for (const [command, args] of pythonCandidates) { + const result = run(command, [...args, '--version'], { timeout: 10_000 }); + if (result.status === 0) return { command, args }; + } + throw new Error('Python 3 was not found.'); +}; + +const requestJson = (port, pathname) => + new Promise((resolve) => { + const req = http.get({ host: '127.0.0.1', port, path: pathname, timeout: 800 }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + try { + resolve(res.statusCode === 200 ? JSON.parse(body) : null); + } catch { + resolve(null); + } + }); + }); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + req.on('error', () => resolve(null)); + }); + +const findRunning = async () => { + for (let port = defaultPort; port < defaultPort + 100; port++) { + const payload = await requestJson(port, '/api/version'); + if (payload?.version) return `http://localhost:${port}`; + } + return ''; +}; + +if (!fs.existsSync(path.join(toolRoot, 'server.py'))) { + throw new Error(`Missing review editor server: ${path.join(toolRoot, 'server.py')}`); +} + +const existing = await findRunning(); +if (existing) { + console.log(`EPUB review editor is already running: ${existing}`); + process.exit(0); +} + +if (!fs.existsSync(venvPython)) { + console.log('Creating review editor Python environment...'); + const python = findPython(); + const result = run(python.command, [...python.args, '-m', 'venv', venvRoot], { + cwd: appRoot, + timeout: 120_000, + }); + if (result.status !== 0) { + throw new Error(result.stderr || result.stdout || 'Failed to create virtual environment.'); + } +} + +const flaskCheck = run( + venvPython, + ['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'], + { cwd: toolRoot, timeout: 30_000 }, +); + +if (flaskCheck.status !== 0) { + console.log('Installing review editor dependencies...'); + const install = run(venvPython, ['-m', 'pip', 'install', '-r', path.join(toolRoot, 'requirements.txt')], { + cwd: toolRoot, + timeout: 180_000, + }); + if (install.status !== 0) { + throw new Error(install.stderr || install.stdout || 'Failed to install dependencies.'); + } +} + +fs.mkdirSync(reviewRoot, { recursive: true }); + +const child = spawn( + venvPython, + [ + path.join(toolRoot, 'server.py'), + '--review-root', + reviewRoot, + '--host', + '127.0.0.1', + '--port', + String(defaultPort), + '--no-browser', + ], + { + cwd: toolRoot, + stdio: 'inherit', + windowsHide: true, + }, +); + +child.on('exit', (code) => { + process.exit(code ?? 0); +}); diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index 54718d80..452c7899 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -19,7 +19,7 @@ "img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*", "style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com", "font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com", - "frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com", + "frame-src": "'self' blob: asset: http://asset.localhost http://127.0.0.1:* https://*.stripe.com", "script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com" }, "assetProtocol": { diff --git a/apps/readest-app/src/__tests__/middleware.test.ts b/apps/readest-app/src/__tests__/middleware.test.ts index bd56d9de..4741dd3d 100644 --- a/apps/readest-app/src/__tests__/middleware.test.ts +++ b/apps/readest-app/src/__tests__/middleware.test.ts @@ -18,6 +18,7 @@ describe('middleware cross-origin isolation headers', () => { it('keeps the stricter require-corp on every other document route', () => { expect(coep('/')).toBe('require-corp'); expect(coep('/library')).toBe('require-corp'); + expect(coep('/review-editor')).toBe('require-corp'); // Must not be caught by a naive startsWith('/s'). expect(coep('/settings')).toBe('require-corp'); expect(coep('/search')).toBe('require-corp'); 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 new file mode 100644 index 00000000..b6c37d23 --- /dev/null +++ b/apps/readest-app/src/app/api/review-editor/launch/route.ts @@ -0,0 +1,296 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { NextResponse } from 'next/server'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const DEFAULT_PORT = 5177; +const PORT_SCAN_LIMIT = 100; +const launchState = { + promise: null as Promise | null, +}; + +type CommandResult = { + ok: boolean; + stdout: string; + stderr: string; + status: number | null; + error?: string; +}; + +type LaunchResponse = { + ok: boolean; + url?: string; + reused?: boolean; + reviewRoot?: string; + version?: string; + error?: string; +}; + +type PythonCommand = { + command: string; + args: string[]; +}; + +const appRoot = () => process.cwd(); +const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor'); +const reviewRoot = () => + path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions')); + +const venvRoot = () => path.join(toolRoot(), '.venv'); +const venvPython = () => + process.platform === 'win32' + ? path.join(venvRoot(), 'Scripts', 'python.exe') + : path.join(venvRoot(), 'bin', 'python'); + +const run = ( + command: string, + args: string[], + options: { cwd?: string; timeout?: number } = {}, +): CommandResult => { + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: 'utf8', + timeout: options.timeout ?? 30_000, + windowsHide: true, + }); + return { + ok: result.status === 0 && !result.error, + stdout: result.stdout || '', + stderr: result.stderr || '', + status: result.status, + error: result.error?.message, + }; +}; + +const readExpectedVersion = () => { + try { + const versionText = fs.readFileSync(path.join(toolRoot(), 'version.py'), 'utf8'); + return /version\s*=\s*"([^"]+)"/.exec(versionText)?.[1] || ''; + } catch { + return ''; + } +}; + +const isLaunchEnabled = () => + process.env['NODE_ENV'] === 'development' && + process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web' && + process.env['READEST_REVIEW_EDITOR_LAUNCH'] !== '0'; + +const isLoopbackHost = (host: string | null) => { + const normalized = (host || '').toLowerCase(); + return ( + normalized.startsWith('localhost') || + normalized.startsWith('127.0.0.1') || + normalized.startsWith('[::1]') + ); +}; + +const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:'); + +const requestJson = (port: number, pathname: string): Promise | null> => + new Promise((resolve) => { + const req = http.get( + { + host: '127.0.0.1', + port, + path: pathname, + timeout: 800, + }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => { + if (res.statusCode !== 200) { + resolve(null); + return; + } + try { + resolve(JSON.parse(body) as Record); + } catch { + resolve(null); + } + }); + }, + ); + req.on('timeout', () => { + req.destroy(); + resolve(null); + }); + req.on('error', () => resolve(null)); + }); + +const findRunningEditor = async () => { + const expectedVersion = readExpectedVersion(); + 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) { + return `http://127.0.0.1:${port}`; + } + } + return ''; +}; + +const resolvePythonCommand = (): PythonCommand => { + const candidates: PythonCommand[] = + process.platform === 'win32' + ? [ + { command: 'py', args: ['-3'] }, + { command: 'python', args: [] }, + { command: 'python3', args: [] }, + ] + : [ + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + ]; + + for (const candidate of candidates) { + const versionCheck = run(candidate.command, [...candidate.args, '--version'], { + timeout: 10_000, + }); + if (versionCheck.ok) return candidate; + } + throw new Error('没有找到 Python 3。请先安装 Python 3,然后重新打开审校器。'); +}; + +const ensureVenv = () => { + const pythonPath = venvPython(); + if (fs.existsSync(pythonPath)) return; + fs.mkdirSync(toolRoot(), { recursive: true }); + const python = resolvePythonCommand(); + const result = run(python.command, [...python.args, '-m', 'venv', venvRoot()], { + cwd: appRoot(), + timeout: 120_000, + }); + if (!result.ok) { + throw new Error(`创建 Python 环境失败:${result.stderr || result.error || result.stdout}`); + } +}; + +const ensureDependencies = () => { + const pythonPath = venvPython(); + const flaskCheck = run( + pythonPath, + ['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'], + { cwd: toolRoot(), timeout: 30_000 }, + ); + if (flaskCheck.ok) return; + + const install = run( + pythonPath, + ['-m', 'pip', 'install', '-r', path.join(toolRoot(), 'requirements.txt')], + { cwd: toolRoot(), timeout: 180_000 }, + ); + if (!install.ok) { + throw new Error(`安装审校器依赖失败:${install.stderr || install.error || install.stdout}`); + } +}; + +export async function POST(request: Request) { + if (!isLaunchEnabled()) { + return NextResponse.json( + { + ok: false, + error: 'EPUB 审校器的 Next 启动入口仅在本地 dev-web 开发环境启用。', + }, + { status: 404 }, + ); + } + if ( + request.headers.get('x-readest-review-editor-launch') !== '1' || + !isLoopbackHost(request.headers.get('host')) + ) { + return NextResponse.json( + { + ok: false, + error: 'EPUB 审校器启动请求必须来自本机 Readest 开发页面。', + }, + { status: 403 }, + ); + } + + if (launchState.promise) { + const data = await launchState.promise; + return NextResponse.json(data, { status: data.ok ? 200 : 500 }); + } + + launchState.promise = launchEditor(); + const data = await launchState.promise; + launchState.promise = null; + return NextResponse.json(data, { status: data.ok ? 200 : 500 }); +} + +async function launchEditor(): Promise { + const bundledToolRoot = toolRoot(); + const serverPath = path.join(bundledToolRoot, 'server.py'); + if (!fs.existsSync(serverPath)) { + return { + ok: false, + error: `没有找到已迁移的审校器后端:${serverPath}`, + }; + } + + const runningUrl = await findRunningEditor(); + if (runningUrl) { + return { + ok: true, + url: runningUrl, + reused: true, + reviewRoot: reviewRoot(), + version: readExpectedVersion(), + }; + } + + try { + ensureVenv(); + ensureDependencies(); + fs.mkdirSync(reviewRoot(), { recursive: true }); + + const launch = run( + venvPython(), + [ + serverPath, + '--review-root', + reviewRoot(), + '--host', + '127.0.0.1', + '--port', + String(DEFAULT_PORT), + '--daemon', + '--no-browser', + ], + { cwd: bundledToolRoot, timeout: 45_000 }, + ); + if (!launch.ok) { + throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`); + } + + const launchedUrl = normalizeLoopbackUrl( + /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()), + ); + if (!launchedUrl) { + throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); + } + + return { + ok: true, + url: launchedUrl, + reused: false, + reviewRoot: reviewRoot(), + version: readExpectedVersion(), + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + reviewRoot: reviewRoot(), + }; + } +} diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index d5d465ac..3d64685b 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -25,7 +25,7 @@ import { useSettingsStore } from '@/store/settingsStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useTransferQueue } from '@/hooks/useTransferQueue'; -import { navigateToLogin, navigateToProfile } from '@/utils/nav'; +import { navigateToLogin, navigateToProfile, navigateToReviewEditor } from '@/utils/nav'; import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window'; import { setAboutDialogVisible } from '@/components/AboutWindow'; import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow'; @@ -204,6 +204,11 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow setCacheManagerDialogVisible(true); }; + const handleOpenReviewEditor = () => { + navigateToReviewEditor(router); + setIsDropdownOpen?.(false); + }; + const handleRefreshMetadata = async () => { if (!appService || isRefreshingMetadata) return; setIsRefreshingMetadata(true); @@ -431,6 +436,9 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow
    + {process.env['NODE_ENV'] === 'development' && isWebAppPlatform() && ( + + )} {appService?.canCustomizeRootDir && ( diff --git a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx new file mode 100644 index 00000000..ae63e76c --- /dev/null +++ b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx @@ -0,0 +1,192 @@ +'use client'; + +import clsx from 'clsx'; +import { ArrowLeft, ExternalLink, RefreshCw, Rocket, Wrench } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { openExternalUrl } from '@/utils/open'; + +type LaunchState = 'idle' | 'launching' | 'ready' | 'failed'; + +type LaunchResponse = { + ok: boolean; + url?: string; + reused?: boolean; + reviewRoot?: string; + version?: string; + error?: string; +}; + +const LaunchButton = ({ + onClick, + disabled, + children, + variant = 'primary', +}: { + onClick: () => void; + disabled?: boolean; + children: ReactNode; + variant?: 'primary' | 'ghost'; +}) => ( + +); + +export default function ReviewEditorLauncher() { + const router = useRouter(); + const [state, setState] = useState('idle'); + const [payload, setPayload] = useState(null); + const didAutoLaunchRef = useRef(false); + + const launch = useCallback(async () => { + setState('launching'); + setPayload(null); + try { + const response = await fetch('/api/review-editor/launch', { + method: 'POST', + headers: { + 'x-readest-review-editor-launch': '1', + }, + }); + const data = (await response.json()) as LaunchResponse; + setPayload(data); + setState(response.ok && data.ok && data.url ? 'ready' : 'failed'); + } catch (error) { + setPayload({ + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + setState('failed'); + } + }, []); + + useEffect(() => { + if (didAutoLaunchRef.current) return; + didAutoLaunchRef.current = true; + void launch(); + }, [launch]); + + const editorUrl = payload?.url; + const frameTitle = useMemo(() => { + if (!payload?.version) return 'EPUB 审校器'; + return `EPUB 审校器 v${payload.version}`; + }, [payload?.version]); + + return ( +
    +
    +
    + +
    +

    EPUB 审校器

    +

    + Readest 基座内置旧版审校、书架与 AI 翻译工作流 +

    +
    +
    +
    + + {editorUrl ? ( + + ) : null} +
    +
    + + {state === 'ready' && editorUrl ? ( +