diff --git a/projects/web/.gitignore b/projects/web/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/projects/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/projects/web/README.md b/projects/web/README.md index cc592819..68a4ec70 100644 --- a/projects/web/README.md +++ b/projects/web/README.md @@ -1,12 +1,55 @@ -### 前端本地开发 +# MinerU web -1. 安装 nodejs 18 和 pnpm; -```javascript -npm install -g pnpm +## Table of Contents +- [Local Frontend Development](#local-frontend-development) +- [Technology Stack](#technology-stack) + +## Local Frontend Development + +### Prerequisites +- Node.js 18.x +- pnpm + +### Installation Steps + +1. Install Node.js 18 + - Visit the [Node.js official website](https://nodejs.org/) to download and install Node.js version 18.x + +2. Install pnpm + ```bash + npm install -g pnpm +3. Clone the repository + ```git clone https://github.com/opendatalab/MinerU + cd ./projects/web + ``` +4. Install dependencies + ``` + pnpm install + ``` +5. Run the development server + ``` + pnpm run dev + ``` +6. ⚠️ Note: This command is for local development only, do not use for deployment! +Open your browser and visit http://localhost:5173 (or another address output in the console) + +7. Ensure that the backend service in ./projects/web_demo is running + +8. If you encounter an error when executing `pnpm install`, you can switch to an alternative package manager. + ``` + npm install -g yarn + yarn + yarn start + ``` + + +## Building the Project ``` -2. 执行 `pnpm install && pnpm run dev` 即可。⚠️ 注意:此命令仅用于本地开发,不要用于部署! -3. build +pnpm run build ``` -1.pnpm run build -2.npm run build -``` \ No newline at end of file +## Technology Stack +- React +- Tailwind CSS +- typeScript +- zustand +- ahooks diff --git a/projects/web/README_zh-CN.md b/projects/web/README_zh-CN.md new file mode 100644 index 00000000..64724bb2 --- /dev/null +++ b/projects/web/README_zh-CN.md @@ -0,0 +1,61 @@ +# MinerU web + + +## 目录 +- [前端本地开发](#前端本地开发) +- [技术栈](#技术栈) +## 前端本地开发 + +### 前置条件 +- Node.js 18.x +- pnpm + +### 安装步骤 + +1. 安装 Node.js 18 + - 访问 [Node.js 官网](https://nodejs.org/) 下载并安装 Node.js 18.x 版本 + +2. 安装 pnpm + ```bash + npm install -g pnpm + ``` +3. 克隆仓库 + ``` + 1. git clone https://github.com/opendatalab/MinerU + 2. cd ./projects/web + ``` + +4. 安装依赖 + ``` + pnpm install + ``` + +5. 运行开发服务器 + ``` + pnpm run dev + ``` + +6. ⚠️ 注意:此命令仅用于本地开发,不要用于部署! +打开浏览器访问 http://localhost:5173(或控制台输出的其他地址) +构建项目 +要构建生产版本,请执行以下命令: + + ``` + pnpm run build + ``` +7. 请确保./projects/web_demo后端服务启动 + +8. 如果pnpm install执行error,可更换包管理器 + ``` + npm install -g yarn + yarn + yarn start + ``` + +## 技术栈 + +- React +- Tailwind CSS +- typeScript +- zustand +- ahooks diff --git a/projects/web/src/api/extract.ts b/projects/web/src/api/extract.ts index 65ffceb0..7e1e3091 100644 --- a/projects/web/src/api/extract.ts +++ b/projects/web/src/api/extract.ts @@ -95,6 +95,7 @@ export interface TaskIdResItem { type: ExtractTaskType | "unknown"; state: "running" | "done" | "pending" | "failed" | "unknown"; markdownUrl: string[]; + file_key?: string; } export type TaskIdRes = TaskIdResItem[]; @@ -166,3 +167,30 @@ export const localUpload = (file: File) => { }, }); }; + +export interface UpdateMarkdownRequest { + file_key: string; + data: { + [pageNumber: string]: string; + }; +} + +export interface UpdateMarkdownResponse { + success: boolean; + message?: string; +} + +export const updateMarkdownContent = async ( + params: UpdateMarkdownRequest +): Promise => { + return axios + .put("/api/v2/extract/markdown", params) + .then((res) => { + if (!res?.data?.error) { + return res.data.data; + } else { + handleErrorMsg(res); + return null; + } + }); +}; diff --git a/projects/web/src/components/SaveStatus.tsx b/projects/web/src/components/SaveStatus.tsx new file mode 100644 index 00000000..59a13652 --- /dev/null +++ b/projects/web/src/components/SaveStatus.tsx @@ -0,0 +1,67 @@ +import React, { + useState, + useEffect, + useImperativeHandle, + forwardRef, +} from "react"; + +interface SaveStatusProps { + className?: string; +} + +export interface SaveStatusRef { + triggerSave: () => void; +} + +const SaveStatus = forwardRef( + ({ className }, ref) => { + const [lastSaveTime, setLastSaveTime] = useState(null); + const [showSaved, setShowSaved] = useState(false); + const [timeSinceLastSave, setTimeSinceLastSave] = useState(""); + + useImperativeHandle(ref, () => ({ + triggerSave: () => { + setLastSaveTime(new Date()); + setShowSaved(true); + }, + })); + + useEffect(() => { + if (showSaved) { + const timer = setTimeout(() => { + setShowSaved(false); + }, 10000); + return () => clearTimeout(timer); + } + }, [showSaved]); + + useEffect(() => { + const updateTimeSinceLastSave = () => { + if (lastSaveTime) { + const now = new Date(); + const diffInMinutes = Math.floor( + (now.getTime() - lastSaveTime.getTime()) / 60000 + ); + if (diffInMinutes > 0) { + setTimeSinceLastSave(`${diffInMinutes} 分钟前`); + } + } + }; + + const timer = setInterval(updateTimeSinceLastSave, 60000); + updateTimeSinceLastSave(); // 立即更新一次 + return () => clearInterval(timer); + }, [lastSaveTime]); + + return ( +
+ {showSaved && 已保存} + {!showSaved && lastSaveTime && ( + 最近修改:{timeSinceLastSave} + )} +
+ ); + } +); + +export default SaveStatus; diff --git a/projects/web/src/pages/extract/components/md-viewer/index.tsx b/projects/web/src/pages/extract/components/md-viewer/index.tsx index 95edd82b..2e00dc94 100644 --- a/projects/web/src/pages/extract/components/md-viewer/index.tsx +++ b/projects/web/src/pages/extract/components/md-viewer/index.tsx @@ -17,6 +17,7 @@ import { TaskIdResItem } from "@/api/extract"; import useMdStore from "@/store/mdStore"; import CodeMirror from "@/components/code-mirror"; import { useParams } from "react-router-dom"; +import SaveStatus, { SaveStatusRef } from "@/components/SaveStatus"; interface IMdViewerProps { md?: string; @@ -48,10 +49,12 @@ const MdViewer: React.FC = ({ allMdContentWithAnchor, setMdUrlArr, mdContents, + updateMdContent, } = useMdStore(); const [lineWrap, setLineWrap] = useState(false); const threshold = 562 - 427; + const statusRef = useRef(null); const menuList = [ { @@ -137,8 +140,12 @@ const MdViewer: React.FC = ({ } }, [taskInfo?.markdownUrl, params?.jobID]); - const handleContentChange = (val: string) => { + const handleContentChange = (val: string, index: number) => { setAllMdContentWithAnchor(val); + statusRef?.current?.triggerSave(); + if (taskInfo?.file_key) { + updateMdContent(taskInfo.file_key!, index, val); + } }; return ( @@ -161,12 +168,13 @@ const MdViewer: React.FC = ({ ))} + {displayType === "code" && ( <> = ({ handleContentChange(val, index)} editable className="w-full h-full" /> diff --git a/projects/web/src/pages/home.tsx b/projects/web/src/pages/home.tsx index c162e902..fa0309a9 100644 --- a/projects/web/src/pages/home.tsx +++ b/projects/web/src/pages/home.tsx @@ -3,23 +3,24 @@ import ErrorBoundary from "@/components/error-boundary"; import styles from "./home.module.scss"; import { SlotID, Path } from "@/constant/route"; -import { HashRouter, Routes, Route, Outlet } from "react-router-dom"; +import { + BrowserRouter, + Routes, + Route, + Outlet, + Navigate, + useLocation, + HashRouter, +} from "react-router-dom"; import { ExtractorSide } from "./extract-side"; import { LanguageProvider } from "@/context/language-provider"; import PDFUpload from "@/pages/extract/components/pdf-upload"; import PDFExtractionJob from "@/pages/extract/components/pdf-extraction"; -// judge if the app has hydrated -// const useHasHydrated = () => { -// const [hasHydrated, setHasHydrated] = useState(false); -// useEffect(() => { -// setHasHydrated(true); -// }, []); -// return hasHydrated; -// }; - export function WindowContent() { + const location = useLocation(); const isHome = location.pathname === Path.Home; + return ( <> @@ -31,13 +32,15 @@ export function WindowContent() { } function Screen() { - // if you do not need to use the renderContent for rendering router, you can use the other render function to interrupt before the renderContent - const renderContent = () => { return (
}> + } + /> } @@ -46,15 +49,13 @@ function Screen() { path="/OpenSourceTools/Extractor/PDF/:jobID" element={} /> - {/* } /> */} + } + />
- - // - // - // - // ); }; @@ -62,11 +63,6 @@ function Screen() { } export function Home() { - // leave this comment to check if the app has hydrated - // if (!useHasHydrated()) { - // return ; - // } - return ( diff --git a/projects/web/src/store/mdStore.ts b/projects/web/src/store/mdStore.ts index 49d2d661..2368b274 100644 --- a/projects/web/src/store/mdStore.ts +++ b/projects/web/src/store/mdStore.ts @@ -1,6 +1,7 @@ // mdStore.ts import { create } from "zustand"; import axios from "axios"; +import { updateMarkdownContent, UpdateMarkdownRequest } from "@/api/extract"; // 确保路径正确 interface MdContent { content: string; @@ -48,6 +49,11 @@ interface MdState { ) => string; jumpToAnchor: (anchorId: string) => number; reset: () => void; + updateMdContent: ( + fileKey: string, + pageNumber: string | number, + newContent: string + ) => Promise; } const MAX_CONCURRENT_REQUESTS = 2; @@ -122,7 +128,6 @@ const useMdStore = create((set, get) => ({ const results = await fetchWithConcurrency(urls); - // 只有当这是最新的请求时,才更新状态 if (get().currentRequestId === requestId) { const newMdContents: Record = {}; results.forEach(([url, content]) => { @@ -191,6 +196,55 @@ const useMdStore = create((set, get) => ({ } return -1; // Anchor not found }, + + updateMdContent: async ( + fileKey: string, + pageNumber: string, + newContent: string + ) => { + try { + const params: UpdateMarkdownRequest = { + file_key: fileKey, + data: { + [pageNumber]: newContent, + }, + }; + + const result = await updateMarkdownContent(params); + + if (result && result.success) { + // 更新本地状态 + set((state) => { + const updatedMdContents = { ...state.mdContents }; + if (updatedMdContents[fileKey]) { + updatedMdContents[fileKey] = { + ...updatedMdContents[fileKey], + content: newContent, + }; + } + + // 重新计算 allMdContent 和 allMdContentWithAnchor + const contentArray = Object.values(updatedMdContents).map( + (content) => content.content + ); + const newAllMdContent = state.getAllMdContent(contentArray); + const newAllMdContentWithAnchor = + state.getContentWithAnchors(contentArray); + + return { + mdContents: updatedMdContents, + allMdContent: newAllMdContent, + allMdContentWithAnchor: newAllMdContentWithAnchor, + }; + }); + } else { + throw new Error("Failed to update Markdown content"); + } + } catch (error) { + set({ error: error as Error }); + throw error; + } + }, })); export default useMdStore;