Compare commits
9 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
64ac579c2a | |
|
|
965781213e | |
|
|
8dbfeddfe4 | |
|
|
fd69d47eca | |
|
|
ac2b198cd7 | |
|
|
a598099fba | |
|
|
8509f351a4 | |
|
|
59e2554238 | |
|
|
75835051b2 |
|
|
@ -5,12 +5,20 @@ import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import click
|
import click
|
||||||
import zipfile
|
import zipfile
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
|
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header
|
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File, Form, APIRouter, Header
|
||||||
|
from pydantic import BaseModel
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, FileResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
|
|
@ -23,7 +31,9 @@ from base64 import b64encode
|
||||||
# MinerU 内部导入
|
# MinerU 内部导入
|
||||||
from mineru.cli.common import aio_do_parse, read_fn, pdf_suffixes, image_suffixes, word_suffixes
|
from mineru.cli.common import aio_do_parse, read_fn, pdf_suffixes, image_suffixes, word_suffixes
|
||||||
from mineru.utils.cli_parser import arg_parse
|
from mineru.utils.cli_parser import arg_parse
|
||||||
|
from mineru.utils.config_reader import get_device
|
||||||
from mineru.utils.guess_suffix_or_lang import guess_suffix_by_path
|
from mineru.utils.guess_suffix_or_lang import guess_suffix_by_path
|
||||||
|
from mineru.utils.model_utils import clean_memory
|
||||||
from mineru.version import __version__
|
from mineru.version import __version__
|
||||||
|
|
||||||
# --- 日志配置 ---
|
# --- 日志配置 ---
|
||||||
|
|
@ -36,13 +46,71 @@ _request_semaphore: Optional[asyncio.Semaphore] = None
|
||||||
|
|
||||||
# --- 任务进度跟踪 ---
|
# --- 任务进度跟踪 ---
|
||||||
_task_progress: dict = {}
|
_task_progress: dict = {}
|
||||||
|
_task_progress_dir = Path(
|
||||||
|
os.getenv("MINERU_TASK_PROGRESS_DIR", Path(tempfile.gettempdir()) / "mineru-task-progress")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _task_progress_path(task_id: str) -> Path:
|
||||||
|
task_key = hashlib.sha256(task_id.encode("utf-8")).hexdigest()
|
||||||
|
return _task_progress_dir / f"{task_key}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _store_task_progress(task_id: str, state: dict) -> None:
|
||||||
|
"""Store progress in memory and on disk so API worker processes share it."""
|
||||||
|
state = dict(state)
|
||||||
|
state["task_id"] = task_id
|
||||||
|
_task_progress[task_id] = state
|
||||||
|
|
||||||
|
try:
|
||||||
|
_task_progress_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
progress_path = _task_progress_path(task_id)
|
||||||
|
temp_path = progress_path.with_name(
|
||||||
|
f"{progress_path.name}.{os.getpid()}.{threading.get_ident()}.tmp"
|
||||||
|
)
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as fp:
|
||||||
|
json.dump(state, fp, ensure_ascii=False)
|
||||||
|
os.replace(temp_path, progress_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to persist task progress for {task_id}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_task_progress(task_id: str) -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
with open(_task_progress_path(task_id), "r", encoding="utf-8") as fp:
|
||||||
|
state = json.load(fp)
|
||||||
|
_task_progress[task_id] = state
|
||||||
|
return state
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return _task_progress.get(task_id)
|
||||||
|
|
||||||
|
|
||||||
def _update_task_progress(task_id: Optional[str], progress: int, stage: str):
|
def _update_task_progress(task_id: Optional[str], progress: int, stage: str):
|
||||||
"""更新任务进度(安全调用,task_id 为 None 时静默跳过)"""
|
"""更新任务进度(安全调用,task_id 为 None 时静默跳过)"""
|
||||||
if task_id and task_id in _task_progress:
|
if not task_id:
|
||||||
_task_progress[task_id]["progress"] = min(progress, 100)
|
return
|
||||||
_task_progress[task_id]["stage"] = stage
|
state = _get_task_progress(task_id)
|
||||||
|
if state is not None:
|
||||||
|
current = int(state.get("progress", 0) or 0)
|
||||||
|
state["progress"] = min(max(progress, current), 100)
|
||||||
|
state["stage"] = stage
|
||||||
|
_store_task_progress(task_id, state)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_runtime_memory():
|
||||||
|
"""Release transient accelerator caches after each API parse request."""
|
||||||
|
try:
|
||||||
|
clean_memory(get_device())
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Failed to clean runtime memory: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_parse_error(exc: Exception) -> str:
|
||||||
|
message = str(exc)
|
||||||
|
if "CUDA out of memory" in message or exc.__class__.__name__ in ("OutOfMemoryError", "RuntimeError"):
|
||||||
|
if "out of memory" in message.lower():
|
||||||
|
return "GPU显存不足,已尝试清理缓存。请减少最大页数、关闭公式/表格识别,或稍后重试。"
|
||||||
|
return f"Internal Error: {message}"
|
||||||
|
|
||||||
|
|
||||||
class ProgressTracker:
|
class ProgressTracker:
|
||||||
|
|
@ -119,7 +187,11 @@ class _StderrProgressCapture:
|
||||||
|
|
||||||
# tqdm 进度条模式:名称: 百分比|...| 当前/总数
|
# tqdm 进度条模式:名称: 百分比|...| 当前/总数
|
||||||
_PATTERNS = [
|
_PATTERNS = [
|
||||||
(re.compile(r'Two Step Extraction:\s*(\d+)%.*?(\d+)/(\d+)'), 'extract'),
|
(re.compile(r'Layout Preparation:\s*(\d+)%.*?(\d+)/(\d+)'), 'layout_prepare'),
|
||||||
|
(re.compile(r'Layout Output Parsing:\s*(\d+)%.*?(\d+)/(\d+)'), 'layout_parse'),
|
||||||
|
(re.compile(r'Extract Preparation:\s*(\d+)%.*?(\d+)/(\d+)'), 'extract_prepare'),
|
||||||
|
(re.compile(r'Post Processing:\s*(\d+)%.*?(\d+)/(\d+)'), 'post_process'),
|
||||||
|
(re.compile(r'Two Step Extraction:\s*(\d+)%.*?(\d+)/(\d+)'), 'vlm_predict'),
|
||||||
(re.compile(r'MFD Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfd'),
|
(re.compile(r'MFD Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfd'),
|
||||||
(re.compile(r'MFR Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfr'),
|
(re.compile(r'MFR Predict:\s*(\d+)%.*?(\d+)/(\d+)'), 'mfr'),
|
||||||
(re.compile(r'OCR-det:\s*(\d+)%.*?(\d+)/(\d+)'), 'ocr_det'),
|
(re.compile(r'OCR-det:\s*(\d+)%.*?(\d+)/(\d+)'), 'ocr_det'),
|
||||||
|
|
@ -127,22 +199,35 @@ class _StderrProgressCapture:
|
||||||
(re.compile(r'Loading safetensors.*?:\s*(\d+)%'), 'load_model'),
|
(re.compile(r'Loading safetensors.*?:\s*(\d+)%'), 'load_model'),
|
||||||
(re.compile(r'Capturing CUDA graphs.*?:\s*(\d+)%'), 'cuda_graph'),
|
(re.compile(r'Capturing CUDA graphs.*?:\s*(\d+)%'), 'cuda_graph'),
|
||||||
]
|
]
|
||||||
|
_GENERIC_PREDICT_PATTERN = re.compile(r'^Predict:\s*(\d+)%.*?(\d+)/(\d+)')
|
||||||
|
|
||||||
# 各阶段的进度映射范围 [start%, end%]
|
# 各阶段的进度映射范围 [start%, end%]
|
||||||
_RANGES = {
|
_RANGES = {
|
||||||
'load_model': (12, 18),
|
'load_model': (12, 18),
|
||||||
'cuda_graph': (33, 37),
|
'cuda_graph': (33, 37),
|
||||||
'extract': (42, 65),
|
'layout_prepare': (42, 45),
|
||||||
'mfd': (75, 80),
|
'layout_predict': (45, 68),
|
||||||
'mfr': (80, 87),
|
'layout_parse': (68, 70),
|
||||||
'ocr_det': (87, 92),
|
'extract_prepare': (70, 72),
|
||||||
'ocr_rec': (92, 96),
|
'extract_predict': (72, 88),
|
||||||
|
'vlm_predict': (45, 88),
|
||||||
|
'post_process': (88, 90),
|
||||||
|
'mfd': (90, 92),
|
||||||
|
'mfr': (92, 94),
|
||||||
|
'ocr_det': (94, 96),
|
||||||
|
'ocr_rec': (96, 97),
|
||||||
}
|
}
|
||||||
|
|
||||||
_STAGE_LABELS = {
|
_STAGE_LABELS = {
|
||||||
'load_model': '加载模型权重',
|
'load_model': '加载模型权重',
|
||||||
'cuda_graph': '捕获CUDA计算图',
|
'cuda_graph': '捕获CUDA计算图',
|
||||||
'extract': 'VLM文档分析',
|
'layout_prepare': '准备版面分析',
|
||||||
|
'layout_predict': '版面分析',
|
||||||
|
'layout_parse': '解析版面结果',
|
||||||
|
'extract_prepare': '准备内容抽取',
|
||||||
|
'extract_predict': '内容抽取',
|
||||||
|
'vlm_predict': 'VLM文档分析',
|
||||||
|
'post_process': '后处理',
|
||||||
'mfd': '数学公式检测',
|
'mfd': '数学公式检测',
|
||||||
'mfr': '数学公式识别',
|
'mfr': '数学公式识别',
|
||||||
'ocr_det': '文字区域检测',
|
'ocr_det': '文字区域检测',
|
||||||
|
|
@ -152,54 +237,82 @@ class _StderrProgressCapture:
|
||||||
def __init__(self, task_id: str):
|
def __init__(self, task_id: str):
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self._active = False
|
self._active = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
|
||||||
self._orig_stderr = None
|
self._orig_stderr = None
|
||||||
|
self._buf = ""
|
||||||
|
self._last_anchor = ""
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
self._active = True
|
self._active = True
|
||||||
self._orig_stderr = sys.stderr
|
self._orig_stderr = sys.stderr
|
||||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
sys.stderr = self
|
||||||
self._thread.start()
|
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._active = False
|
self._active = False
|
||||||
if self._thread and self._thread.is_alive():
|
if self._buf.strip():
|
||||||
self._thread.join(timeout=2)
|
self._parse_line(self._buf.strip())
|
||||||
self._thread = None
|
self._buf = ""
|
||||||
|
if self._orig_stderr is not None and sys.stderr is self:
|
||||||
|
sys.stderr = self._orig_stderr
|
||||||
|
|
||||||
def _reader_loop(self):
|
def write(self, text):
|
||||||
buf = ""
|
if self._orig_stderr is not None:
|
||||||
orig = self._orig_stderr
|
self._orig_stderr.write(text)
|
||||||
while self._active:
|
if not self._active:
|
||||||
try:
|
return len(text)
|
||||||
ch = orig.read(1)
|
for ch in text:
|
||||||
if not ch:
|
self._buf += ch
|
||||||
break
|
|
||||||
buf += ch
|
|
||||||
# tqdm 用 \r 更新同一行,\n 表示新行
|
# tqdm 用 \r 更新同一行,\n 表示新行
|
||||||
if ch == '\r' or ch == '\n':
|
if ch == '\r' or ch == '\n':
|
||||||
if buf.strip():
|
if self._buf.strip():
|
||||||
self._parse_line(buf.strip())
|
self._parse_line(self._buf.strip())
|
||||||
buf = ""
|
self._buf = ""
|
||||||
except Exception:
|
return len(text)
|
||||||
break
|
|
||||||
|
def flush(self):
|
||||||
|
if self._orig_stderr is not None:
|
||||||
|
self._orig_stderr.flush()
|
||||||
|
|
||||||
|
def isatty(self):
|
||||||
|
return bool(self._orig_stderr and self._orig_stderr.isatty())
|
||||||
|
|
||||||
|
def fileno(self):
|
||||||
|
if self._orig_stderr is not None:
|
||||||
|
return self._orig_stderr.fileno()
|
||||||
|
raise OSError("stderr is not available")
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
if self._orig_stderr is not None:
|
||||||
|
return getattr(self._orig_stderr, name)
|
||||||
|
raise AttributeError(name)
|
||||||
|
|
||||||
def _parse_line(self, line: str):
|
def _parse_line(self, line: str):
|
||||||
|
if "Layout Preparation:" in line:
|
||||||
|
self._last_anchor = "layout"
|
||||||
|
elif "Extract Preparation:" in line:
|
||||||
|
self._last_anchor = "extract"
|
||||||
|
|
||||||
|
generic_predict = self._GENERIC_PREDICT_PATTERN.search(line)
|
||||||
|
if generic_predict:
|
||||||
|
stage = "extract_predict" if self._last_anchor == "extract" else "layout_predict"
|
||||||
|
self._update_from_match(generic_predict, stage)
|
||||||
|
return
|
||||||
|
|
||||||
for pattern, stage in self._PATTERNS:
|
for pattern, stage in self._PATTERNS:
|
||||||
m = pattern.search(line)
|
m = pattern.search(line)
|
||||||
if m:
|
if m:
|
||||||
pct = int(m.group(1))
|
self._update_from_match(m, stage)
|
||||||
|
break
|
||||||
|
|
||||||
|
def _update_from_match(self, match, stage: str):
|
||||||
|
pct = int(match.group(1))
|
||||||
lo, hi = self._RANGES.get(stage, (0, 100))
|
lo, hi = self._RANGES.get(stage, (0, 100))
|
||||||
mapped = lo + int((hi - lo) * pct / 100)
|
mapped = lo + int((hi - lo) * pct / 100)
|
||||||
label = self._STAGE_LABELS.get(stage, stage)
|
label = self._STAGE_LABELS.get(stage, stage)
|
||||||
if stage == 'extract' and len(m.groups()) >= 3:
|
if len(match.groups()) >= 3:
|
||||||
cur, total = m.group(2), m.group(3)
|
cur, total = match.group(2), match.group(3)
|
||||||
label = f"VLM文档分析 ({cur}/{total}页)"
|
unit = "页" if stage in ("layout_predict", "vlm_predict") else ""
|
||||||
elif stage in ('mfd', 'mfr', 'ocr_det', 'ocr_rec') and len(m.groups()) >= 3:
|
label = f"{label} ({cur}/{total}{unit})"
|
||||||
cur, total = m.group(2), m.group(3)
|
|
||||||
label = f"{label} ({cur}/{total})"
|
|
||||||
_update_task_progress(self.task_id, mapped, label)
|
_update_task_progress(self.task_id, mapped, label)
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
async def limit_concurrency():
|
async def limit_concurrency():
|
||||||
|
|
@ -248,12 +361,383 @@ def get_infer_result(file_suffix_identifier: str, pdf_name: str, parse_dir: str)
|
||||||
api_router = APIRouter(prefix="/api")
|
api_router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
class MindmapOrganizeRequest(BaseModel):
|
||||||
|
markdown: str
|
||||||
|
mode: str = "smart"
|
||||||
|
prompt: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_json_object(text: Optional[str]) -> str:
|
||||||
|
content = (text or "").strip()
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = re.sub(r"^```(?:json|markdown|md)?\s*", "", content, flags=re.IGNORECASE)
|
||||||
|
content = re.sub(r"\s*```$", "", content)
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_llm_message_content(message: dict) -> str:
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, str):
|
||||||
|
parts.append(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
text = item.get("text") or item.get("content")
|
||||||
|
if isinstance(text, str):
|
||||||
|
parts.append(text)
|
||||||
|
if parts:
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
for key in ("reasoning_content", "reasoning", "output_text", "text"):
|
||||||
|
value = message.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MINDMAP_ORGANIZE_PROMPT = """你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保留原文标题结构,不要重写或打乱主要标题层级。
|
||||||
|
2. 将标题下的段落内容总结为要点,合并相近段落,避免逐段照抄。
|
||||||
|
3. 保留原文语言:英文内容输出英文,中文内容输出中文,多语言内容按原文语言分别保留。
|
||||||
|
4. 不要编造原文没有的信息。
|
||||||
|
5. 保留关键数字、公式、专有名词、步骤和结论。
|
||||||
|
6. 最大层级不超过 4 层。
|
||||||
|
7. 每个父节点下最多 8 个子节点。
|
||||||
|
8. 节点标题尽量简短,正文说明使用短句列表。
|
||||||
|
9. 只输出 Markdown,不要输出解释、代码块围栏或额外说明。"""
|
||||||
|
|
||||||
|
MINDMAP_MERGE_PROMPT = """你是 Markdown 思维导图结构校对助手。下面是多个分块已经整理总结后的局部 Markdown 大纲。
|
||||||
|
|
||||||
|
任务:
|
||||||
|
1. 合并这些局部大纲为一份完整 Markdown。
|
||||||
|
2. 只检查和调整标题层级结构、顺序、重复标题和父子关系。
|
||||||
|
3. 不要重新总结正文,不要扩写内容,不要新增原文没有的信息。
|
||||||
|
4. 保留各局部大纲中的源语言:英文保持英文,中文保持中文,多语言分别保留。
|
||||||
|
5. 最大层级不超过 4 层。
|
||||||
|
6. 每个父节点下最多 8 个子节点,必要时只合并相近标题。
|
||||||
|
7. 只输出 Markdown,不要输出解释、代码块围栏或额外说明。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_tokens(text: str) -> int:
|
||||||
|
ascii_chars = 0
|
||||||
|
non_ascii_chars = 0
|
||||||
|
for ch in text:
|
||||||
|
if ch.isspace():
|
||||||
|
continue
|
||||||
|
if ord(ch) < 128:
|
||||||
|
ascii_chars += 1
|
||||||
|
else:
|
||||||
|
non_ascii_chars += 1
|
||||||
|
return max(1, int(ascii_chars / 4) + int(non_ascii_chars * 1.5))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mindmap_max_output_tokens() -> int:
|
||||||
|
return int(os.getenv("MINDMAP_LLM_MAX_OUTPUT_TOKENS", "4096"))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mindmap_context_budget(prompt: str, reserve_output_tokens: Optional[int] = None) -> tuple[int, int]:
|
||||||
|
if reserve_output_tokens is None:
|
||||||
|
reserve_output_tokens = _get_mindmap_max_output_tokens()
|
||||||
|
max_context_tokens = int(os.getenv("MINDMAP_LLM_MAX_CONTEXT_TOKENS", "32768"))
|
||||||
|
prompt_tokens = _estimate_tokens(prompt)
|
||||||
|
safety_tokens = int(os.getenv("MINDMAP_LLM_SAFETY_TOKENS", "1024"))
|
||||||
|
input_budget_tokens = max(2048, max_context_tokens - prompt_tokens - reserve_output_tokens - safety_tokens)
|
||||||
|
logger.info(
|
||||||
|
"Mindmap context budget max_context_tokens={} prompt_tokens={} reserve_output_tokens={} safety_tokens={} input_budget_tokens={}",
|
||||||
|
max_context_tokens, prompt_tokens, reserve_output_tokens, safety_tokens, input_budget_tokens
|
||||||
|
)
|
||||||
|
return max_context_tokens, input_budget_tokens
|
||||||
|
|
||||||
|
|
||||||
|
def _split_markdown_blocks(markdown: str) -> list[str]:
|
||||||
|
lines = markdown.splitlines()
|
||||||
|
blocks: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
|
||||||
|
heading_pattern = re.compile(r"^#{1,6}\s+")
|
||||||
|
for line in lines:
|
||||||
|
if heading_pattern.match(line) and current:
|
||||||
|
blocks.append("\n".join(current).strip())
|
||||||
|
current = [line]
|
||||||
|
else:
|
||||||
|
current.append(line)
|
||||||
|
|
||||||
|
if current:
|
||||||
|
blocks.append("\n".join(current).strip())
|
||||||
|
|
||||||
|
return [block for block in blocks if block]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_large_block(block: str, max_tokens: int) -> list[str]:
|
||||||
|
paragraphs = re.split(r"\n{2,}", block)
|
||||||
|
chunks: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
current_tokens = 0
|
||||||
|
|
||||||
|
for paragraph in paragraphs:
|
||||||
|
paragraph = paragraph.strip()
|
||||||
|
if not paragraph:
|
||||||
|
continue
|
||||||
|
paragraph_tokens = _estimate_tokens(paragraph)
|
||||||
|
if current and current_tokens + paragraph_tokens > max_tokens:
|
||||||
|
chunks.append("\n\n".join(current))
|
||||||
|
current = [paragraph]
|
||||||
|
current_tokens = paragraph_tokens
|
||||||
|
else:
|
||||||
|
current.append(paragraph)
|
||||||
|
current_tokens += paragraph_tokens
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append("\n\n".join(current))
|
||||||
|
return chunks or [block]
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_markdown_by_headings(markdown: str, max_tokens: int) -> list[str]:
|
||||||
|
blocks = _split_markdown_blocks(markdown)
|
||||||
|
chunks: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
current_tokens = 0
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
block_tokens = _estimate_tokens(block)
|
||||||
|
if block_tokens > max_tokens:
|
||||||
|
if current:
|
||||||
|
chunks.append("\n\n".join(current))
|
||||||
|
current = []
|
||||||
|
current_tokens = 0
|
||||||
|
chunks.extend(_split_large_block(block, max_tokens))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current and current_tokens + block_tokens > max_tokens:
|
||||||
|
chunks.append("\n\n".join(current))
|
||||||
|
current = [block]
|
||||||
|
current_tokens = block_tokens
|
||||||
|
else:
|
||||||
|
current.append(block)
|
||||||
|
current_tokens += block_tokens
|
||||||
|
|
||||||
|
if current:
|
||||||
|
chunks.append("\n\n".join(current))
|
||||||
|
|
||||||
|
return chunks or [markdown]
|
||||||
|
|
||||||
|
|
||||||
|
def _call_mindmap_llm(markdown: str, mode: str = "smart", custom_prompt: Optional[str] = None, task_id: Optional[str] = None, request_role: str = "organize") -> str:
|
||||||
|
base_url = os.getenv("MINDMAP_LLM_BASE_URL", "").rstrip("/")
|
||||||
|
model = os.getenv("MINDMAP_LLM_MODEL", "gemma-4-26B")
|
||||||
|
api_key = os.getenv("MINDMAP_LLM_API_KEY", "")
|
||||||
|
timeout = int(os.getenv("MINDMAP_LLM_TIMEOUT", "180"))
|
||||||
|
max_output_tokens = _get_mindmap_max_output_tokens()
|
||||||
|
|
||||||
|
if not base_url:
|
||||||
|
raise RuntimeError("未配置智能整理模型服务,请设置 MINDMAP_LLM_BASE_URL")
|
||||||
|
|
||||||
|
compact_markdown = markdown.strip()
|
||||||
|
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
|
||||||
|
prompt = f"""{prompt_template}
|
||||||
|
|
||||||
|
原始 Markdown:
|
||||||
|
{compact_markdown}
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Mindmap LLM request start task_id={} role={} model={} base_url={} mode={} input_chars={} input_tokens_est={} prompt_chars={} max_tokens={}",
|
||||||
|
task_id or "-", request_role, model, base_url, mode, len(compact_markdown), _estimate_tokens(compact_markdown), len(prompt_template), max_output_tokens
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": "你擅长把长文档整理成结构清晰、层次合理的思维导图 Markdown,并严格保留原文语言。"},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"temperature": float(os.getenv("MINDMAP_LLM_TEMPERATURE", "0.2")),
|
||||||
|
"max_tokens": max_output_tokens,
|
||||||
|
}
|
||||||
|
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {api_key}"
|
||||||
|
|
||||||
|
url = f"{base_url}/chat/completions"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
result = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="ignore")
|
||||||
|
raise RuntimeError(f"智能整理模型请求失败: HTTP {exc.code} {detail}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"智能整理模型请求失败: {exc}") from exc
|
||||||
|
|
||||||
|
choices = result.get("choices") or []
|
||||||
|
choice = choices[0] if choices else {}
|
||||||
|
message = choice.get("message") or {}
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
usage = result.get("usage")
|
||||||
|
content = _extract_llm_message_content(message)
|
||||||
|
if not content:
|
||||||
|
logger.warning(
|
||||||
|
"Mindmap LLM returned empty content task_id={} role={} finish_reason={} usage={} message_keys={}",
|
||||||
|
task_id or "-", request_role, finish_reason, usage, list(message.keys())
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"智能整理模型返回空内容,finish_reason={finish_reason}")
|
||||||
|
organized = _extract_json_object(content)
|
||||||
|
if not organized:
|
||||||
|
logger.warning(
|
||||||
|
"Mindmap LLM returned invalid organized content task_id={} role={} finish_reason={} usage={}",
|
||||||
|
task_id or "-", request_role, finish_reason, usage
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"智能整理模型未返回有效内容,finish_reason={finish_reason}")
|
||||||
|
logger.info(
|
||||||
|
"Mindmap LLM request completed task_id={} role={} finish_reason={} usage={} output_chars={} output_tokens_est={}",
|
||||||
|
task_id or "-", request_role, finish_reason, usage, len(organized), _estimate_tokens(organized)
|
||||||
|
)
|
||||||
|
return organized
|
||||||
|
|
||||||
|
|
||||||
|
def _organize_mindmap_markdown(markdown: str, mode: str, custom_prompt: Optional[str], task_id: str) -> str:
|
||||||
|
prompt_template = (custom_prompt or "").strip() or DEFAULT_MINDMAP_ORGANIZE_PROMPT
|
||||||
|
_, input_budget_tokens = _get_mindmap_context_budget(prompt_template)
|
||||||
|
source_tokens = _estimate_tokens(markdown)
|
||||||
|
logger.info(
|
||||||
|
"Mindmap organize strategy task_id={} source_chars={} source_tokens_est={} input_budget_tokens={}",
|
||||||
|
task_id, len(markdown), source_tokens, input_budget_tokens
|
||||||
|
)
|
||||||
|
|
||||||
|
if source_tokens <= input_budget_tokens:
|
||||||
|
_update_task_progress(task_id, 35, "调用智能整理模型")
|
||||||
|
return _call_mindmap_llm(markdown, mode, prompt_template, task_id, "single")
|
||||||
|
|
||||||
|
chunks = _chunk_markdown_by_headings(markdown, input_budget_tokens)
|
||||||
|
logger.info("Mindmap large input split task_id={} chunks={}", task_id, len(chunks))
|
||||||
|
partial_results: list[str] = []
|
||||||
|
for index, chunk in enumerate(chunks, start=1):
|
||||||
|
progress = 20 + int(index / max(len(chunks), 1) * 55)
|
||||||
|
_update_task_progress(task_id, progress, f"智能整理分块 {index}/{len(chunks)}")
|
||||||
|
logger.info(
|
||||||
|
"Mindmap chunk organize task_id={} chunk={}/{} chars={} tokens_est={}",
|
||||||
|
task_id, index, len(chunks), len(chunk), _estimate_tokens(chunk)
|
||||||
|
)
|
||||||
|
partial = _call_mindmap_llm(chunk, mode, prompt_template, task_id, f"chunk-{index}")
|
||||||
|
partial_results.append(partial)
|
||||||
|
|
||||||
|
merged_input = "\n\n".join(
|
||||||
|
f"<!-- chunk {index} -->\n{partial}"
|
||||||
|
for index, partial in enumerate(partial_results, start=1)
|
||||||
|
)
|
||||||
|
_, merge_budget_tokens = _get_mindmap_context_budget(MINDMAP_MERGE_PROMPT)
|
||||||
|
merge_tokens = _estimate_tokens(merged_input)
|
||||||
|
if merge_tokens > merge_budget_tokens:
|
||||||
|
logger.warning(
|
||||||
|
"Mindmap merged outline still exceeds context task_id={} tokens_est={} budget={} chunks={}",
|
||||||
|
task_id, merge_tokens, merge_budget_tokens, len(partial_results)
|
||||||
|
)
|
||||||
|
merge_chunks = _chunk_markdown_by_headings(merged_input, merge_budget_tokens)
|
||||||
|
merged_round: list[str] = []
|
||||||
|
for index, chunk in enumerate(merge_chunks, start=1):
|
||||||
|
_update_task_progress(task_id, 78 + int(index / max(len(merge_chunks), 1) * 10), f"合并局部大纲 {index}/{len(merge_chunks)}")
|
||||||
|
merged_round.append(_call_mindmap_llm(chunk, mode, MINDMAP_MERGE_PROMPT, task_id, f"merge-round-{index}"))
|
||||||
|
merged_input = "\n\n".join(merged_round)
|
||||||
|
|
||||||
|
_update_task_progress(task_id, 90, "全局整理标题结构")
|
||||||
|
return _call_mindmap_llm(merged_input, mode, MINDMAP_MERGE_PROMPT, task_id, "merge")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_mindmap_organize_task(task_id: str, markdown: str, mode: str, prompt: Optional[str]):
|
||||||
|
try:
|
||||||
|
_store_task_progress(task_id, {
|
||||||
|
"progress": 10,
|
||||||
|
"stage": "准备智能整理",
|
||||||
|
"status": "processing",
|
||||||
|
"error": None,
|
||||||
|
"file_names": "",
|
||||||
|
"result_md": None,
|
||||||
|
})
|
||||||
|
organized = await asyncio.to_thread(_organize_mindmap_markdown, markdown, mode, prompt, task_id)
|
||||||
|
state = _get_task_progress(task_id) or {}
|
||||||
|
state.update({
|
||||||
|
"progress": 100,
|
||||||
|
"stage": "智能整理完成",
|
||||||
|
"status": "completed",
|
||||||
|
"error": None,
|
||||||
|
"result_md": organized,
|
||||||
|
})
|
||||||
|
_store_task_progress(task_id, state)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"Mindmap organize task failed task_id={task_id}: {exc}")
|
||||||
|
state = _get_task_progress(task_id) or {}
|
||||||
|
state.update({
|
||||||
|
"progress": 100,
|
||||||
|
"stage": "智能整理失败",
|
||||||
|
"status": "failed",
|
||||||
|
"error": str(exc),
|
||||||
|
"result_md": None,
|
||||||
|
})
|
||||||
|
_store_task_progress(task_id, state)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/parse_tasks/{task_id}", status_code=201)
|
||||||
|
async def create_parse_task(task_id: str):
|
||||||
|
"""Register a task before the multipart upload starts."""
|
||||||
|
state = {
|
||||||
|
"progress": 0,
|
||||||
|
"stage": "等待上传",
|
||||||
|
"status": "pending",
|
||||||
|
"error": None,
|
||||||
|
"file_names": "",
|
||||||
|
}
|
||||||
|
_store_task_progress(task_id, state)
|
||||||
|
logger.info(f"Registered parse task pid={os.getpid()} task_id={task_id}")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/parse_progress/{task_id}")
|
@api_router.get("/parse_progress/{task_id}")
|
||||||
async def get_parse_progress(task_id: str):
|
async def get_parse_progress(task_id: str):
|
||||||
"""查询解析任务的实时进度"""
|
"""查询解析任务的实时进度"""
|
||||||
if task_id not in _task_progress:
|
state = _get_task_progress(task_id)
|
||||||
|
if state is None:
|
||||||
|
logger.warning(f"Parse task not found pid={os.getpid()} task_id={task_id}")
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return _task_progress[task_id]
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.post("/mindmap_tasks/{task_id}", status_code=201)
|
||||||
|
async def create_mindmap_task(task_id: str, request: MindmapOrganizeRequest):
|
||||||
|
"""Create an async task that organizes Markdown into summarized mindmap Markdown."""
|
||||||
|
markdown = request.markdown.strip()
|
||||||
|
if not markdown:
|
||||||
|
raise HTTPException(status_code=400, detail="Markdown content is required")
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"progress": 0,
|
||||||
|
"stage": "等待智能整理",
|
||||||
|
"status": "pending",
|
||||||
|
"error": None,
|
||||||
|
"file_names": "",
|
||||||
|
"result_md": None,
|
||||||
|
}
|
||||||
|
_store_task_progress(task_id, state)
|
||||||
|
asyncio.create_task(_run_mindmap_organize_task(task_id, markdown, request.mode, request.prompt))
|
||||||
|
logger.info(
|
||||||
|
"Registered mindmap organize task pid={} task_id={} mode={} input_chars={} custom_prompt={}",
|
||||||
|
os.getpid(), task_id, request.mode, len(markdown), bool((request.prompt or "").strip())
|
||||||
|
)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/mindmap_progress/{task_id}")
|
||||||
|
async def get_mindmap_progress(task_id: str):
|
||||||
|
"""Query async mindmap organization progress and result."""
|
||||||
|
state = _get_task_progress(task_id)
|
||||||
|
if state is None:
|
||||||
|
logger.warning(f"Mindmap task not found pid={os.getpid()} task_id={task_id}")
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
@api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)])
|
@api_router.post(path="/file_parse", dependencies=[Depends(limit_concurrency)])
|
||||||
|
|
@ -274,6 +758,7 @@ async def parse_pdf(
|
||||||
response_format_zip: bool = Form(False),
|
response_format_zip: bool = Form(False),
|
||||||
start_page_id: int = Form(0),
|
start_page_id: int = Form(0),
|
||||||
end_page_id: int = Form(99999),
|
end_page_id: int = Form(99999),
|
||||||
|
form_task_id: Optional[str] = Form(None, alias="task_id"),
|
||||||
x_task_id: Optional[str] = Header(None),
|
x_task_id: Optional[str] = Header(None),
|
||||||
):
|
):
|
||||||
# 从 app 实例状态中获取配置 (FastAPI 实例会在下方创建)
|
# 从 app 实例状态中获取配置 (FastAPI 实例会在下方创建)
|
||||||
|
|
@ -281,12 +766,16 @@ async def parse_pdf(
|
||||||
config = getattr(app.state, "config", {})
|
config = getattr(app.state, "config", {})
|
||||||
|
|
||||||
# 初始化进度跟踪
|
# 初始化进度跟踪
|
||||||
task_id = x_task_id or str(uuid.uuid4())
|
task_id = x_task_id or form_task_id or str(uuid.uuid4())
|
||||||
file_names_str = ", ".join(f.filename or "unknown" for f in files)
|
file_names_str = ", ".join(f.filename or "unknown" for f in files)
|
||||||
_task_progress[task_id] = {
|
_store_task_progress(task_id, {
|
||||||
"progress": 0, "stage": "准备中", "status": "processing",
|
"progress": 0, "stage": "准备中", "status": "processing",
|
||||||
"error": None, "file_names": file_names_str,
|
"error": None, "file_names": file_names_str,
|
||||||
}
|
})
|
||||||
|
logger.info(
|
||||||
|
f"Started parse task pid={os.getpid()} task_id={task_id} "
|
||||||
|
f"header_task_id={x_task_id} form_task_id={form_task_id}"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
unique_dir = os.path.join(output_dir, str(uuid.uuid4()))
|
unique_dir = os.path.join(output_dir, str(uuid.uuid4()))
|
||||||
|
|
@ -353,7 +842,10 @@ async def parse_pdf(
|
||||||
_update_task_progress(task_id, 97, "生成结果文件")
|
_update_task_progress(task_id, 97, "生成结果文件")
|
||||||
|
|
||||||
_update_task_progress(task_id, 100, "转换完成")
|
_update_task_progress(task_id, 100, "转换完成")
|
||||||
_task_progress[task_id]["status"] = "completed"
|
completed_state = _get_task_progress(task_id) or {}
|
||||||
|
completed_state["status"] = "completed"
|
||||||
|
_store_task_progress(task_id, completed_state)
|
||||||
|
_cleanup_runtime_memory()
|
||||||
|
|
||||||
# 清理日志捕获和 stderr 捕获
|
# 清理日志捕获和 stderr 捕获
|
||||||
stderr_capture.stop()
|
stderr_capture.stop()
|
||||||
|
|
@ -416,9 +908,12 @@ async def parse_pdf(
|
||||||
_current_task_id = None
|
_current_task_id = None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
_task_progress[task_id]["status"] = "failed"
|
failed_state = _get_task_progress(task_id) or {}
|
||||||
_task_progress[task_id]["error"] = str(e)
|
failed_state["status"] = "failed"
|
||||||
return JSONResponse(status_code=500, content={"error": f"Internal Error: {str(e)}"})
|
failed_state["error"] = _format_parse_error(e)
|
||||||
|
_store_task_progress(task_id, failed_state)
|
||||||
|
_cleanup_runtime_memory()
|
||||||
|
return JSONResponse(status_code=500, content={"error": _format_parse_error(e)})
|
||||||
|
|
||||||
|
|
||||||
# --- FastAPI 核心应用 ---
|
# --- FastAPI 核心应用 ---
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,15 @@ export interface ParseResult {
|
||||||
export interface ParseProgress {
|
export interface ParseProgress {
|
||||||
progress: number
|
progress: number
|
||||||
stage: string
|
stage: string
|
||||||
status: 'processing' | 'completed' | 'failed'
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
error: string | null
|
error: string | null
|
||||||
file_names: string
|
file_names: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MindmapOrganizeProgress extends ParseProgress {
|
||||||
|
result_md?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const documentApi = {
|
export const documentApi = {
|
||||||
/**
|
/**
|
||||||
* 解析文档
|
* 解析文档
|
||||||
|
|
@ -65,6 +69,9 @@ export const documentApi = {
|
||||||
if (params.server_url) {
|
if (params.server_url) {
|
||||||
formData.append('server_url', params.server_url)
|
formData.append('server_url', params.server_url)
|
||||||
}
|
}
|
||||||
|
if (taskId) {
|
||||||
|
formData.append('task_id', taskId)
|
||||||
|
}
|
||||||
|
|
||||||
return request.post('/api/file_parse', formData, {
|
return request.post('/api/file_parse', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -80,6 +87,12 @@ export const documentApi = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
createParseTask(taskId: string): Promise<ParseProgress> {
|
||||||
|
return request.post(`/api/parse_tasks/${encodeURIComponent(taskId)}`).then(result => {
|
||||||
|
return result as unknown as ParseProgress
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询解析进度
|
* 查询解析进度
|
||||||
*/
|
*/
|
||||||
|
|
@ -87,5 +100,21 @@ export const documentApi = {
|
||||||
return request.get(`/api/parse_progress/${taskId}`).then(result => {
|
return request.get(`/api/parse_progress/${taskId}`).then(result => {
|
||||||
return result as unknown as ParseProgress
|
return result as unknown as ParseProgress
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
createMindmapTask(taskId: string, markdown: string, mode = 'smart', prompt?: string): Promise<MindmapOrganizeProgress> {
|
||||||
|
return request.post(`/api/mindmap_tasks/${encodeURIComponent(taskId)}`, {
|
||||||
|
markdown,
|
||||||
|
mode,
|
||||||
|
prompt
|
||||||
|
}).then(result => {
|
||||||
|
return result as unknown as MindmapOrganizeProgress
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getMindmapProgress(taskId: string): Promise<MindmapOrganizeProgress> {
|
||||||
|
return request.get(`/api/mindmap_progress/${encodeURIComponent(taskId)}`).then(result => {
|
||||||
|
return result as unknown as MindmapOrganizeProgress
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -50,8 +50,6 @@
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<div class="divider"></div>
|
|
||||||
|
|
||||||
<!-- 识别选项 -->
|
<!-- 识别选项 -->
|
||||||
<div class="section-title">{{ $t('config.recognitionOptions') }}</div>
|
<div class="section-title">{{ $t('config.recognitionOptions') }}</div>
|
||||||
|
|
||||||
|
|
@ -104,6 +102,46 @@
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="section-title">导出与智能整理</div>
|
||||||
|
|
||||||
|
<el-form-item label="导出分辨率" class="form-item">
|
||||||
|
<el-select v-model="exportResolutionPreset" style="width: 100%" class="select">
|
||||||
|
<el-option
|
||||||
|
v-for="option in exportResolutionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:label="option.label"
|
||||||
|
:value="option.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-input-number
|
||||||
|
v-if="exportResolutionPreset === 'custom'"
|
||||||
|
v-model="draftConfig.exportResolution"
|
||||||
|
:min="720"
|
||||||
|
:max="16384"
|
||||||
|
:step="100"
|
||||||
|
controls-position="right"
|
||||||
|
class="export-resolution-input"
|
||||||
|
/>
|
||||||
|
<div class="form-item-description">
|
||||||
|
PNG/JPEG 导出长边上限;默认 8K,导出文件会按该最大长边生效。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="智能整理提示词" class="form-item">
|
||||||
|
<el-input
|
||||||
|
v-model="draftConfig.mindmapPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="10"
|
||||||
|
resize="vertical"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
<div class="form-item-description">
|
||||||
|
切换到“智能整理”时发送给后端大模型;默认保留标题结构、总结段落要点,并保持原文语言。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
<div class="config-actions">
|
<div class="config-actions">
|
||||||
<el-button @click="resetDraft">重置</el-button>
|
<el-button @click="resetDraft">重置</el-button>
|
||||||
<el-button type="primary" @click="confirmConfig">确认</el-button>
|
<el-button type="primary" @click="confirmConfig">确认</el-button>
|
||||||
|
|
@ -113,9 +151,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { DocumentConfig } from '@/composables/useDocumentProcessor'
|
import { DEFAULT_DOCUMENT_CONFIG, type DocumentConfig } from '@/composables/useDocumentProcessor'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: DocumentConfig
|
modelValue: DocumentConfig
|
||||||
|
|
@ -126,16 +164,45 @@ interface Props {
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value: DocumentConfig): void
|
(e: 'update:modelValue', value: DocumentConfig): void
|
||||||
(e: 'backendChange', backend: string): void
|
(e: 'backendChange', backend: string): void
|
||||||
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const exportResolutionOptions = [
|
||||||
|
{ label: '1080P', value: 1080 },
|
||||||
|
{ label: '2K', value: 2560 },
|
||||||
|
{ label: '4K', value: 3840 },
|
||||||
|
{ label: '8K', value: 7680 },
|
||||||
|
{ label: '自定义', value: 'custom' }
|
||||||
|
] as const
|
||||||
|
|
||||||
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
const createDraft = (value: DocumentConfig): DocumentConfig => ({ ...value })
|
||||||
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
const draftConfig = reactive<DocumentConfig>(createDraft(props.modelValue))
|
||||||
|
|
||||||
|
const getExportResolutionPreset = (resolution: number): number | 'custom' => {
|
||||||
|
const isPreset = exportResolutionOptions.some((option) => {
|
||||||
|
return typeof option.value === 'number' && option.value === resolution
|
||||||
|
})
|
||||||
|
return isPreset ? resolution : 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportResolutionPreset = ref<number | 'custom'>(getExportResolutionPreset(props.modelValue.exportResolution))
|
||||||
|
|
||||||
|
watch(exportResolutionPreset, (value) => {
|
||||||
|
if (value === 'custom') {
|
||||||
|
if (getExportResolutionPreset(draftConfig.exportResolution) !== 'custom') {
|
||||||
|
draftConfig.exportResolution = 3840
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draftConfig.exportResolution = value
|
||||||
|
})
|
||||||
|
|
||||||
const syncDraft = (value: DocumentConfig) => {
|
const syncDraft = (value: DocumentConfig) => {
|
||||||
Object.assign(draftConfig, createDraft(value))
|
Object.assign(draftConfig, createDraft(value))
|
||||||
|
exportResolutionPreset.value = getExportResolutionPreset(value.exportResolution)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
@ -159,7 +226,12 @@ const onBackendChange = (backend: string) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetDraft = () => {
|
const resetDraft = () => {
|
||||||
syncDraft(props.modelValue)
|
const defaults = createDraft(DEFAULT_DOCUMENT_CONFIG)
|
||||||
|
syncDraft(defaults)
|
||||||
|
emit('update:modelValue', defaults)
|
||||||
|
emit('backendChange', defaults.backend)
|
||||||
|
ElMessage.success('已恢复默认设置')
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmConfig = () => {
|
const confirmConfig = () => {
|
||||||
|
|
@ -167,6 +239,7 @@ const confirmConfig = () => {
|
||||||
emit('update:modelValue', confirmed)
|
emit('update:modelValue', confirmed)
|
||||||
emit('backendChange', confirmed.backend)
|
emit('backendChange', confirmed.backend)
|
||||||
ElMessage.success('设置已确认')
|
ElMessage.success('设置已确认')
|
||||||
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFormulaLabel = (backend: string) => {
|
const getFormulaLabel = (backend: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="markdown-renderer">
|
<div class="markdown-renderer">
|
||||||
<template v-if="content">
|
<template v-if="content">
|
||||||
<div class="render-actions">
|
<div class="content-toolbar render-actions">
|
||||||
<div class="render-actions-left">
|
<div class="render-actions-left">
|
||||||
<el-segmented
|
<el-segmented
|
||||||
v-if="mode === 'markdown'"
|
v-if="mode === 'markdown'"
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="render-actions-right">
|
||||||
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
|
<el-button type="primary" size="small" class="action-button primary" @click="handleDownload()">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
|
|
@ -23,6 +24,7 @@
|
||||||
下载
|
下载
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="render-content">
|
<div class="render-content">
|
||||||
<div v-if="mode === 'markdown'" class="render-shell">
|
<div v-if="mode === 'markdown'" class="render-shell">
|
||||||
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
|
<div ref="markdownRef" class="markdown-content rendered-html" v-html="renderedContent"></div>
|
||||||
|
|
@ -52,11 +54,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="render-shell">
|
<div v-else class="render-shell">
|
||||||
<div class="richtext-stage">
|
<div ref="richtextRef" class="markdown-content rendered-html richtext-content" v-html="renderedContent"></div>
|
||||||
<article ref="richtextRef" class="richtext-document">
|
|
||||||
<div class="markdown-content richtext-content" v-html="renderedContent"></div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -390,22 +388,35 @@ async function handleDownload() {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.render-actions {
|
.content-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: sticky;
|
}
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909C;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions {
|
||||||
|
padding: 0 0 12px;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,6 +425,15 @@ async function handleDownload() {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
@ -447,7 +467,7 @@ async function handleDownload() {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 16px 16px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.render-header {
|
.render-header {
|
||||||
|
|
@ -473,7 +493,7 @@ async function handleDownload() {
|
||||||
.markdown-content {
|
.markdown-content {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 10px;
|
border-radius: 4px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,10 +503,9 @@ async function handleDownload() {
|
||||||
|
|
||||||
.pdf-stage {
|
.pdf-stage {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-radius: 12px;
|
border: 1px solid #E9ECEF;
|
||||||
background:
|
border-radius: 4px;
|
||||||
radial-gradient(circle at top, rgba(22, 93, 255, 0.08), transparent 30%),
|
background: #F8F9FA;
|
||||||
#eef2f7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-preview-pages {
|
.pdf-preview-pages {
|
||||||
|
|
@ -522,17 +541,18 @@ async function handleDownload() {
|
||||||
|
|
||||||
.richtext-stage {
|
.richtext-stage {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 12px;
|
border: 1px solid #E9ECEF;
|
||||||
background: linear-gradient(180deg, #f3f4f6 0%, #eef2f7 100%);
|
border-radius: 4px;
|
||||||
|
background: #F8F9FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
.richtext-document {
|
.richtext-document {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid #dde3ea;
|
border: 1px solid #dde3ea;
|
||||||
border-radius: 12px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.richtext-content {
|
.richtext-content {
|
||||||
|
|
@ -633,9 +653,19 @@ async function handleDownload() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.render-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.render-actions-left {
|
.render-actions-left {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.render-actions-right {
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pdf-page {
|
.pdf-page {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="mindmap-container">
|
<div class="mindmap-container">
|
||||||
<div class="mindmap-actions">
|
<div class="content-toolbar mindmap-actions">
|
||||||
<span class="mindmap-subtitle" v-if="content">已生成 {{ nodeCount }} 个节点</span>
|
<div class="mindmap-actions-left">
|
||||||
|
<span class="content-toolbar-meta">{{ nodeSummary }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mindmap-actions-right">
|
||||||
<el-dropdown @command="handleDownload">
|
<el-dropdown @command="handleDownload">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="small"
|
size="small"
|
||||||
class="action-button primary"
|
class="action-button primary"
|
||||||
|
:disabled="!content"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
|
|
@ -34,6 +38,7 @@
|
||||||
重置视图
|
重置视图
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
<div class="mindmap-content" @wheel.prevent="handleWheel" @click="handleNodeClick">
|
||||||
<div class="empty-state" v-if="!content">
|
<div class="empty-state" v-if="!content">
|
||||||
<el-icon class="empty-icon"><Document /></el-icon>
|
<el-icon class="empty-icon"><Document /></el-icon>
|
||||||
|
|
@ -62,6 +67,10 @@ const props = defineProps({
|
||||||
content: {
|
content: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
|
},
|
||||||
|
exportResolution: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -75,12 +84,66 @@ let renderTimer: number | undefined
|
||||||
const transformer = new Transformer()
|
const transformer = new Transformer()
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
const isRendering = ref(false)
|
const isRendering = ref(false)
|
||||||
|
const totalNodeCount = ref(0)
|
||||||
|
const hiddenNodeCount = ref(0)
|
||||||
|
|
||||||
const nodeCount = computed(() => {
|
type MarkmapNode = {
|
||||||
if (!props.content) return 0
|
content?: string
|
||||||
return (props.content.match(/^#{1,6}\s+/gm) || []).length
|
children?: MarkmapNode[]
|
||||||
|
payload?: {
|
||||||
|
fold?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeSummary = computed(() => {
|
||||||
|
if (!props.content) return '暂无节点'
|
||||||
|
return hiddenNodeCount.value > 0
|
||||||
|
? `已生成 ${totalNodeCount.value} 个节点,隐藏 ${hiddenNodeCount.value} 个节点`
|
||||||
|
: `已生成 ${totalNodeCount.value} 个节点`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateNodeStats = () => {
|
||||||
|
const root = mmInstance?.state?.data as MarkmapNode | undefined
|
||||||
|
if (!root || !props.content) {
|
||||||
|
totalNodeCount.value = 0
|
||||||
|
hiddenNodeCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const countSubtree = (node: MarkmapNode): number => {
|
||||||
|
const children = node.children || []
|
||||||
|
return 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const walkVisible = (node: MarkmapNode): { total: number; hidden: number } => {
|
||||||
|
const children = node.children || []
|
||||||
|
const total = 1 + children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
|
||||||
|
if (node.payload?.fold && children.length > 0) {
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
hidden: children.reduce((sum, child) => sum + countSubtree(child), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childStats = children.reduce(
|
||||||
|
(stats, child) => {
|
||||||
|
const childStat = walkVisible(child)
|
||||||
|
return {
|
||||||
|
total: stats.total + childStat.total,
|
||||||
|
hidden: stats.hidden + childStat.hidden
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ total: 1, hidden: 0 }
|
||||||
|
)
|
||||||
|
return childStats
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = walkVisible(root)
|
||||||
|
totalNodeCount.value = Math.max(0, stats.total - 1)
|
||||||
|
hiddenNodeCount.value = Math.max(0, stats.hidden)
|
||||||
|
}
|
||||||
|
|
||||||
const initMarkmap = async () => {
|
const initMarkmap = async () => {
|
||||||
if (!svgRef.value) return
|
if (!svgRef.value) return
|
||||||
|
|
||||||
|
|
@ -104,11 +167,12 @@ const initMarkmap = async () => {
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const initialExpandLevel = nodeCount.value > 80 ? 2 : -1
|
const sourceNodeCount = (props.content.match(/^#{1,6}\s+/gm) || []).length
|
||||||
|
const initialExpandLevel = sourceNodeCount > 80 ? 2 : -1
|
||||||
|
|
||||||
if (mmInstance) {
|
if (mmInstance) {
|
||||||
if (typeof mmInstance.setData === 'function') {
|
if (typeof mmInstance.setData === 'function') {
|
||||||
mmInstance.setData(root)
|
await mmInstance.setData(root, { initialExpandLevel })
|
||||||
mmInstance.fit()
|
mmInstance.fit()
|
||||||
} else {
|
} else {
|
||||||
mmInstance = Markmap.create(svgRef.value, {
|
mmInstance = Markmap.create(svgRef.value, {
|
||||||
|
|
@ -129,6 +193,7 @@ const initMarkmap = async () => {
|
||||||
svgRef.value?.querySelectorAll('text').forEach((text) => {
|
svgRef.value?.querySelectorAll('text').forEach((text) => {
|
||||||
text.setAttribute('data-editable-node', 'true')
|
text.setAttribute('data-editable-node', 'true')
|
||||||
})
|
})
|
||||||
|
updateNodeStats()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing markmap:', error)
|
console.error('Error initializing markmap:', error)
|
||||||
mmInstance = null
|
mmInstance = null
|
||||||
|
|
@ -176,14 +241,114 @@ function downloadBlob(blob: Blob, fileName: string) {
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function createExportSvg() {
|
type ExportFormat = 'svg' | 'png' | 'jpeg'
|
||||||
|
|
||||||
|
interface ExportBox {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getViewportSize(svg: SVGElement) {
|
||||||
|
const rect = svg.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
width: Math.ceil(Math.max(rect.width, 320)),
|
||||||
|
height: Math.ceil(Math.max(rect.height, 240))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanExportSvg(svg: SVGElement) {
|
||||||
|
if (!svg.getAttribute('xmlns')) {
|
||||||
|
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
||||||
|
}
|
||||||
|
svg.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxFromSvgRect(rect: DOMRect): ExportBox {
|
||||||
|
return {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformBox(box: ExportBox, matrix: DOMMatrix): ExportBox {
|
||||||
|
const points = [
|
||||||
|
new DOMPoint(box.x, box.y).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x, box.y + box.height).matrixTransform(matrix),
|
||||||
|
new DOMPoint(box.x + box.width, box.y + box.height).matrixTransform(matrix)
|
||||||
|
]
|
||||||
|
const xs = points.map((point) => point.x)
|
||||||
|
const ys = points.map((point) => point.y)
|
||||||
|
const minX = Math.min(...xs)
|
||||||
|
const minY = Math.min(...ys)
|
||||||
|
const maxX = Math.max(...xs)
|
||||||
|
const maxY = Math.max(...ys)
|
||||||
|
return {
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntersection(a: ExportBox, b: ExportBox): ExportBox | null {
|
||||||
|
const x = Math.max(a.x, b.x)
|
||||||
|
const y = Math.max(a.y, b.y)
|
||||||
|
const right = Math.min(a.x + a.width, b.x + b.width)
|
||||||
|
const bottom = Math.min(a.y + a.height, b.y + b.height)
|
||||||
|
if (right <= x || bottom <= y) return null
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: right - x,
|
||||||
|
height: bottom - y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function padBox(box: ExportBox, padding: number, boundary?: ExportBox): ExportBox {
|
||||||
|
const padded = {
|
||||||
|
x: box.x - padding,
|
||||||
|
y: box.y - padding,
|
||||||
|
width: box.width + padding * 2,
|
||||||
|
height: box.height + padding * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!boundary) return padded
|
||||||
|
const clipped = getIntersection(padded, boundary)
|
||||||
|
return clipped || box
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBox(box: ExportBox): ExportBox {
|
||||||
|
const x = Math.min(box.x, box.x + box.width)
|
||||||
|
const y = Math.min(box.y, box.y + box.height)
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.abs(box.width),
|
||||||
|
height: Math.abs(box.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCompleteInViewport(contentBox: ExportBox, viewportBox: ExportBox) {
|
||||||
|
const tolerance = 4
|
||||||
|
return (
|
||||||
|
contentBox.x >= viewportBox.x - tolerance &&
|
||||||
|
contentBox.y >= viewportBox.y - tolerance &&
|
||||||
|
contentBox.x + contentBox.width <= viewportBox.x + viewportBox.width + tolerance &&
|
||||||
|
contentBox.y + contentBox.height <= viewportBox.y + viewportBox.height + tolerance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFullExportSvg() {
|
||||||
const svg = svgRef.value
|
const svg = svgRef.value
|
||||||
if (!svg) return null
|
if (!svg) return null
|
||||||
|
|
||||||
const svgCopy = svg.cloneNode(true) as SVGElement
|
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||||
if (!svgCopy.getAttribute('xmlns')) {
|
cleanExportSvg(svgCopy)
|
||||||
svgCopy.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentGroup = svg.querySelector('g')
|
const contentGroup = svg.querySelector('g')
|
||||||
const bbox = contentGroup
|
const bbox = contentGroup
|
||||||
|
|
@ -195,10 +360,53 @@ function createExportSvg() {
|
||||||
const height = Math.ceil(Math.max(bbox.height + padding * 2, 240))
|
const height = Math.ceil(Math.max(bbox.height + padding * 2, 240))
|
||||||
const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}`
|
const viewBox = `${Math.floor(bbox.x - padding)} ${Math.floor(bbox.y - padding)} ${width} ${height}`
|
||||||
|
|
||||||
|
const viewportGroupCopy = svgCopy.querySelector('g')
|
||||||
|
viewportGroupCopy?.removeAttribute('transform')
|
||||||
svgCopy.setAttribute('viewBox', viewBox)
|
svgCopy.setAttribute('viewBox', viewBox)
|
||||||
svgCopy.setAttribute('width', String(width))
|
svgCopy.setAttribute('width', String(width))
|
||||||
svgCopy.setAttribute('height', String(height))
|
svgCopy.setAttribute('height', String(height))
|
||||||
svgCopy.querySelectorAll('[style*="cursor"]').forEach((node) => node.removeAttribute('style'))
|
|
||||||
|
return {
|
||||||
|
data: new XMLSerializer().serializeToString(svgCopy),
|
||||||
|
width,
|
||||||
|
height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createVisibleExportSvg() {
|
||||||
|
const svg = svgRef.value
|
||||||
|
if (!svg) return null
|
||||||
|
|
||||||
|
const contentGroup = svg.querySelector('g') as SVGGElement | null
|
||||||
|
if (!contentGroup) return createFullExportSvg()
|
||||||
|
|
||||||
|
const viewport = getViewportSize(svg)
|
||||||
|
const viewportBox = { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
||||||
|
const ctm = contentGroup.getCTM()
|
||||||
|
if (!ctm) return createFullExportSvg()
|
||||||
|
|
||||||
|
const contentBox = transformBox(getBoxFromSvgRect(contentGroup.getBBox()), ctm)
|
||||||
|
if (isCompleteInViewport(contentBox, viewportBox)) {
|
||||||
|
return createFullExportSvg()
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgCopy = svg.cloneNode(true) as SVGElement
|
||||||
|
cleanExportSvg(svgCopy)
|
||||||
|
|
||||||
|
const screenVisibleBox = padBox(getIntersection(contentBox, viewportBox) || viewportBox, 24, viewportBox)
|
||||||
|
const localVisibleBox = normalizeBox(transformBox(screenVisibleBox, ctm.inverse()))
|
||||||
|
const localContentBox = getBoxFromSvgRect(contentGroup.getBBox())
|
||||||
|
const clippedLocalBox = getIntersection(localVisibleBox, localContentBox) || localVisibleBox
|
||||||
|
const visibleBox = padBox(clippedLocalBox, 24)
|
||||||
|
const width = Math.ceil(Math.max(visibleBox.width, 320))
|
||||||
|
const height = Math.ceil(Math.max(visibleBox.height, 240))
|
||||||
|
const viewBox = `${Math.floor(visibleBox.x)} ${Math.floor(visibleBox.y)} ${width} ${height}`
|
||||||
|
|
||||||
|
const viewportGroupCopy = svgCopy.querySelector('g')
|
||||||
|
viewportGroupCopy?.removeAttribute('transform')
|
||||||
|
svgCopy.setAttribute('width', String(width))
|
||||||
|
svgCopy.setAttribute('height', String(height))
|
||||||
|
svgCopy.setAttribute('viewBox', viewBox)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: new XMLSerializer().serializeToString(svgCopy),
|
data: new XMLSerializer().serializeToString(svgCopy),
|
||||||
|
|
@ -208,8 +416,8 @@ function createExportSvg() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载思维导图
|
// 下载思维导图
|
||||||
const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
const downloadMindMap = async (format: ExportFormat = 'svg') => {
|
||||||
const exportSvg = createExportSvg()
|
const exportSvg = createVisibleExportSvg()
|
||||||
if (!exportSvg) return
|
if (!exportSvg) return
|
||||||
|
|
||||||
const time = new Date().getTime()
|
const time = new Date().getTime()
|
||||||
|
|
@ -218,8 +426,12 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxCanvasPixels = 16_000_000
|
const baseScale = 3
|
||||||
const scaleFactor = Math.min(3, Math.max(1, Math.sqrt(maxCanvasPixels / (exportSvg.width * exportSvg.height))))
|
const maxResolution = Number(props.exportResolution) || 0
|
||||||
|
const longEdge = Math.max(exportSvg.width, exportSvg.height)
|
||||||
|
const scaleFactor = maxResolution > 0
|
||||||
|
? Math.min(baseScale, Math.max(0.1, maxResolution / longEdge))
|
||||||
|
: baseScale
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
|
|
@ -245,6 +457,10 @@ const downloadMindMap = async (format: 'svg' | 'png' | 'jpeg' = 'svg') => {
|
||||||
|
|
||||||
// 处理下载命令
|
// 处理下载命令
|
||||||
const handleDownload = (command: string) => {
|
const handleDownload = (command: string) => {
|
||||||
|
if (!props.content) {
|
||||||
|
ElMessage.warning('暂无思维导图内容可下载')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
if (command === 'svg' || command === 'png' || command === 'jpeg') {
|
||||||
downloadMindMap(command)
|
downloadMindMap(command)
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +470,7 @@ const handleDownload = (command: string) => {
|
||||||
const resetView = () => {
|
const resetView = () => {
|
||||||
mmInstance?.fit()
|
mmInstance?.fit()
|
||||||
scale.value = 1
|
scale.value = 1
|
||||||
|
window.setTimeout(updateNodeStats, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 放大
|
// 放大
|
||||||
|
|
@ -292,6 +509,7 @@ const handleWheel = (event: WheelEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNodeClick = async (event: MouseEvent) => {
|
const handleNodeClick = async (event: MouseEvent) => {
|
||||||
|
window.setTimeout(updateNodeStats, 0)
|
||||||
const textNode = (event.target as Element | null)?.closest?.('text')
|
const textNode = (event.target as Element | null)?.closest?.('text')
|
||||||
const oldText = textNode?.textContent?.trim()
|
const oldText = textNode?.textContent?.trim()
|
||||||
if (!oldText || !props.content || oldText === '文档思维导图') return
|
if (!oldText || !props.content || oldText === '文档思维导图') return
|
||||||
|
|
@ -319,28 +537,52 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #FFFFFF;
|
background-color: transparent;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-actions {
|
.content-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 16px;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-subtitle {
|
.mindmap-actions {
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-left,
|
||||||
|
.mindmap-actions-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindmap-actions-right {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #86909C;
|
color: #86909C;
|
||||||
background-color: #F2F3F5;
|
white-space: nowrap;
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
@ -382,6 +624,8 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
background-color: #F9FAFC;
|
background-color: #F9FAFC;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
border: 1px solid #E9ECEF;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-content:active {
|
.mindmap-content:active {
|
||||||
|
|
@ -449,16 +693,18 @@ const handleNodeClick = async (event: MouseEvent) => {
|
||||||
|
|
||||||
/* 响应式设计 */
|
/* 响应式设计 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.mindmap-header {
|
.mindmap-actions {
|
||||||
padding: 12px 16px;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-title {
|
.mindmap-actions-left,
|
||||||
font-size: 14px;
|
.mindmap-actions-right {
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-subtitle {
|
.content-toolbar-meta {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, watch } from 'vue'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import { documentApi, type ParseParams } from '@/api/document'
|
import { documentApi, type ParseParams } from '@/api/document'
|
||||||
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
import { convertWordToPdf, isWordFile } from '@/utils/wordToPdf'
|
||||||
|
|
@ -13,6 +13,8 @@ export interface DocumentConfig {
|
||||||
maxPages: number
|
maxPages: number
|
||||||
backend: string
|
backend: string
|
||||||
serverUrl: string
|
serverUrl: string
|
||||||
|
exportResolution: number
|
||||||
|
mindmapPrompt: string
|
||||||
tableEnable: boolean
|
tableEnable: boolean
|
||||||
formulaEnable: boolean
|
formulaEnable: boolean
|
||||||
language: string
|
language: string
|
||||||
|
|
@ -26,6 +28,42 @@ export interface ProcessResult {
|
||||||
downloadUrl?: string
|
downloadUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DOCUMENT_CONFIG_STORAGE_KEY = 'mineru.documentProcessor.config.v4'
|
||||||
|
|
||||||
|
export const DEFAULT_MINDMAP_PROMPT = `你是文档结构整理助手。请基于用户提供的 Markdown 生成适合思维导图展示的 Markdown。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 保留原文标题结构,不要重写或打乱主要标题层级。
|
||||||
|
2. 将标题下的段落内容总结为要点,合并相近段落,避免逐段照抄。
|
||||||
|
3. 保留原文语言:英文内容输出英文,中文内容输出中文,多语言内容按原文语言分别保留。
|
||||||
|
4. 不要编造原文没有的信息。
|
||||||
|
5. 保留关键数字、公式、专有名词、步骤和结论。
|
||||||
|
6. 最大层级不超过 4 层。
|
||||||
|
7. 每个父节点下最多 8 个子节点。
|
||||||
|
8. 节点标题尽量简短,正文说明使用短句列表。
|
||||||
|
9. 只输出 Markdown,不要输出解释、代码块围栏或额外说明。`
|
||||||
|
|
||||||
|
export const DEFAULT_DOCUMENT_CONFIG: DocumentConfig = {
|
||||||
|
maxPages: 1000,
|
||||||
|
backend: 'hybrid-auto-engine',
|
||||||
|
serverUrl: 'http://localhost:30000',
|
||||||
|
exportResolution: 7680,
|
||||||
|
mindmapPrompt: DEFAULT_MINDMAP_PROMPT,
|
||||||
|
tableEnable: true,
|
||||||
|
formulaEnable: true,
|
||||||
|
language: 'ch',
|
||||||
|
forceOcr: false
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCachedConfig(): Partial<DocumentConfig> {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(DOCUMENT_CONFIG_STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useDocumentProcessor() {
|
export function useDocumentProcessor() {
|
||||||
// 文件相关
|
// 文件相关
|
||||||
const uploadedFiles: Ref<File[]> = ref([])
|
const uploadedFiles: Ref<File[]> = ref([])
|
||||||
|
|
@ -33,15 +71,22 @@ export function useDocumentProcessor() {
|
||||||
|
|
||||||
// 配置相关
|
// 配置相关
|
||||||
const config = reactive<DocumentConfig>({
|
const config = reactive<DocumentConfig>({
|
||||||
maxPages: 1000,
|
...DEFAULT_DOCUMENT_CONFIG,
|
||||||
backend: 'hybrid-auto-engine',
|
...loadCachedConfig()
|
||||||
serverUrl: 'http://localhost:30000',
|
|
||||||
tableEnable: true,
|
|
||||||
formulaEnable: true,
|
|
||||||
language: 'ch',
|
|
||||||
forceOcr: false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
config,
|
||||||
|
(value) => {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用本地存储时不影响当前页面使用。
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
// 结果相关
|
// 结果相关
|
||||||
const results = ref<ProcessResult | null>(null)
|
const results = ref<ProcessResult | null>(null)
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
|
|
@ -82,36 +127,61 @@ export function useDocumentProcessor() {
|
||||||
if (!files || files.length === 0) return
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
const validFiles: File[] = []
|
const validFiles: File[] = []
|
||||||
|
const file = files[0]
|
||||||
|
uploadedFiles.value = []
|
||||||
|
results.value = null
|
||||||
|
error.value = null
|
||||||
|
if (files.length > 1) {
|
||||||
|
error.value = '一次只能选择一个文件,已使用第一个文件'
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i]
|
|
||||||
const fileType = file.type
|
const fileType = file.type
|
||||||
const fileName = file.name.toLowerCase()
|
const fileName = file.name.toLowerCase()
|
||||||
|
|
||||||
console.log('Processing file:', fileName, 'type:', fileType)
|
console.log('Processing file:', fileName, 'type:', fileType)
|
||||||
|
|
||||||
// 验证文件类型 - 支持 PDF、图片和 Word 文档
|
// 验证文件类型 - 支持 Markdown、PDF、Word 和后端可转换为 PDF 的图片格式
|
||||||
const isImage = fileType.startsWith('image/')
|
const supportedImageExtensions = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.tiff', '.gif', '.jp2']
|
||||||
|
const isImage = fileType.startsWith('image/') && supportedImageExtensions.some((ext) => fileName.endsWith(ext))
|
||||||
const isPdf = fileType === 'application/pdf'
|
const isPdf = fileType === 'application/pdf'
|
||||||
const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
const isWord = fileName.endsWith('.docx') || fileName.endsWith('.doc') || fileType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
const isMarkdown = fileName.endsWith('.md') || fileType === 'text/markdown'
|
||||||
|
|
||||||
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord)
|
console.log('File type checks - isImage:', isImage, 'isPdf:', isPdf, 'isWord:', isWord, 'isMarkdown:', isMarkdown)
|
||||||
|
|
||||||
if (!isImage && !isPdf && !isWord) {
|
if (!isImage && !isPdf && !isWord && !isMarkdown) {
|
||||||
error.value = '不支持的文件类型'
|
error.value = '不支持的文件类型,请选择 MD、PDF、Word、JPG、PNG、WEBP、BMP、TIFF、GIF 或 JP2 文件'
|
||||||
console.log('Unsupported file type:', fileName, fileType)
|
console.log('Unsupported file type:', fileName, fileType)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证文件大小 (100MB)
|
// 验证文件大小 (100MB)
|
||||||
if (file.size > 100 * 1024 * 1024) {
|
if (file.size > 100 * 1024 * 1024) {
|
||||||
error.value = '文件大小超出限制'
|
error.value = '文件大小超出限制'
|
||||||
console.log('File too large:', fileName, file.size)
|
console.log('File too large:', fileName, file.size)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是 Word 文档,转换为 PDF
|
if (isMarkdown) {
|
||||||
if (isWord) {
|
try {
|
||||||
|
isUploading.value = true
|
||||||
|
const markdown = await file.text()
|
||||||
|
validFiles.push(file)
|
||||||
|
results.value = {
|
||||||
|
markdown,
|
||||||
|
source: markdown,
|
||||||
|
mindmap: buildMindmapMarkdown(markdown)
|
||||||
|
}
|
||||||
|
error.value = null
|
||||||
|
console.log('Markdown file loaded directly:', fileName, markdown.length)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = 'Markdown 文件读取失败: ' + (err as Error).message
|
||||||
|
console.error('Markdown read failed:', (err as Error).message)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
} else if (isWord) {
|
||||||
try {
|
try {
|
||||||
isUploading.value = true
|
isUploading.value = true
|
||||||
error.value = '正在将 Word 文档转换为 PDF...'
|
error.value = '正在将 Word 文档转换为 PDF...'
|
||||||
|
|
@ -123,7 +193,7 @@ export function useDocumentProcessor() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
|
error.value = 'Word 转换为 PDF 失败: ' + (err as Error).message
|
||||||
console.error('Conversion failed:', (err as Error).message)
|
console.error('Conversion failed:', (err as Error).message)
|
||||||
continue
|
return
|
||||||
} finally {
|
} finally {
|
||||||
isUploading.value = false
|
isUploading.value = false
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +201,6 @@ export function useDocumentProcessor() {
|
||||||
validFiles.push(file)
|
validFiles.push(file)
|
||||||
console.log('Adding file directly:', fileName)
|
console.log('Adding file directly:', fileName)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
uploadedFiles.value = validFiles
|
uploadedFiles.value = validFiles
|
||||||
console.log('Final uploaded files:', validFiles.map(f => f.name))
|
console.log('Final uploaded files:', validFiles.map(f => f.name))
|
||||||
|
|
@ -194,15 +263,24 @@ export function useDocumentProcessor() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const firstFileName = uploadedFiles.value[0]?.name.toLowerCase() || ''
|
||||||
|
if (firstFileName.endsWith('.md')) {
|
||||||
|
if (!results.value) {
|
||||||
|
error.value = 'Markdown 文件内容为空,请重新上传'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
isProcessing.value = true
|
isProcessing.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
processingStage.value = '准备提交解析任务'
|
processingStage.value = '准备提交解析任务'
|
||||||
|
|
||||||
// 生成任务ID
|
// 生成任务ID
|
||||||
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
startProgressPolling(taskId)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await documentApi.createParseTask(taskId)
|
||||||
|
startProgressPolling(taskId)
|
||||||
processingStage.value = '提交文档到解析服务'
|
processingStage.value = '提交文档到解析服务'
|
||||||
const params: ParseParams = {
|
const params: ParseParams = {
|
||||||
files: uploadedFiles.value,
|
files: uploadedFiles.value,
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,156 @@
|
||||||
const MAX_NODE_TEXT_LENGTH = 72
|
import MarkdownIt from 'markdown-it'
|
||||||
const MAX_PARAGRAPH_SUMMARY_LENGTH = 90
|
import type Token from 'markdown-it/lib/token.mjs'
|
||||||
const MAX_CHILDREN_PER_HEADING = 18
|
|
||||||
const ROOT_TITLE = '文档思维导图'
|
const ROOT_TITLE = '文档思维导图'
|
||||||
|
const MAX_HEADING_LEVEL = 6
|
||||||
|
|
||||||
type BlockType = 'paragraph' | 'list' | 'table' | 'code' | 'math' | 'image'
|
const md = new MarkdownIt({
|
||||||
|
html: true,
|
||||||
|
linkify: true,
|
||||||
|
typographer: true,
|
||||||
|
breaks: true
|
||||||
|
})
|
||||||
|
|
||||||
interface HeadingState {
|
function normalizeNodeText(text: string) {
|
||||||
level: number
|
return text
|
||||||
childCount: number
|
.replace(/\r?\n/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWhitespace(text: string) {
|
function clampHeadingLevel(level: number) {
|
||||||
return text.replace(/\s+/g, ' ').trim()
|
return Math.min(Math.max(level, 1), MAX_HEADING_LEVEL)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripMarkdownInline(text: string) {
|
function pushHeading(result: string[], level: number, text: string) {
|
||||||
return normalizeWhitespace(text)
|
const normalized = normalizeNodeText(text)
|
||||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1')
|
if (!normalized) return
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
result.push(`${'#'.repeat(clampHeadingLevel(level))} ${normalized}`)
|
||||||
.replace(/[*_`~>#]/g, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncateText(text: string, maxLength = MAX_NODE_TEXT_LENGTH) {
|
function getHeadingLevel(token: Token) {
|
||||||
const normalized = normalizeWhitespace(text)
|
const tagLevel = Number(token.tag.replace(/^h/i, ''))
|
||||||
if (normalized.length <= maxLength) return normalized
|
return Number.isFinite(tagLevel) && tagLevel > 0 ? tagLevel : 1
|
||||||
return `${normalized.slice(0, maxLength - 1)}...`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeParagraph(text: string) {
|
function getNextInlineContent(tokens: Token[], index: number) {
|
||||||
const cleaned = stripMarkdownInline(text)
|
const next = tokens[index + 1]
|
||||||
const sentence = cleaned.match(/^(.+?[。!?!?;;::]|\S.{20,}?[,,])/)
|
return next?.type === 'inline' ? next.content : ''
|
||||||
return truncateText(sentence?.[1] || cleaned, MAX_PARAGRAPH_SUMMARY_LENGTH)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeading(line: string) {
|
function getFenceContent(token: Token) {
|
||||||
return /^#{1,6}\s+/.test(line.trim())
|
const info = normalizeNodeText(token.info || '')
|
||||||
|
const content = token.content.trim()
|
||||||
|
if (!content) return info ? `代码块 ${info}` : '代码块'
|
||||||
|
return info ? `代码块 ${info}: ${content}` : `代码块: ${content}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeadingLevel(line: string) {
|
function collectTableRows(tokens: Token[], startIndex: number) {
|
||||||
return line.trim().match(/^#{1,6}/)?.[0].length || 0
|
const rows: string[][] = []
|
||||||
|
let currentRow: string[] | null = null
|
||||||
|
let endIndex = startIndex
|
||||||
|
|
||||||
|
for (let i = startIndex + 1; i < tokens.length; i += 1) {
|
||||||
|
const token = tokens[i]
|
||||||
|
endIndex = i
|
||||||
|
|
||||||
|
if (token.type === 'table_close') break
|
||||||
|
if (token.type === 'tr_open') {
|
||||||
|
currentRow = []
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (token.type === 'tr_close') {
|
||||||
|
if (currentRow && currentRow.some((cell) => cell.trim())) {
|
||||||
|
rows.push(currentRow)
|
||||||
|
}
|
||||||
|
currentRow = null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ((token.type === 'th_open' || token.type === 'td_open') && currentRow) {
|
||||||
|
currentRow.push(getNextInlineContent(tokens, i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHeadingText(line: string) {
|
return { rows, endIndex }
|
||||||
return stripMarkdownInline(line.trim().replace(/^#{1,6}\s+/, ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getListText(line: string) {
|
|
||||||
return stripMarkdownInline(line.trim().replace(/^[-*+]\s+|^\d+[.)、]\s+/, ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
function countHeadingChildren(stack: HeadingState[], level: number) {
|
|
||||||
while (stack.length && stack[stack.length - 1].level >= level) {
|
|
||||||
stack.pop()
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = stack[stack.length - 1]
|
|
||||||
if (!parent) return true
|
|
||||||
parent.childCount += 1
|
|
||||||
return parent.childCount <= MAX_CHILDREN_PER_HEADING
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushBlock(result: string[], stack: HeadingState[], blockType: BlockType, text: string) {
|
|
||||||
const cleaned = text.trim()
|
|
||||||
if (!cleaned) return
|
|
||||||
|
|
||||||
const parentLevel = stack.length ? stack[stack.length - 1].level : 1
|
|
||||||
const level = Math.min(parentLevel + 1, 6)
|
|
||||||
if (!countHeadingChildren(stack, level)) return
|
|
||||||
|
|
||||||
const prefixMap: Record<BlockType, string> = {
|
|
||||||
paragraph: '摘要',
|
|
||||||
list: '要点',
|
|
||||||
table: '表格',
|
|
||||||
code: '代码',
|
|
||||||
math: '公式',
|
|
||||||
image: '图片'
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(`${'#'.repeat(level)} ${prefixMap[blockType]}:${cleaned}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildMindmapMarkdown(markdown: string) {
|
export function buildMindmapMarkdown(markdown: string) {
|
||||||
const lines = markdown.split(/\r?\n/)
|
if (!markdown.trim()) return ''
|
||||||
|
|
||||||
|
const tokens = md.parse(markdown, {})
|
||||||
const result: string[] = [`# ${ROOT_TITLE}`]
|
const result: string[] = [`# ${ROOT_TITLE}`]
|
||||||
const stack: HeadingState[] = [{ level: 1, childCount: 0 }]
|
let currentHeadingLevel = 1
|
||||||
let paragraphBuffer: string[] = []
|
let listDepth = 0
|
||||||
let inCodeBlock = false
|
|
||||||
let codeBlockTitle = ''
|
|
||||||
|
|
||||||
const flushParagraph = () => {
|
for (let i = 0; i < tokens.length; i += 1) {
|
||||||
if (paragraphBuffer.length === 0) return
|
const token = tokens[i]
|
||||||
const text = summarizeParagraph(paragraphBuffer.join(' '))
|
|
||||||
pushBlock(result, stack, 'paragraph', text)
|
|
||||||
paragraphBuffer = []
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lines) {
|
if (token.type === 'heading_open') {
|
||||||
const trimmed = line.trim()
|
currentHeadingLevel = clampHeadingLevel(getHeadingLevel(token) + 1)
|
||||||
|
pushHeading(result, currentHeadingLevel, getNextInlineContent(tokens, i) || '未命名章节')
|
||||||
if (/^```/.test(trimmed)) {
|
i += 2
|
||||||
if (inCodeBlock) {
|
|
||||||
pushBlock(result, stack, 'code', codeBlockTitle || '代码块')
|
|
||||||
codeBlockTitle = ''
|
|
||||||
} else {
|
|
||||||
flushParagraph()
|
|
||||||
codeBlockTitle = trimmed.replace(/^```/, '').trim()
|
|
||||||
}
|
|
||||||
inCodeBlock = !inCodeBlock
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inCodeBlock) continue
|
if (token.type === 'bullet_list_open' || token.type === 'ordered_list_open') {
|
||||||
|
listDepth += 1
|
||||||
if (!trimmed) {
|
|
||||||
flushParagraph()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHeading(trimmed)) {
|
if (token.type === 'bullet_list_close' || token.type === 'ordered_list_close') {
|
||||||
flushParagraph()
|
listDepth = Math.max(0, listDepth - 1)
|
||||||
const rawLevel = getHeadingLevel(trimmed)
|
|
||||||
const level = Math.min(rawLevel + 1, 6)
|
|
||||||
while (stack.length && stack[stack.length - 1].level >= level) {
|
|
||||||
stack.pop()
|
|
||||||
}
|
|
||||||
result.push(`${'#'.repeat(level)} ${truncateText(getHeadingText(trimmed)) || '未命名章节'}`)
|
|
||||||
stack.push({ level, childCount: 0 })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\|.+\|$/.test(trimmed)) {
|
if (token.type === 'paragraph_open') {
|
||||||
flushParagraph()
|
const level = currentHeadingLevel + Math.max(listDepth, 1)
|
||||||
pushBlock(result, stack, 'table', '表格内容')
|
pushHeading(result, level, getNextInlineContent(tokens, i))
|
||||||
|
i += 2
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^!\[[^\]]*]\([^)]+\)/.test(trimmed)) {
|
if (token.type === 'fence' || token.type === 'code_block') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, getFenceContent(token))
|
||||||
pushBlock(result, stack, 'image', stripMarkdownInline(trimmed) || '图片')
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^\$\$.*\$\$$/.test(trimmed) || /^\$[^$].*\$$/.test(trimmed)) {
|
if (token.type === 'html_block') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, token.content)
|
||||||
pushBlock(result, stack, 'math', truncateText(trimmed, 80))
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (/^[-*+]\s+|^\d+[.)、]\s+/.test(trimmed)) {
|
if (token.type === 'blockquote_open') {
|
||||||
flushParagraph()
|
pushHeading(result, currentHeadingLevel + 1, '引用')
|
||||||
pushBlock(result, stack, 'list', truncateText(getListText(trimmed)))
|
currentHeadingLevel = clampHeadingLevel(currentHeadingLevel + 1)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
paragraphBuffer.push(trimmed)
|
if (token.type === 'blockquote_close') {
|
||||||
|
currentHeadingLevel = Math.max(1, currentHeadingLevel - 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'hr') {
|
||||||
|
pushHeading(result, currentHeadingLevel + 1, '---')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token.type === 'table_open') {
|
||||||
|
const tableLevel = currentHeadingLevel + 1
|
||||||
|
const { rows, endIndex } = collectTableRows(tokens, i)
|
||||||
|
pushHeading(result, tableLevel, '表格')
|
||||||
|
rows.forEach((row) => {
|
||||||
|
pushHeading(result, tableLevel + 1, row.map(normalizeNodeText).join(' | '))
|
||||||
|
})
|
||||||
|
i = endIndex
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flushParagraph()
|
|
||||||
return result.join('\n')
|
return result.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceFirstMindmapText(markdown: string, oldText: string, newText: string) {
|
export function replaceFirstMindmapText(markdown: string, oldText: string, newText: string) {
|
||||||
const normalizedOld = oldText.replace(/^(摘要|要点|表格|代码|公式|图片):/, '').trim()
|
const normalizedOld = oldText.replace(/^(引用|表格|代码块)(\s|:|:)?/, '').trim()
|
||||||
if (!normalizedOld || !newText.trim()) return markdown
|
if (!normalizedOld || !newText.trim()) return markdown
|
||||||
|
|
||||||
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escaped = normalizedOld.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
|
@ -171,7 +160,7 @@ export function replaceFirstMindmapText(markdown: string, oldText: string, newTe
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = markdown.split(/\r?\n/)
|
const lines = markdown.split(/\r?\n/)
|
||||||
const targetLineIndex = lines.findIndex((line) => stripMarkdownInline(line).includes(normalizedOld))
|
const targetLineIndex = lines.findIndex((line) => normalizeNodeText(line).includes(normalizedOld))
|
||||||
if (targetLineIndex >= 0) {
|
if (targetLineIndex >= 0) {
|
||||||
lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim())
|
lines[targetLineIndex] = lines[targetLineIndex].replace(normalizedOld, newText.trim())
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ request.interceptors.response.use(
|
||||||
(error) => {
|
(error) => {
|
||||||
// 统一错误处理
|
// 统一错误处理
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
|
const serverMessage = error.response.data?.error || error.response.data?.detail
|
||||||
|
if (serverMessage) {
|
||||||
|
error.message = serverMessage
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 400:
|
case 400:
|
||||||
error.message = '请求参数错误'
|
error.message = '请求参数错误'
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,15 @@
|
||||||
@dragleave="isDragging = false"
|
@dragleave="isDragging = false"
|
||||||
@click="triggerUpload"
|
@click="triggerUpload"
|
||||||
>
|
>
|
||||||
<div class="upload-content" v-show="!isUploadAreaCollapsed">
|
<div class="upload-content" v-if="uploadedFiles.length === 0">
|
||||||
<el-icon class="upload-icon"><Folder /></el-icon>
|
<el-icon class="upload-icon"><Folder /></el-icon>
|
||||||
|
<div class="upload-copy">
|
||||||
<div class="upload-text">文件导入</div>
|
<div class="upload-text">文件导入</div>
|
||||||
<div class="upload-hint">支持 PDF、Word、PNG 格式文件,可转换为 Markdown 和思维导图,也可手动粘贴 Markdown 源码</div>
|
<div class="upload-hint">支持 MD、PDF、Word、JPG、PNG、BMP、TIFF 等格式文件</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="uploaded-files">
|
<div class="uploaded-files" v-else>
|
||||||
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
|
<div class="file-item" v-for="(file, index) in uploadedFiles" :key="index">
|
||||||
<el-icon class="file-icon"><Document /></el-icon>
|
<el-icon class="file-icon"><Document /></el-icon>
|
||||||
<span class="file-name">{{ file.name }}</span>
|
<span class="file-name">{{ file.name }}</span>
|
||||||
|
|
@ -33,14 +35,14 @@
|
||||||
@click.stop="removeFile(index)"
|
@click.stop="removeFile(index)"
|
||||||
class="remove-button"
|
class="remove-button"
|
||||||
/>
|
/>
|
||||||
</div>
|
<el-button
|
||||||
</div>
|
type="primary"
|
||||||
|
:loading="isProcessing || isUploading"
|
||||||
<div class="upload-actions" v-if="uploadedFiles.length > 0">
|
@click.stop="handleProcessDocument"
|
||||||
<el-button type="primary" :loading="isProcessing || isUploading" @click.stop="handleProcessDocument">
|
>
|
||||||
开始转换
|
开始转换
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button @click.stop="clearAllFiles">清空</el-button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -52,10 +54,12 @@
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
v-model="config"
|
:model-value="config"
|
||||||
:backend-options="backendOptions"
|
:backend-options="backendOptions"
|
||||||
:language-options="languageOptions"
|
:language-options="languageOptions"
|
||||||
|
@update:model-value="applyConfig"
|
||||||
@backend-change="handleBackendChange"
|
@backend-change="handleBackendChange"
|
||||||
|
@close="closeSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -136,7 +140,28 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="activeTab === 'source'" class="source-content">
|
<div v-show="activeTab === 'source'" class="source-content">
|
||||||
<div class="source-toolbar">
|
<div class="content-toolbar source-toolbar">
|
||||||
|
<div class="content-toolbar-info source-mode-toolbar">
|
||||||
|
<el-segmented
|
||||||
|
v-model="sourceContentMode"
|
||||||
|
:options="sourceContentModeOptions"
|
||||||
|
size="small"
|
||||||
|
:disabled="sourceViewMode === 'ast' || isSmartOrganizing"
|
||||||
|
/>
|
||||||
|
<span v-if="isSmartOrganizing" class="smart-organize-status">
|
||||||
|
{{ smartOrganizeStage || '智能整理中' }} {{ smartOrganizeProgress }}%
|
||||||
|
</span>
|
||||||
|
<span v-else-if="smartOrganizeError" class="template-error-inline">
|
||||||
|
{{ smartOrganizeError }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="sourceContentMode === 'smart' && smartMarkdownContent" class="smart-organize-status">
|
||||||
|
智能整理结果
|
||||||
|
</span>
|
||||||
|
<span v-else-if="templateRenderError" class="template-error-inline">
|
||||||
|
{{ templateRenderError }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="content-toolbar-actions">
|
||||||
<el-button
|
<el-button
|
||||||
:type="hasActiveReplacementRules ? 'primary' : 'default'"
|
:type="hasActiveReplacementRules ? 'primary' : 'default'"
|
||||||
class="replacement-config-button"
|
class="replacement-config-button"
|
||||||
|
|
@ -144,8 +169,6 @@
|
||||||
>
|
>
|
||||||
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
|
{{ hasActiveReplacementRules ? `动态模板渲染(已配置${activeReplacementRuleCount}条)` : '动态模板渲染' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<div v-if="templateRenderError" class="template-error-inline">
|
|
||||||
{{ templateRenderError }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -163,7 +186,11 @@
|
||||||
|
|
||||||
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
<div v-show="activeTab === 'mindmap'" class="mindmap-content">
|
||||||
<div class="mindmap-box">
|
<div class="mindmap-box">
|
||||||
<MindMapRenderer :content="renderedMindmapContent" @node-edit="handleMindmapNodeEdit" />
|
<MindMapRenderer
|
||||||
|
:content="renderedMindmapContent"
|
||||||
|
:export-resolution="config.exportResolution"
|
||||||
|
@node-edit="handleMindmapNodeEdit"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,8 +201,7 @@
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
accept=".md,.pdf,.doc,.docx,.png,.jpg,.jpeg,.webp,.bmp,.tiff,.gif,.jp2"
|
||||||
accept=".pdf,.doc,.docx,.png"
|
|
||||||
style="position: absolute; width: 0; height: 0; overflow: hidden;"
|
style="position: absolute; width: 0; height: 0; overflow: hidden;"
|
||||||
@change="handleFileInputChange"
|
@change="handleFileInputChange"
|
||||||
/>
|
/>
|
||||||
|
|
@ -219,17 +245,48 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { Delete, Document, Folder, Close, Loading, ArrowDown } from '@element-plus/icons-vue'
|
import { Delete, Document, Folder, Close, Loading, ArrowDown } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import ConfigPanel from '@/components/ConfigPanel.vue'
|
import ConfigPanel from '@/components/ConfigPanel.vue'
|
||||||
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
import MarkdownRenderer from '@/components/MarkdownRenderer.vue'
|
||||||
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
import MindMapRenderer from '@/components/MindMapRenderer.vue'
|
||||||
import { useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
import { documentApi } from '@/api/document'
|
||||||
|
import { DOCUMENT_CONFIG_STORAGE_KEY, useDocumentProcessor } from '@/composables/useDocumentProcessor'
|
||||||
import { markdownToAstString } from '@/utils/markdownAst'
|
import { markdownToAstString } from '@/utils/markdownAst'
|
||||||
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
import { buildMindmapMarkdown, replaceFirstMindmapText } from '@/utils/mindmapMarkdown'
|
||||||
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
import { createEmptyReplacementRule, renderMarkdownTemplate, type ReplacementRule } from '@/utils/markdownTemplate'
|
||||||
|
|
||||||
|
const MARKDOWN_RENDER_MODE_KEY = 'mineru.documentProcessor.markdownRenderMode'
|
||||||
|
const SOURCE_VIEW_MODE_KEY = 'mineru.documentProcessor.sourceViewMode'
|
||||||
|
|
||||||
|
function loadStoredValue<T>(key: string, fallback: T, validate?: (value: unknown) => value is T): T {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key)
|
||||||
|
if (!raw) return fallback
|
||||||
|
const value = JSON.parse(raw)
|
||||||
|
return validate ? (validate(value) ? value : fallback) : value
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeValue(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(value))
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用本地存储时不影响当前页面使用。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMarkdownRenderMode(value: unknown): value is 'markdown' | 'html' | 'pdf' | 'richtext' {
|
||||||
|
return value === 'markdown' || value === 'html' || value === 'pdf' || value === 'richtext'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceViewMode(value: unknown): value is 'markdown' | 'ast' {
|
||||||
|
return value === 'markdown' || value === 'ast'
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
uploadedFiles,
|
uploadedFiles,
|
||||||
config,
|
config,
|
||||||
|
|
@ -253,16 +310,41 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
const activeTab = ref('markdown')
|
const activeTab = ref('markdown')
|
||||||
const isUploadAreaCollapsed = ref(false)
|
const isUploadAreaCollapsed = ref(false)
|
||||||
const markdownRenderMode = ref<'markdown' | 'html' | 'pdf' | 'richtext'>('markdown')
|
const markdownRenderMode = ref<'markdown' | 'html' | 'pdf' | 'richtext'>(
|
||||||
|
loadStoredValue(MARKDOWN_RENDER_MODE_KEY, 'markdown', isMarkdownRenderMode)
|
||||||
|
)
|
||||||
const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
|
const markdownCompatibilityFlavor = ref<'commonmark' | 'gfm'>('gfm')
|
||||||
const sourceViewMode = ref<'markdown' | 'ast'>('markdown')
|
const sourceViewMode = ref<'markdown' | 'ast'>(
|
||||||
|
loadStoredValue(SOURCE_VIEW_MODE_KEY, 'markdown', isSourceViewMode)
|
||||||
|
)
|
||||||
|
const sourceContentMode = ref<'source' | 'smart'>('source')
|
||||||
|
const sourceContentModeOptions = [
|
||||||
|
{ label: '源码', value: 'source' },
|
||||||
|
{ label: '智能整理', value: 'smart' }
|
||||||
|
]
|
||||||
const showReplacementDialog = ref(false)
|
const showReplacementDialog = ref(false)
|
||||||
const isMobileDialog = ref(false)
|
const isMobileDialog = ref(false)
|
||||||
const replacementRules = ref<ReplacementRule[]>([])
|
const replacementRules = ref<ReplacementRule[]>([])
|
||||||
const draftReplacementRules = ref<ReplacementRule[]>([])
|
const draftReplacementRules = ref<ReplacementRule[]>([])
|
||||||
|
const smartMarkdownContent = ref('')
|
||||||
|
const smartOrganizeTaskId = ref('')
|
||||||
|
const isSmartOrganizing = ref(false)
|
||||||
|
const smartOrganizeProgress = ref(0)
|
||||||
|
const smartOrganizeStage = ref('')
|
||||||
|
const smartOrganizeError = ref('')
|
||||||
|
let smartOrganizeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
watch(markdownRenderMode, (value) => storeValue(MARKDOWN_RENDER_MODE_KEY, value))
|
||||||
|
watch(sourceViewMode, (value) => storeValue(SOURCE_VIEW_MODE_KEY, value))
|
||||||
|
|
||||||
const manualMarkdownContent = computed(() => results.value?.source || '')
|
const manualMarkdownContent = computed(() => results.value?.source || '')
|
||||||
const templateRenderResult = computed(() => renderMarkdownTemplate(manualMarkdownContent.value, replacementRules.value))
|
const activeSourceMarkdownContent = computed(() => {
|
||||||
|
if (sourceContentMode.value === 'smart' && smartMarkdownContent.value) {
|
||||||
|
return smartMarkdownContent.value
|
||||||
|
}
|
||||||
|
return manualMarkdownContent.value
|
||||||
|
})
|
||||||
|
const templateRenderResult = computed(() => renderMarkdownTemplate(activeSourceMarkdownContent.value, replacementRules.value))
|
||||||
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
const renderedMarkdownContent = computed(() => templateRenderResult.value.markdown)
|
||||||
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
|
const renderedMindmapContent = computed(() => buildMindmapMarkdown(renderedMarkdownContent.value))
|
||||||
const templateRenderError = computed(() => templateRenderResult.value.error)
|
const templateRenderError = computed(() => templateRenderResult.value.error)
|
||||||
|
|
@ -290,15 +372,105 @@ const sourcePanelContent = computed({
|
||||||
if (sourceViewMode.value === 'ast') {
|
if (sourceViewMode.value === 'ast') {
|
||||||
return markdownToAstString(renderedMarkdownContent.value)
|
return markdownToAstString(renderedMarkdownContent.value)
|
||||||
}
|
}
|
||||||
return manualMarkdownContent.value
|
return activeSourceMarkdownContent.value
|
||||||
},
|
},
|
||||||
set: (value: string) => {
|
set: (value: string) => {
|
||||||
if (sourceViewMode.value === 'markdown' && results.value) {
|
if (sourceViewMode.value !== 'markdown') return
|
||||||
|
if (sourceContentMode.value === 'smart') {
|
||||||
|
smartMarkdownContent.value = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (results.value) {
|
||||||
results.value.source = value
|
results.value.source = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const stopSmartOrganizePolling = () => {
|
||||||
|
if (smartOrganizeTimer) {
|
||||||
|
clearInterval(smartOrganizeTimer)
|
||||||
|
smartOrganizeTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetSmartOrganizeState = () => {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
|
sourceContentMode.value = 'source'
|
||||||
|
smartMarkdownContent.value = ''
|
||||||
|
smartOrganizeTaskId.value = ''
|
||||||
|
isSmartOrganizing.value = false
|
||||||
|
smartOrganizeProgress.value = 0
|
||||||
|
smartOrganizeStage.value = ''
|
||||||
|
smartOrganizeError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSmartOrganizePolling = (taskId: string) => {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
|
smartOrganizeTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const data = await documentApi.getMindmapProgress(taskId)
|
||||||
|
smartOrganizeProgress.value = data.progress
|
||||||
|
smartOrganizeStage.value = data.stage
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
|
isSmartOrganizing.value = false
|
||||||
|
smartMarkdownContent.value = data.result_md || ''
|
||||||
|
smartOrganizeError.value = ''
|
||||||
|
sourceContentMode.value = 'smart'
|
||||||
|
ElMessage.success('智能整理完成')
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
|
isSmartOrganizing.value = false
|
||||||
|
sourceContentMode.value = 'source'
|
||||||
|
smartOrganizeError.value = data.error || '智能整理失败'
|
||||||
|
ElMessage.error(smartOrganizeError.value)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
|
isSmartOrganizing.value = false
|
||||||
|
sourceContentMode.value = 'source'
|
||||||
|
smartOrganizeError.value = (err as Error).message || '智能整理进度查询失败'
|
||||||
|
ElMessage.error(smartOrganizeError.value)
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSmartOrganize = async () => {
|
||||||
|
const markdown = manualMarkdownContent.value.trim()
|
||||||
|
if (!markdown) {
|
||||||
|
sourceContentMode.value = 'source'
|
||||||
|
ElMessage.warning('暂无 Markdown 源码可整理')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (smartMarkdownContent.value) {
|
||||||
|
sourceContentMode.value = 'smart'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = `mindmap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
smartOrganizeTaskId.value = taskId
|
||||||
|
isSmartOrganizing.value = true
|
||||||
|
smartOrganizeProgress.value = 0
|
||||||
|
smartOrganizeStage.value = '创建智能整理任务'
|
||||||
|
smartOrganizeError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await documentApi.createMindmapTask(taskId, markdown, 'hybrid', config.mindmapPrompt)
|
||||||
|
startSmartOrganizePolling(taskId)
|
||||||
|
} catch (err) {
|
||||||
|
isSmartOrganizing.value = false
|
||||||
|
sourceContentMode.value = 'source'
|
||||||
|
smartOrganizeError.value = (err as Error).message || '智能整理任务创建失败'
|
||||||
|
ElMessage.error(smartOrganizeError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sourceContentMode, (value) => {
|
||||||
|
if (value === 'smart') {
|
||||||
|
startSmartOrganize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const toggleSettings = () => {
|
const toggleSettings = () => {
|
||||||
if (showSettings.value) {
|
if (showSettings.value) {
|
||||||
const settingsPanel = document.querySelector('.settings-panel')
|
const settingsPanel = document.querySelector('.settings-panel')
|
||||||
|
|
@ -323,6 +495,12 @@ const toggleSettings = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeSettings = () => {
|
||||||
|
if (showSettings.value) {
|
||||||
|
toggleSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const settingsPanel = document.querySelector('.settings-panel')
|
const settingsPanel = document.querySelector('.settings-panel')
|
||||||
const settingsButton = document.querySelector('.settings-button')
|
const settingsButton = document.querySelector('.settings-button')
|
||||||
|
|
@ -347,7 +525,17 @@ const handleBackendChange = (backend: string) => {
|
||||||
console.log('Backend changed to:', backend)
|
console.log('Backend changed to:', backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyConfig = (nextConfig: typeof config) => {
|
||||||
|
Object.assign(config, nextConfig)
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DOCUMENT_CONFIG_STORAGE_KEY, JSON.stringify(nextConfig))
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用本地存储时不影响当前页面使用。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleProcessDocument = async () => {
|
const handleProcessDocument = async () => {
|
||||||
|
resetSmartOrganizeState()
|
||||||
await processDocument()
|
await processDocument()
|
||||||
if (error.value) {
|
if (error.value) {
|
||||||
ElMessage.error(error.value)
|
ElMessage.error(error.value)
|
||||||
|
|
@ -403,13 +591,18 @@ const saveReplacementRules = () => {
|
||||||
|
|
||||||
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
|
const handleMindmapNodeEdit = ({ oldText, newText }: { oldText: string; newText: string }) => {
|
||||||
if (!results.value) return
|
if (!results.value) return
|
||||||
const updated = replaceFirstMindmapText(results.value.source || results.value.markdown, oldText, newText)
|
const currentMarkdown = activeSourceMarkdownContent.value
|
||||||
if (updated === (results.value.source || results.value.markdown)) {
|
const updated = replaceFirstMindmapText(currentMarkdown, oldText, newText)
|
||||||
|
if (updated === currentMarkdown) {
|
||||||
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
|
ElMessage.warning('未在 Markdown 中定位到该节点文本,可在源码区直接编辑')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (sourceContentMode.value === 'smart') {
|
||||||
|
smartMarkdownContent.value = updated
|
||||||
|
} else {
|
||||||
results.value.source = updated
|
results.value.source = updated
|
||||||
results.value.markdown = updated
|
results.value.markdown = updated
|
||||||
|
}
|
||||||
ElMessage.success('节点已同步到 Markdown')
|
ElMessage.success('节点已同步到 Markdown')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -417,20 +610,17 @@ const triggerUpload = () => {
|
||||||
fileInput.value?.click()
|
fileInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetReplacementConfig = () => {
|
|
||||||
replacementRules.value = []
|
|
||||||
draftReplacementRules.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileInputChange = async (event: Event) => {
|
const handleFileInputChange = async (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
if (input.files) {
|
if (input.files) {
|
||||||
await handleFileUpload(input.files)
|
await handleFileUpload(input.files)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
if (uploadedFiles.value.length > 0) {
|
if (uploadedFiles.value.length > 0) {
|
||||||
|
resetSmartOrganizeState()
|
||||||
isUploadAreaCollapsed.value = true
|
isUploadAreaCollapsed.value = true
|
||||||
|
if (!results.value) {
|
||||||
initializeManualResult()
|
initializeManualResult()
|
||||||
resetReplacementConfig()
|
}
|
||||||
markdownRenderMode.value = 'markdown'
|
markdownRenderMode.value = 'markdown'
|
||||||
sourceViewMode.value = 'markdown'
|
sourceViewMode.value = 'markdown'
|
||||||
activeTab.value = 'source'
|
activeTab.value = 'source'
|
||||||
|
|
@ -445,9 +635,11 @@ const handleDrop = async (event: DragEvent) => {
|
||||||
if (event.dataTransfer?.files) {
|
if (event.dataTransfer?.files) {
|
||||||
await handleFileUpload(event.dataTransfer.files)
|
await handleFileUpload(event.dataTransfer.files)
|
||||||
if (uploadedFiles.value.length > 0) {
|
if (uploadedFiles.value.length > 0) {
|
||||||
|
resetSmartOrganizeState()
|
||||||
isUploadAreaCollapsed.value = true
|
isUploadAreaCollapsed.value = true
|
||||||
|
if (!results.value) {
|
||||||
initializeManualResult()
|
initializeManualResult()
|
||||||
resetReplacementConfig()
|
}
|
||||||
markdownRenderMode.value = 'markdown'
|
markdownRenderMode.value = 'markdown'
|
||||||
sourceViewMode.value = 'markdown'
|
sourceViewMode.value = 'markdown'
|
||||||
activeTab.value = 'source'
|
activeTab.value = 'source'
|
||||||
|
|
@ -463,8 +655,8 @@ const removeFile = (index: number) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearAllFiles = () => {
|
const clearAllFiles = () => {
|
||||||
|
resetSmartOrganizeState()
|
||||||
clearAll()
|
clearAll()
|
||||||
resetReplacementConfig()
|
|
||||||
isUploadAreaCollapsed.value = false
|
isUploadAreaCollapsed.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -474,6 +666,7 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopSmartOrganizePolling()
|
||||||
window.removeEventListener('resize', updateViewportState)
|
window.removeEventListener('resize', updateViewportState)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -522,7 +715,7 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
padding: 0 24px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -537,19 +730,23 @@ onUnmounted(() => {
|
||||||
.drag-upload-area {
|
.drag-upload-area {
|
||||||
border: 2px dashed #CED4DA;
|
border: 2px dashed #CED4DA;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px 24px;
|
padding: 8px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
background-color: #FFFFFF;
|
background-color: #FFFFFF;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
height: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area.collapsed {
|
.drag-upload-area.collapsed {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
min-height: 36px;
|
min-height: 52px;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area:hover,
|
.drag-upload-area:hover,
|
||||||
|
|
@ -560,56 +757,52 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.upload-content {
|
.upload-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 48px;
|
font-size: 24px;
|
||||||
color: #165DFF;
|
color: #165DFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-copy {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #343A40;
|
color: #343A40;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-hint {
|
.upload-hint {
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
color: #6C757D;
|
color: #6C757D;
|
||||||
line-height: 1.5;
|
line-height: 1.4;
|
||||||
max-width: 400px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.uploaded-files {
|
.uploaded-files {
|
||||||
margin-top: 24px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-upload-area.collapsed .uploaded-files {
|
|
||||||
margin-top: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 0;
|
||||||
background-color: #F8F9FA;
|
background-color: #F8F9FA;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 0;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
@ -625,15 +818,16 @@ onUnmounted(() => {
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #343A40;
|
color: #343A40;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: calc(100% - 40px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-button {
|
.remove-button {
|
||||||
|
flex-shrink: 0;
|
||||||
color: #6C757D;
|
color: #6C757D;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -710,6 +904,10 @@ onUnmounted(() => {
|
||||||
border-bottom: 1px solid #E9ECEF;
|
border-bottom: 1px solid #E9ECEF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-tabs :deep(.el-tabs__header) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.result-tabs :deep(.el-tabs__nav) {
|
.result-tabs :deep(.el-tabs__nav) {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -857,23 +1055,21 @@ onUnmounted(() => {
|
||||||
.markdown-box {
|
.markdown-box {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #E9ECEF;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
padding: 16px;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
background-color: transparent;
|
||||||
overflow-x: hidden;
|
|
||||||
background-color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindmap-box {
|
.mindmap-box {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #E9ECEF;
|
border: 0;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: white;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.source-content {
|
.source-content {
|
||||||
|
|
@ -892,6 +1088,45 @@ onUnmounted(() => {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info,
|
||||||
|
.content-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-mode-toolbar {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smart-organize-status {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909C;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343A40;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #86909C;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.replacement-config-button {
|
.replacement-config-button {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
@ -994,7 +1229,10 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.main-content,
|
.main-content {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.result-content {
|
.result-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
@ -1004,11 +1242,11 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-upload-area {
|
.drag-upload-area {
|
||||||
padding: 32px 16px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-icon {
|
.upload-icon {
|
||||||
font-size: 32px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-text {
|
.upload-text {
|
||||||
|
|
@ -1033,6 +1271,14 @@ onUnmounted(() => {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-toolbar-info {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-toolbar-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.replacement-dialog-body {
|
.replacement-dialog-body {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default defineConfig({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8000',
|
target: 'http://10.100.52.76:8000',
|
||||||
changeOrigin: true
|
changeOrigin: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue