Compare commits
2 Commits
4623e3fa1c
...
8a6a6574bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6a6574bb | ||
|
|
9025437a37 |
@@ -53,6 +53,11 @@ from app.modules.data_process.document_chunking import (
|
|||||||
merge_short_chunks,
|
merge_short_chunks,
|
||||||
)
|
)
|
||||||
from app.modules.data_process.generation import generate_model_records
|
from app.modules.data_process.generation import generate_model_records
|
||||||
|
from app.modules.data_process.office_preview import (
|
||||||
|
MAX_XLSX_PREVIEW_ROWS,
|
||||||
|
build_docx_preview,
|
||||||
|
build_xlsx_preview,
|
||||||
|
)
|
||||||
from app.modules.data_process.storage import (
|
from app.modules.data_process.storage import (
|
||||||
LocalDataProcessStorage,
|
LocalDataProcessStorage,
|
||||||
StagedSourceObject,
|
StagedSourceObject,
|
||||||
@@ -111,6 +116,12 @@ LEGACY_OFFICE_CONVERSIONS = {
|
|||||||
".xls": ".xlsx",
|
".xls": ".xlsx",
|
||||||
".ppt": ".pptx",
|
".ppt": ".pptx",
|
||||||
}
|
}
|
||||||
|
RAW_INLINE_PREVIEW_MEDIA_TYPES = {
|
||||||
|
"pdf": "application/pdf",
|
||||||
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
def ok(data: Any = None, message: str = "ok") -> dict[str, Any]:
|
||||||
@@ -897,8 +908,13 @@ def source_file_raw(
|
|||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
with api_errors():
|
with api_errors():
|
||||||
source = store.get_source_file(task_id, file_id, include_content=False)
|
source = store.get_source_file(task_id, file_id, include_content=False)
|
||||||
if str(source.get("file_format") or "").lower() != "pdf":
|
file_format = str(source.get("file_format") or "").lower()
|
||||||
raise fail(415, "raw inline preview is only available for PDF source files")
|
media_type = RAW_INLINE_PREVIEW_MEDIA_TYPES.get(file_format)
|
||||||
|
if media_type is None:
|
||||||
|
raise fail(
|
||||||
|
415,
|
||||||
|
"raw inline preview is only available for PDF and modern Office source files",
|
||||||
|
)
|
||||||
storage_object_id = str(source.get("storage_object_id") or "")
|
storage_object_id = str(source.get("storage_object_id") or "")
|
||||||
actual_size = storage.file_size(
|
actual_size = storage.file_size(
|
||||||
storage_object_id,
|
storage_object_id,
|
||||||
@@ -906,14 +922,15 @@ def source_file_raw(
|
|||||||
expected_source_file_id=file_id,
|
expected_source_file_id=file_id,
|
||||||
)
|
)
|
||||||
if actual_size is None:
|
if actual_size is None:
|
||||||
raise fail(410, "the original PDF is unavailable for this legacy source file")
|
raise fail(410, "the original file is unavailable for this legacy source file")
|
||||||
expected_size = int(source.get("size_bytes") or 0)
|
expected_size = int(source.get("size_bytes") or 0)
|
||||||
if actual_size != expected_size:
|
if actual_size != expected_size:
|
||||||
raise ValueError("source object size does not match metadata")
|
raise ValueError("source object size does not match metadata")
|
||||||
selected_range = _source_byte_range(range_header, actual_size)
|
selected_range = _source_byte_range(range_header, actual_size)
|
||||||
start, end = selected_range or (0, actual_size - 1)
|
start, end = selected_range or (0, actual_size - 1)
|
||||||
length = end - start + 1
|
length = end - start + 1
|
||||||
name = _safe_file_name(str(source.get("name") or "source.pdf"), "source.pdf")
|
default_name = f"source.{file_format}"
|
||||||
|
name = _safe_file_name(str(source.get("name") or default_name), default_name)
|
||||||
headers = {
|
headers = {
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
"Cache-Control": "private, no-store",
|
"Cache-Control": "private, no-store",
|
||||||
@@ -938,11 +955,61 @@ def source_file_raw(
|
|||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
body,
|
body,
|
||||||
status_code=206 if selected_range is not None else 200,
|
status_code=206 if selected_range is not None else 200,
|
||||||
media_type="application/pdf",
|
media_type=media_type,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{task_id}/source-files/{file_id}/office-preview")
|
||||||
|
def source_file_office_preview(
|
||||||
|
task_id: str,
|
||||||
|
file_id: str,
|
||||||
|
sheet_index: int = Query(default=0, ge=0),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
limit: int = Query(default=100, ge=1, le=MAX_XLSX_PREVIEW_ROWS),
|
||||||
|
store: DataProcessStore = Depends(get_data_process_store),
|
||||||
|
storage: LocalDataProcessStorage = Depends(get_data_process_storage),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""返回 Word 版式块或 Excel 工作表网格,不把二进制内容下发给组件解析。"""
|
||||||
|
|
||||||
|
with api_errors():
|
||||||
|
source = store.get_source_file(task_id, file_id, include_content=False)
|
||||||
|
file_format = str(source.get("file_format") or "").lower()
|
||||||
|
if file_format not in {"docx", "xlsx"}:
|
||||||
|
raise fail(415, "Office preview is only available for DOCX and XLSX source files")
|
||||||
|
storage_object_id = str(source.get("storage_object_id") or "")
|
||||||
|
actual_size = storage.file_size(
|
||||||
|
storage_object_id,
|
||||||
|
expected_task_id=task_id,
|
||||||
|
expected_source_file_id=file_id,
|
||||||
|
)
|
||||||
|
if actual_size is None:
|
||||||
|
raise fail(410, "the original Office file is unavailable for this legacy source file")
|
||||||
|
expected_size = int(source.get("size_bytes") or 0)
|
||||||
|
if actual_size != expected_size:
|
||||||
|
raise ValueError("source object size does not match metadata")
|
||||||
|
raw = b"".join(
|
||||||
|
storage.iter_bytes(
|
||||||
|
storage_object_id,
|
||||||
|
expected_task_id=task_id,
|
||||||
|
expected_source_file_id=file_id,
|
||||||
|
expected_size=actual_size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
preview = (
|
||||||
|
build_docx_preview(raw)
|
||||||
|
if file_format == "docx"
|
||||||
|
else build_xlsx_preview(
|
||||||
|
raw,
|
||||||
|
sheet_index=sheet_index,
|
||||||
|
offset=offset,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
preview["file_name"] = str(source.get("name") or f"source.{file_format}")
|
||||||
|
return ok(preview)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}/source-files/{file_id}/pdf-pages")
|
@router.get("/{task_id}/source-files/{file_id}/pdf-pages")
|
||||||
def source_file_pdf_pages(
|
def source_file_pdf_pages(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|||||||
308
backend/app/modules/data_process/office_preview.py
Normal file
308
backend/app/modules/data_process/office_preview.py
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
"""Word 与 Excel 原文件的安全、受限预览模型。
|
||||||
|
|
||||||
|
预览只返回浏览器绘制所需的结构化数据,不返回或执行 Office 包中的活动内容。
|
||||||
|
DOCX 的字符偏移与上传时的正文抽取规则保持一致,供前端定位当前切片。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from docx import Document
|
||||||
|
from docx.oxml.table import CT_Tbl
|
||||||
|
from docx.oxml.text.paragraph import CT_P
|
||||||
|
from docx.table import Table
|
||||||
|
from docx.text.paragraph import Paragraph
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
from app.modules.data_process.algorithms import (
|
||||||
|
_MAX_WORKBOOK_COLUMNS,
|
||||||
|
_MAX_WORKBOOK_HEADER_SCAN_ROWS,
|
||||||
|
_infer_xlsx_header_region,
|
||||||
|
_normalize_spreadsheet_value,
|
||||||
|
_rewrite_xlsx_workbook_relationships,
|
||||||
|
_validate_office_archive,
|
||||||
|
_xlsx_sheet_merge_ranges,
|
||||||
|
normalize_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_DOCX_PREVIEW_BLOCKS = 2_000
|
||||||
|
MAX_XLSX_PREVIEW_ROWS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _docx_alignment(paragraph: Paragraph) -> str:
|
||||||
|
value = paragraph.alignment
|
||||||
|
return {
|
||||||
|
0: "left",
|
||||||
|
1: "center",
|
||||||
|
2: "right",
|
||||||
|
3: "justify",
|
||||||
|
4: "distribute",
|
||||||
|
5: "justify",
|
||||||
|
7: "justify",
|
||||||
|
8: "distribute",
|
||||||
|
9: "distribute",
|
||||||
|
}.get(int(value) if value is not None else -1, "left")
|
||||||
|
|
||||||
|
|
||||||
|
def _docx_heading_level(paragraph: Paragraph) -> int | None:
|
||||||
|
style = paragraph.style
|
||||||
|
if style is None:
|
||||||
|
return None
|
||||||
|
style_name = str(style.name or "")
|
||||||
|
style_id = str(style.style_id or "")
|
||||||
|
match = re.search(r"(?:heading|标题)\s*([1-6])", f"{style_name} {style_id}", re.IGNORECASE)
|
||||||
|
return int(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def build_docx_preview(raw: bytes) -> dict[str, Any]:
|
||||||
|
"""把 DOCX 转为保留标题、段落和表格顺序的浏览器预览模型。"""
|
||||||
|
|
||||||
|
_validate_office_archive(raw, "docx")
|
||||||
|
try:
|
||||||
|
document = Document(io.BytesIO(raw))
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"invalid DOCX file: {exc}") from exc
|
||||||
|
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
source_cursor = 0
|
||||||
|
has_source_content = False
|
||||||
|
rendered_blocks = 0
|
||||||
|
truncated = False
|
||||||
|
|
||||||
|
def source_range(value: str) -> tuple[str, int, int] | None:
|
||||||
|
nonlocal source_cursor, has_source_content
|
||||||
|
text = normalize_text(value)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if has_source_content:
|
||||||
|
source_cursor += 2
|
||||||
|
start = source_cursor
|
||||||
|
source_cursor += len(text)
|
||||||
|
has_source_content = True
|
||||||
|
return text, start, source_cursor
|
||||||
|
|
||||||
|
for child in document.element.body.iterchildren():
|
||||||
|
if rendered_blocks >= MAX_DOCX_PREVIEW_BLOCKS:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if isinstance(child, CT_P):
|
||||||
|
paragraph = Paragraph(child, document)
|
||||||
|
located = source_range(paragraph.text)
|
||||||
|
if located is None:
|
||||||
|
continue
|
||||||
|
text, start, end = located
|
||||||
|
style_name = str(paragraph.style.name or "") if paragraph.style else ""
|
||||||
|
blocks.append(
|
||||||
|
{
|
||||||
|
"type": "paragraph",
|
||||||
|
"text": text,
|
||||||
|
"style": style_name,
|
||||||
|
"heading_level": _docx_heading_level(paragraph),
|
||||||
|
"alignment": _docx_alignment(paragraph),
|
||||||
|
"is_list": "list" in style_name.casefold() or "列表" in style_name,
|
||||||
|
"source_start": start,
|
||||||
|
"source_end": end,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rendered_blocks += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(child, CT_Tbl):
|
||||||
|
continue
|
||||||
|
table = Table(child, document)
|
||||||
|
preview_rows: list[dict[str, Any]] = []
|
||||||
|
for row in table.rows:
|
||||||
|
if rendered_blocks >= MAX_DOCX_PREVIEW_BLOCKS:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
cell_values = [normalize_text(cell.text) for cell in row.cells]
|
||||||
|
located = source_range("\t".join(cell_values))
|
||||||
|
if located is None:
|
||||||
|
continue
|
||||||
|
_, start, end = located
|
||||||
|
preview_rows.append(
|
||||||
|
{
|
||||||
|
"cells": cell_values,
|
||||||
|
"source_start": start,
|
||||||
|
"source_end": end,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rendered_blocks += 1
|
||||||
|
if preview_rows:
|
||||||
|
blocks.append({"type": "table", "rows": preview_rows})
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "docx",
|
||||||
|
"blocks": blocks,
|
||||||
|
"truncated": truncated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_xlsx_preview(
|
||||||
|
raw: bytes,
|
||||||
|
*,
|
||||||
|
sheet_index: int = 0,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""按工作表分页返回 XLSX 的表头和记录网格。"""
|
||||||
|
|
||||||
|
if sheet_index < 0 or offset < 0:
|
||||||
|
raise ValueError("sheet_index and offset must be non-negative")
|
||||||
|
if limit < 1 or limit > MAX_XLSX_PREVIEW_ROWS:
|
||||||
|
raise ValueError(
|
||||||
|
f"XLSX preview limit must be in [1, {MAX_XLSX_PREVIEW_ROWS}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_office_archive(raw, "xlsx")
|
||||||
|
merged_by_sheet, normalized_targets = _xlsx_sheet_merge_ranges(raw)
|
||||||
|
workbook_raw = (
|
||||||
|
_rewrite_xlsx_workbook_relationships(raw, normalized_targets)
|
||||||
|
if normalized_targets
|
||||||
|
else raw
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
workbook = load_workbook(
|
||||||
|
io.BytesIO(workbook_raw),
|
||||||
|
read_only=True,
|
||||||
|
data_only=True,
|
||||||
|
keep_links=False,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"invalid XLSX file: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
sheets = [
|
||||||
|
{
|
||||||
|
"index": index,
|
||||||
|
"name": worksheet.title,
|
||||||
|
"state": worksheet.sheet_state,
|
||||||
|
}
|
||||||
|
for index, worksheet in enumerate(workbook.worksheets)
|
||||||
|
]
|
||||||
|
if not sheets:
|
||||||
|
raise ValueError("XLSX workbook contains no worksheets")
|
||||||
|
if sheet_index >= len(sheets):
|
||||||
|
raise ValueError("XLSX worksheet index is out of range")
|
||||||
|
|
||||||
|
worksheet = workbook.worksheets[sheet_index]
|
||||||
|
reset_dimensions = getattr(worksheet, "reset_dimensions", None)
|
||||||
|
if callable(reset_dimensions):
|
||||||
|
reset_dimensions()
|
||||||
|
row_iterator = enumerate(worksheet.iter_rows(values_only=True), start=1)
|
||||||
|
buffered_rows: dict[int, tuple[Any, ...]] = {}
|
||||||
|
|
||||||
|
def normalized_values(row: tuple[Any, ...]) -> list[Any]:
|
||||||
|
values = list(row)
|
||||||
|
while values and values[-1] in {None, ""}:
|
||||||
|
values.pop()
|
||||||
|
if len(values) > _MAX_WORKBOOK_COLUMNS:
|
||||||
|
raise ValueError(
|
||||||
|
f"XLSX worksheet {worksheet.title!r} exceeds "
|
||||||
|
f"{_MAX_WORKBOOK_COLUMNS} columns"
|
||||||
|
)
|
||||||
|
return values
|
||||||
|
|
||||||
|
for row_number, row in row_iterator:
|
||||||
|
values = normalized_values(row)
|
||||||
|
if not values or all(value in {None, ""} for value in values):
|
||||||
|
continue
|
||||||
|
buffered_rows[row_number] = tuple(values)
|
||||||
|
if len(buffered_rows) >= _MAX_WORKBOOK_HEADER_SCAN_ROWS:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not buffered_rows:
|
||||||
|
return {
|
||||||
|
"format": "xlsx",
|
||||||
|
"sheets": sheets,
|
||||||
|
"active_sheet": {
|
||||||
|
"index": sheet_index,
|
||||||
|
"name": worksheet.title,
|
||||||
|
"columns": [],
|
||||||
|
"rows": [],
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, header_end_row, headers = _infer_xlsx_header_region(
|
||||||
|
worksheet.title,
|
||||||
|
buffered_rows,
|
||||||
|
merged_by_sheet.get(worksheet.title, ()),
|
||||||
|
)
|
||||||
|
preview_rows: list[dict[str, Any]] = []
|
||||||
|
record_index = 0
|
||||||
|
has_more = False
|
||||||
|
|
||||||
|
def append_row(row_number: int, values: tuple[Any, ...] | list[Any]) -> bool:
|
||||||
|
nonlocal record_index, has_more
|
||||||
|
row_values = list(values)
|
||||||
|
if len(row_values) > len(headers):
|
||||||
|
raise ValueError(
|
||||||
|
f"XLSX worksheet {worksheet.title!r} has a row wider than its header"
|
||||||
|
)
|
||||||
|
row_values.extend([None] * (len(headers) - len(row_values)))
|
||||||
|
record = {
|
||||||
|
header: _normalize_spreadsheet_value(value)
|
||||||
|
for header, value in zip(headers, row_values, strict=True)
|
||||||
|
}
|
||||||
|
if not any(value not in {"", None} for value in record.values()):
|
||||||
|
return False
|
||||||
|
current_index = record_index
|
||||||
|
record_index += 1
|
||||||
|
if current_index < offset:
|
||||||
|
return False
|
||||||
|
if len(preview_rows) >= limit:
|
||||||
|
has_more = True
|
||||||
|
return True
|
||||||
|
preview_rows.append(
|
||||||
|
{
|
||||||
|
"row_number": row_number,
|
||||||
|
"record_index": current_index,
|
||||||
|
"values": [record[header] for header in headers],
|
||||||
|
"record": record,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for row_number, values in buffered_rows.items():
|
||||||
|
if row_number > header_end_row and append_row(row_number, values):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
for row_number, row in row_iterator:
|
||||||
|
values = normalized_values(row)
|
||||||
|
if not values or all(value in {None, ""} for value in values):
|
||||||
|
continue
|
||||||
|
if append_row(row_number, values):
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": "xlsx",
|
||||||
|
"sheets": sheets,
|
||||||
|
"active_sheet": {
|
||||||
|
"index": sheet_index,
|
||||||
|
"name": worksheet.title,
|
||||||
|
"columns": headers,
|
||||||
|
"rows": preview_rows,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
workbook.close()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"MAX_DOCX_PREVIEW_BLOCKS",
|
||||||
|
"MAX_XLSX_PREVIEW_ROWS",
|
||||||
|
"build_docx_preview",
|
||||||
|
"build_xlsx_preview",
|
||||||
|
]
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from docx import Document as WordDocument
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
@@ -1546,11 +1547,112 @@ def test_xlsx_upload_is_accepted_as_structured_records(tmp_path: Path) -> None:
|
|||||||
)
|
)
|
||||||
assert content.status_code == 200
|
assert content.status_code == 200
|
||||||
assert '"answer":"答案二"' in content.json()["data"]["content"]
|
assert '"answer":"答案二"' in content.json()["data"]["content"]
|
||||||
|
office_preview_url = (
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files/{source['id']}/office-preview"
|
||||||
|
)
|
||||||
|
first_page = client.get(office_preview_url, params={"offset": 0, "limit": 1})
|
||||||
|
assert first_page.status_code == 200
|
||||||
|
preview_data = first_page.json()["data"]
|
||||||
|
assert preview_data["format"] == "xlsx"
|
||||||
|
assert preview_data["sheets"] == [{"index": 0, "name": "Sheet", "state": "visible"}]
|
||||||
|
assert preview_data["active_sheet"]["columns"] == ["question", "answer"]
|
||||||
|
assert preview_data["active_sheet"]["rows"][0]["record"] == {
|
||||||
|
"question": "问题一",
|
||||||
|
"answer": "答案一",
|
||||||
|
}
|
||||||
|
assert preview_data["active_sheet"]["has_more"] is True
|
||||||
|
|
||||||
|
second_page = client.get(office_preview_url, params={"offset": 1, "limit": 1})
|
||||||
|
assert second_page.status_code == 200
|
||||||
|
assert second_page.json()["data"]["active_sheet"]["rows"][0]["record"] == {
|
||||||
|
"question": "问题二",
|
||||||
|
"answer": "答案二",
|
||||||
|
}
|
||||||
|
assert second_page.json()["data"]["active_sheet"]["has_more"] is False
|
||||||
|
|
||||||
|
raw_preview = client.get(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files/{source['id']}/raw"
|
||||||
|
)
|
||||||
|
assert raw_preview.status_code == 200
|
||||||
|
assert raw_preview.content == original_bytes
|
||||||
|
assert raw_preview.headers["content-type"].startswith(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||||
|
)
|
||||||
preview = client.post(f"/modelTF/data-process/{task_id}/preview/build")
|
preview = client.post(f"/modelTF/data-process/{task_id}/preview/build")
|
||||||
assert preview.status_code == 200
|
assert preview.status_code == 200
|
||||||
assert preview.json()["data"]["total"] == 2
|
assert preview.json()["data"]["total"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_docx_preview_preserves_document_block_order_and_source_offsets(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
client, store, _ = make_client(tmp_path)
|
||||||
|
task_id = client.post(
|
||||||
|
"/modelTF/data-process",
|
||||||
|
json={"name": "Word 原件预览", "process_type": "unstructured", "config": {}},
|
||||||
|
).json()["data"]["id"]
|
||||||
|
document = WordDocument()
|
||||||
|
document.add_heading("费用管理办法", level=1)
|
||||||
|
document.add_paragraph("第一条 本办法用于规范费用报销。")
|
||||||
|
table = document.add_table(rows=2, cols=2)
|
||||||
|
table.cell(0, 0).text = "费用类型"
|
||||||
|
table.cell(0, 1).text = "审批人"
|
||||||
|
table.cell(1, 0).text = "差旅费"
|
||||||
|
table.cell(1, 1).text = "部门负责人"
|
||||||
|
output = BytesIO()
|
||||||
|
document.save(output)
|
||||||
|
original_bytes = output.getvalue()
|
||||||
|
|
||||||
|
uploaded = client.post(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files",
|
||||||
|
files={
|
||||||
|
"files": (
|
||||||
|
"费用 管理.docx",
|
||||||
|
original_bytes,
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
).json()["data"]["files"][0]
|
||||||
|
response = client.get(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files/{uploaded['id']}/office-preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()["data"]
|
||||||
|
assert data["format"] == "docx"
|
||||||
|
assert data["file_name"] == "费用 管理.docx"
|
||||||
|
assert data["truncated"] is False
|
||||||
|
assert [block["type"] for block in data["blocks"]] == [
|
||||||
|
"paragraph",
|
||||||
|
"paragraph",
|
||||||
|
"table",
|
||||||
|
]
|
||||||
|
assert data["blocks"][0]["heading_level"] == 1
|
||||||
|
assert data["blocks"][0]["text"] == "费用管理办法"
|
||||||
|
assert data["blocks"][2]["rows"][1]["cells"] == ["差旅费", "部门负责人"]
|
||||||
|
|
||||||
|
source_text = store.get_source_file(task_id, uploaded["id"])["content"]
|
||||||
|
first_paragraph = data["blocks"][0]
|
||||||
|
assert (
|
||||||
|
source_text[first_paragraph["source_start"] : first_paragraph["source_end"]]
|
||||||
|
== first_paragraph["text"]
|
||||||
|
)
|
||||||
|
table_row = data["blocks"][2]["rows"][1]
|
||||||
|
assert (
|
||||||
|
source_text[table_row["source_start"] : table_row["source_end"]]
|
||||||
|
== "\t".join(table_row["cells"])
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_preview = client.get(
|
||||||
|
f"/modelTF/data-process/{task_id}/source-files/{uploaded['id']}/raw"
|
||||||
|
)
|
||||||
|
assert raw_preview.status_code == 200
|
||||||
|
assert raw_preview.content == original_bytes
|
||||||
|
assert raw_preview.headers["content-type"].startswith(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_raw_preview_streams_original_file_and_supports_ranges(tmp_path: Path) -> None:
|
def test_pdf_raw_preview_streams_original_file_and_supports_ranges(tmp_path: Path) -> None:
|
||||||
client, store, _ = make_client(tmp_path)
|
client, store, _ = make_client(tmp_path)
|
||||||
task_id = client.post(
|
task_id = client.post(
|
||||||
|
|||||||
@@ -109,16 +109,19 @@ for (const component of expectedComponents) {
|
|||||||
const typesPath = path.join(createDir, 'types.ts')
|
const typesPath = path.join(createDir, 'types.ts')
|
||||||
const modelPath = path.join(createDir, 'previewModel.ts')
|
const modelPath = path.join(createDir, 'previewModel.ts')
|
||||||
const pdfViewerPath = path.join(createDir, 'PdfSourceViewer.vue')
|
const pdfViewerPath = path.join(createDir, 'PdfSourceViewer.vue')
|
||||||
|
const officeViewerPath = path.join(createDir, 'OfficeSourceViewer.vue')
|
||||||
const resultEditorPath = path.join(createDir, 'ResultEditorStep.vue')
|
const resultEditorPath = path.join(createDir, 'ResultEditorStep.vue')
|
||||||
assert.ok(existsSync(typesPath), '缺少向导类型定义')
|
assert.ok(existsSync(typesPath), '缺少向导类型定义')
|
||||||
assert.ok(existsSync(modelPath), '缺少来源映射模型')
|
assert.ok(existsSync(modelPath), '缺少来源映射模型')
|
||||||
assert.ok(existsSync(pdfViewerPath), '缺少 PDF 原文件预览组件')
|
assert.ok(existsSync(pdfViewerPath), '缺少 PDF 原文件预览组件')
|
||||||
|
assert.ok(existsSync(officeViewerPath), '缺少 Word/XLSX 原文件预览组件')
|
||||||
|
|
||||||
const [typesSource, modelSource, previewSource, pdfViewerSource, resultEditorSource] = await Promise.all([
|
const [typesSource, modelSource, previewSource, pdfViewerSource, officeViewerSource, resultEditorSource] = await Promise.all([
|
||||||
readFile(typesPath, 'utf8'),
|
readFile(typesPath, 'utf8'),
|
||||||
readFile(modelPath, 'utf8'),
|
readFile(modelPath, 'utf8'),
|
||||||
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
|
readFile(path.join(createDir, 'PreviewCompareStep.vue'), 'utf8'),
|
||||||
readFile(pdfViewerPath, 'utf8'),
|
readFile(pdfViewerPath, 'utf8'),
|
||||||
|
readFile(officeViewerPath, 'utf8'),
|
||||||
readFile(resultEditorPath, 'utf8'),
|
readFile(resultEditorPath, 'utf8'),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -179,6 +182,8 @@ assert.match(previewSource, /\.preview-editor\s*\{[\s\S]*?flex:\s*1 1 auto[\s\S]
|
|||||||
assert.match(previewSource, /@media \(max-width: 900px\)/, '第四步缺少窄屏上下布局')
|
assert.match(previewSource, /@media \(max-width: 900px\)/, '第四步缺少窄屏上下布局')
|
||||||
assert.match(previewSource, /<PdfSourceViewer[\s\S]*v-if="isPdfSource"/, 'PDF 文件没有切换到原文件查看组件')
|
assert.match(previewSource, /<PdfSourceViewer[\s\S]*v-if="isPdfSource"/, 'PDF 文件没有切换到原文件查看组件')
|
||||||
assert.match(previewSource, /:selected-item="selectedItem \?\? null"/, 'PDF 查看组件没有接收当前选中切片')
|
assert.match(previewSource, /:selected-item="selectedItem \?\? null"/, 'PDF 查看组件没有接收当前选中切片')
|
||||||
|
assert.match(previewSource, /<OfficeSourceViewer[\s\S]*v-else-if="isOfficeSource"/, 'Word/XLSX 没有切换到专用原文件查看组件')
|
||||||
|
assert.match(previewSource, /const isOfficeSource = computed[\s\S]*?'docx', 'xlsx'/, 'Word/XLSX 文件类型分流不完整')
|
||||||
assert.match(previewSource, /:data-preview-id="item\.id"/, '切片行缺少稳定的交互定位标识')
|
assert.match(previewSource, /:data-preview-id="item\.id"/, '切片行缺少稳定的交互定位标识')
|
||||||
assert.match(previewSource, /<div v-else ref="sourceViewerRef" class="source-viewer"/, '非 PDF 文件没有保留文本预览')
|
assert.match(previewSource, /<div v-else ref="sourceViewerRef" class="source-viewer"/, '非 PDF 文件没有保留文本预览')
|
||||||
assert.match(pdfViewerSource, /getDataProcessSourceRawUrl/, 'PDF 查看组件没有使用受控原文件地址')
|
assert.match(pdfViewerSource, /getDataProcessSourceRawUrl/, 'PDF 查看组件没有使用受控原文件地址')
|
||||||
@@ -194,6 +199,21 @@ assert.match(pdfViewerSource, /:aria-label="`PDF 预览:\$\{fileName\}`"/, 'PD
|
|||||||
assert.match(apiSource, /getDataProcessSourceRawUrl/, '前端 API 缺少 PDF 原文件预览地址')
|
assert.match(apiSource, /getDataProcessSourceRawUrl/, '前端 API 缺少 PDF 原文件预览地址')
|
||||||
assert.match(apiSource, /getDataProcessPdfPages/, '前端 API 缺少 PDF 页码映射接口')
|
assert.match(apiSource, /getDataProcessPdfPages/, '前端 API 缺少 PDF 页码映射接口')
|
||||||
assert.match(apiSource, /source-files\/\$\{encodeURIComponent\(fileId\)\}\/pdf-pages/, 'PDF 页码映射接口地址不正确')
|
assert.match(apiSource, /source-files\/\$\{encodeURIComponent\(fileId\)\}\/pdf-pages/, 'PDF 页码映射接口地址不正确')
|
||||||
|
assert.match(apiSource, /getDataProcessOfficePreview/, '前端 API 缺少 Word/XLSX 预览接口')
|
||||||
|
assert.match(apiSource, /source-files\/\$\{encodeURIComponent\(fileId\)\}\/office-preview/, 'Word/XLSX 预览接口地址不正确')
|
||||||
|
for (const marker of [
|
||||||
|
'docx-page',
|
||||||
|
'docx-table',
|
||||||
|
'xlsx-grid',
|
||||||
|
'sheet-selector',
|
||||||
|
'xlsx-pagination',
|
||||||
|
'getDataProcessOfficePreview',
|
||||||
|
'is-highlighted',
|
||||||
|
'打开原文件',
|
||||||
|
'重试',
|
||||||
|
]) {
|
||||||
|
assert.ok(officeViewerSource.includes(marker), `Word/XLSX 预览缺少结构或行为:${marker}`)
|
||||||
|
}
|
||||||
|
|
||||||
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
|
const taskSetupPath = path.join(createDir, 'TaskSetupStep.vue')
|
||||||
const structuredOptionsPath = path.join(createDir, 'StructuredOptionsPanel.vue')
|
const structuredOptionsPath = path.join(createDir, 'StructuredOptionsPanel.vue')
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
DataProcessExternalSourcePayload,
|
DataProcessExternalSourcePayload,
|
||||||
DataProcessExternalTestResult,
|
DataProcessExternalTestResult,
|
||||||
DataProcessPage,
|
DataProcessPage,
|
||||||
|
DataProcessOfficePreview,
|
||||||
DataProcessPdfPages,
|
DataProcessPdfPages,
|
||||||
DataProcessPreviewBuildPayload,
|
DataProcessPreviewBuildPayload,
|
||||||
DataProcessPreviewBuildResult,
|
DataProcessPreviewBuildResult,
|
||||||
@@ -30,6 +31,11 @@ export type {
|
|||||||
DataProcessExternalSourcePayload,
|
DataProcessExternalSourcePayload,
|
||||||
DataProcessExternalTestResult,
|
DataProcessExternalTestResult,
|
||||||
DataProcessPage,
|
DataProcessPage,
|
||||||
|
DataProcessDocxParagraph,
|
||||||
|
DataProcessDocxPreview,
|
||||||
|
DataProcessDocxTable,
|
||||||
|
DataProcessDocxTableRow,
|
||||||
|
DataProcessOfficePreview,
|
||||||
DataProcessPdfPageRange,
|
DataProcessPdfPageRange,
|
||||||
DataProcessPdfPages,
|
DataProcessPdfPages,
|
||||||
DataProcessPreviewBuildPayload,
|
DataProcessPreviewBuildPayload,
|
||||||
@@ -57,6 +63,10 @@ export type {
|
|||||||
DataProcessTaskCreatePayload,
|
DataProcessTaskCreatePayload,
|
||||||
DataProcessTaskUpdatePayload,
|
DataProcessTaskUpdatePayload,
|
||||||
DataProcessType,
|
DataProcessType,
|
||||||
|
DataProcessXlsxActiveSheet,
|
||||||
|
DataProcessXlsxPreview,
|
||||||
|
DataProcessXlsxPreviewRow,
|
||||||
|
DataProcessXlsxSheet,
|
||||||
} from '@/types/dataProcess'
|
} from '@/types/dataProcess'
|
||||||
|
|
||||||
export function getDataProcessTasks(params: {
|
export function getDataProcessTasks(params: {
|
||||||
@@ -137,6 +147,16 @@ export const getDataProcessPdfPages = (
|
|||||||
`/data-process/${encodeURIComponent(taskId)}/source-files/${encodeURIComponent(fileId)}/pdf-pages`,
|
`/data-process/${encodeURIComponent(taskId)}/source-files/${encodeURIComponent(fileId)}/pdf-pages`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const getDataProcessOfficePreview = (
|
||||||
|
taskId: string | number,
|
||||||
|
fileId: string | number,
|
||||||
|
params: { sheet_index?: number; offset?: number; limit?: number } = {},
|
||||||
|
) => get<DataProcessOfficePreview>(
|
||||||
|
`/data-process/${encodeURIComponent(taskId)}/source-files/${encodeURIComponent(fileId)}/office-preview`,
|
||||||
|
params,
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
|
||||||
export const testDataProcessExternalSource = (
|
export const testDataProcessExternalSource = (
|
||||||
taskId: string | number,
|
taskId: string | number,
|
||||||
payload: DataProcessExternalSourcePayload,
|
payload: DataProcessExternalSourcePayload,
|
||||||
|
|||||||
@@ -129,6 +129,67 @@ export interface DataProcessPdfPages {
|
|||||||
pages: DataProcessPdfPageRange[]
|
pages: DataProcessPdfPageRange[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DataProcessDocxParagraph {
|
||||||
|
type: 'paragraph'
|
||||||
|
text: string
|
||||||
|
style: string
|
||||||
|
heading_level: number | null
|
||||||
|
alignment: 'left' | 'center' | 'right' | 'justify' | 'distribute'
|
||||||
|
is_list: boolean
|
||||||
|
source_start: number
|
||||||
|
source_end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessDocxTableRow {
|
||||||
|
cells: string[]
|
||||||
|
source_start: number
|
||||||
|
source_end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessDocxTable {
|
||||||
|
type: 'table'
|
||||||
|
rows: DataProcessDocxTableRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessDocxPreview {
|
||||||
|
format: 'docx'
|
||||||
|
file_name: string
|
||||||
|
blocks: Array<DataProcessDocxParagraph | DataProcessDocxTable>
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessXlsxSheet {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessXlsxPreviewRow {
|
||||||
|
row_number: number
|
||||||
|
record_index: number
|
||||||
|
values: unknown[]
|
||||||
|
record: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessXlsxActiveSheet {
|
||||||
|
index: number
|
||||||
|
name: string
|
||||||
|
columns: string[]
|
||||||
|
rows: DataProcessXlsxPreviewRow[]
|
||||||
|
offset: number
|
||||||
|
limit: number
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataProcessXlsxPreview {
|
||||||
|
format: 'xlsx'
|
||||||
|
file_name: string
|
||||||
|
sheets: DataProcessXlsxSheet[]
|
||||||
|
active_sheet: DataProcessXlsxActiveSheet
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataProcessOfficePreview = DataProcessDocxPreview | DataProcessXlsxPreview
|
||||||
|
|
||||||
export interface DataProcessExternalSourcePayload {
|
export interface DataProcessExternalSourcePayload {
|
||||||
type: 'postgresql'
|
type: 'postgresql'
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -934,6 +934,7 @@ onMounted(() => {
|
|||||||
:items="activePreviewItems"
|
:items="activePreviewItems"
|
||||||
:process-type="processType"
|
:process-type="processType"
|
||||||
:file-name="activePreviewFile?.name ?? ''"
|
:file-name="activePreviewFile?.name ?? ''"
|
||||||
|
:file-format="activePreviewFile?.fileFormat"
|
||||||
:task-id="taskId"
|
:task-id="taskId"
|
||||||
:source-file-id="activePreviewFile?.sourceFileId ?? activePreviewFile?.uid ?? null"
|
:source-file-id="activePreviewFile?.sourceFileId ?? activePreviewFile?.uid ?? null"
|
||||||
:files="previewFiles"
|
:files="previewFiles"
|
||||||
|
|||||||
622
frontend/src/views/data-process/create/OfficeSourceViewer.vue
Normal file
622
frontend/src/views/data-process/create/OfficeSourceViewer.vue
Normal file
@@ -0,0 +1,622 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
getDataProcessOfficePreview,
|
||||||
|
getDataProcessSourceRawUrl,
|
||||||
|
type DataProcessDocxPreview,
|
||||||
|
type DataProcessDocxTableRow,
|
||||||
|
type DataProcessOfficePreview,
|
||||||
|
type DataProcessXlsxPreview,
|
||||||
|
type DataProcessXlsxPreviewRow,
|
||||||
|
} from '@/api/modules/dataProcess'
|
||||||
|
import type { PreviewItem } from './types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskId: string | number | null
|
||||||
|
sourceFileId: string | number | null
|
||||||
|
fileName: string
|
||||||
|
fileFormat?: string
|
||||||
|
selectedItem: PreviewItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const XLSX_PAGE_SIZE = 100
|
||||||
|
const scrollRef = ref<HTMLElement | null>(null)
|
||||||
|
const preview = shallowRef<DataProcessOfficePreview | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const activeSheetIndex = ref(0)
|
||||||
|
const pageOffset = ref(0)
|
||||||
|
let loadSequence = 0
|
||||||
|
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
const normalizedFormat = computed(() => (
|
||||||
|
props.fileFormat?.toLowerCase().replace(/^\./, '')
|
||||||
|
|| props.fileName.split('.').pop()?.toLowerCase()
|
||||||
|
|| ''
|
||||||
|
))
|
||||||
|
const isDocx = computed(() => normalizedFormat.value === 'docx')
|
||||||
|
const docxPreview = computed((): DataProcessDocxPreview | null => (
|
||||||
|
preview.value?.format === 'docx' ? preview.value : null
|
||||||
|
))
|
||||||
|
const xlsxPreview = computed((): DataProcessXlsxPreview | null => (
|
||||||
|
preview.value?.format === 'xlsx' ? preview.value : null
|
||||||
|
))
|
||||||
|
const sourceUrl = computed(() => (
|
||||||
|
props.taskId != null && props.sourceFileId != null
|
||||||
|
? getDataProcessSourceRawUrl(props.taskId, props.sourceFileId)
|
||||||
|
: ''
|
||||||
|
))
|
||||||
|
const visibleRowRange = computed(() => {
|
||||||
|
const sheet = xlsxPreview.value?.active_sheet
|
||||||
|
if (!sheet || !sheet.rows.length) return '当前工作表没有可预览记录'
|
||||||
|
const start = sheet.offset + 1
|
||||||
|
const end = sheet.offset + sheet.rows.length
|
||||||
|
return `第 ${start}–${end} 条记录`
|
||||||
|
})
|
||||||
|
|
||||||
|
function overlapsSelection(start: number, end: number) {
|
||||||
|
const item = props.selectedItem
|
||||||
|
if (!item || item.sourceStart == null || item.sourceEnd == null) return false
|
||||||
|
return end > item.sourceStart && start < item.sourceEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableRowHighlighted(row: DataProcessDocxTableRow) {
|
||||||
|
return overlapsSelection(row.source_start, row.source_end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(stableValue)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, item]) => [key, stableValue(item)]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordKey(value: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(stableValue(value))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRecordKey = computed(() => {
|
||||||
|
const content = props.selectedItem?.originalContent
|
||||||
|
if (!content) return ''
|
||||||
|
try {
|
||||||
|
return recordKey(JSON.parse(content))
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function xlsxRowHighlighted(row: DataProcessXlsxPreviewRow) {
|
||||||
|
return Boolean(selectedRecordKey.value && recordKey(row.record) === selectedRecordKey.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCell(value: unknown) {
|
||||||
|
if (value == null || value === '') return '—'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function locateSelectedItem() {
|
||||||
|
await nextTick()
|
||||||
|
const selected = scrollRef.value?.querySelector<HTMLElement>(
|
||||||
|
'.docx-block.is-highlighted, .docx-table-row.is-highlighted, .xlsx-row.is-highlighted',
|
||||||
|
)
|
||||||
|
selected?.scrollIntoView({
|
||||||
|
block: 'center',
|
||||||
|
inline: 'nearest',
|
||||||
|
behavior: prefersReducedMotion ? 'auto' : 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview(options: { reset?: boolean } = {}) {
|
||||||
|
const sequence = ++loadSequence
|
||||||
|
if (options.reset) {
|
||||||
|
activeSheetIndex.value = 0
|
||||||
|
pageOffset.value = 0
|
||||||
|
preview.value = null
|
||||||
|
}
|
||||||
|
errorMessage.value = ''
|
||||||
|
if (props.taskId == null || props.sourceFileId == null) {
|
||||||
|
errorMessage.value = '缺少原文件标识,无法加载预览'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await getDataProcessOfficePreview(
|
||||||
|
props.taskId,
|
||||||
|
props.sourceFileId,
|
||||||
|
isDocx.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
sheet_index: activeSheetIndex.value,
|
||||||
|
offset: pageOffset.value,
|
||||||
|
limit: XLSX_PAGE_SIZE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (sequence !== loadSequence) return
|
||||||
|
preview.value = result
|
||||||
|
if (result.format === 'xlsx') activeSheetIndex.value = result.active_sheet.index
|
||||||
|
await locateSelectedItem()
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence !== loadSequence) return
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : 'Office 原文件预览加载失败'
|
||||||
|
} finally {
|
||||||
|
if (sequence === loadSequence) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSheet(value: string | number) {
|
||||||
|
activeSheetIndex.value = Number(value)
|
||||||
|
pageOffset.value = 0
|
||||||
|
void loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function previousPage() {
|
||||||
|
pageOffset.value = Math.max(0, pageOffset.value - XLSX_PAGE_SIZE)
|
||||||
|
void loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (!xlsxPreview.value?.active_sheet.has_more) return
|
||||||
|
pageOffset.value += XLSX_PAGE_SIZE
|
||||||
|
void loadPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.taskId, props.sourceFileId, normalizedFormat.value],
|
||||||
|
() => void loadPreview({ reset: true }),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.selectedItem?.id,
|
||||||
|
() => void locateSelectedItem(),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="office-source-viewer"
|
||||||
|
:aria-label="`${normalizedFormat.toUpperCase()} 预览:${fileName}`"
|
||||||
|
>
|
||||||
|
<div class="office-toolbar">
|
||||||
|
<template v-if="xlsxPreview">
|
||||||
|
<div class="sheet-selector">
|
||||||
|
<span>工作表</span>
|
||||||
|
<el-select
|
||||||
|
:model-value="activeSheetIndex"
|
||||||
|
size="small"
|
||||||
|
aria-label="选择 Excel 工作表"
|
||||||
|
@update:model-value="changeSheet"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="sheet in xlsxPreview.sheets"
|
||||||
|
:key="sheet.index"
|
||||||
|
:label="sheet.name"
|
||||||
|
:value="sheet.index"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<span>{{ visibleRowRange }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>Word 网页版式预览</span>
|
||||||
|
<span v-if="docxPreview?.truncated">文档较长,仅展示前 2,000 个内容块</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="loading && preview" class="toolbar-loading" role="status">
|
||||||
|
<i class="fa fa-spinner fa-spin" /> 正在更新预览
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
v-if="sourceUrl"
|
||||||
|
class="source-file-link"
|
||||||
|
:href="sourceUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<i class="fa fa-external-link" /> 打开原文件
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && !preview" class="office-state" role="status">
|
||||||
|
<i class="fa fa-spinner fa-spin" />
|
||||||
|
<strong>正在加载原文件预览</strong>
|
||||||
|
<span>{{ isDocx ? '正在还原 Word 文档结构' : '正在读取 Excel 工作表' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="errorMessage" class="office-state is-error" role="alert">
|
||||||
|
<i class="fa fa-exclamation-circle" />
|
||||||
|
<strong>{{ isDocx ? 'Word 预览失败' : 'Excel 预览失败' }}</strong>
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" size="small" @click="loadPreview()">重试</el-button>
|
||||||
|
<el-button v-if="sourceUrl" tag="a" :href="sourceUrl" target="_blank" size="small">
|
||||||
|
打开原文件
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="docxPreview" ref="scrollRef" class="docx-scroll">
|
||||||
|
<article class="docx-page">
|
||||||
|
<template v-for="(block, index) in docxPreview.blocks" :key="index">
|
||||||
|
<component
|
||||||
|
:is="block.heading_level ? `h${block.heading_level}` : 'p'"
|
||||||
|
v-if="block.type === 'paragraph'"
|
||||||
|
class="docx-block"
|
||||||
|
:class="{
|
||||||
|
'is-highlighted': overlapsSelection(block.source_start, block.source_end),
|
||||||
|
'is-list': block.is_list,
|
||||||
|
}"
|
||||||
|
:style="{ textAlign: block.alignment }"
|
||||||
|
:data-source-start="block.source_start"
|
||||||
|
>
|
||||||
|
{{ block.text }}
|
||||||
|
</component>
|
||||||
|
<div v-else class="docx-table-wrap">
|
||||||
|
<table class="docx-table">
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(row, rowIndex) in block.rows"
|
||||||
|
:key="rowIndex"
|
||||||
|
class="docx-table-row"
|
||||||
|
:class="{ 'is-highlighted': tableRowHighlighted(row) }"
|
||||||
|
:data-source-start="row.source_start"
|
||||||
|
>
|
||||||
|
<td v-for="(cell, cellIndex) in row.cells" :key="cellIndex">{{ cell || ' ' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="!docxPreview.blocks.length" class="office-empty">文档中没有可预览的正文</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="xlsxPreview">
|
||||||
|
<div ref="scrollRef" class="xlsx-scroll">
|
||||||
|
<table v-if="xlsxPreview.active_sheet.columns.length" class="xlsx-grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="row-number-cell">#</th>
|
||||||
|
<th
|
||||||
|
v-for="column in xlsxPreview.active_sheet.columns"
|
||||||
|
:key="column"
|
||||||
|
:title="column"
|
||||||
|
>
|
||||||
|
{{ column }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="row in xlsxPreview.active_sheet.rows"
|
||||||
|
:key="row.row_number"
|
||||||
|
class="xlsx-row"
|
||||||
|
:class="{ 'is-highlighted': xlsxRowHighlighted(row) }"
|
||||||
|
>
|
||||||
|
<th class="row-number-cell">{{ row.row_number }}</th>
|
||||||
|
<td
|
||||||
|
v-for="(value, cellIndex) in row.values"
|
||||||
|
:key="cellIndex"
|
||||||
|
:title="displayCell(value)"
|
||||||
|
>
|
||||||
|
{{ displayCell(value) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div v-else class="office-empty">当前工作表没有可预览记录</div>
|
||||||
|
</div>
|
||||||
|
<div class="xlsx-pagination">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:disabled="pageOffset === 0 || loading"
|
||||||
|
aria-label="上一页工作表记录"
|
||||||
|
@click="previousPage"
|
||||||
|
>
|
||||||
|
<i class="fa fa-angle-left" /> 上一页
|
||||||
|
</el-button>
|
||||||
|
<span>{{ visibleRowRange }}</span>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:disabled="!xlsxPreview.active_sheet.has_more || loading"
|
||||||
|
aria-label="下一页工作表记录"
|
||||||
|
@click="nextPage"
|
||||||
|
>
|
||||||
|
下一页 <i class="fa fa-angle-right" />
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.office-source-viewer {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f4f6f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-toolbar {
|
||||||
|
display: flex;
|
||||||
|
min-height: 42px;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: #7d8798;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e8ee;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-selector {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
> span {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-select) {
|
||||||
|
width: min(220px, 28vw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-file-link {
|
||||||
|
flex: none;
|
||||||
|
margin-left: auto;
|
||||||
|
color: #5147df;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-loading {
|
||||||
|
color: #5b50f2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-state {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 28px;
|
||||||
|
color: #667085;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
color: #5b50f2;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> strong {
|
||||||
|
color: #344054;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
max-width: 420px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error > i {
|
||||||
|
color: #d92d20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 22px;
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-page {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
min-height: calc(100% - 2px);
|
||||||
|
padding: 54px clamp(30px, 7%, 68px);
|
||||||
|
margin: 0 auto;
|
||||||
|
color: #262b34;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dfe3e9;
|
||||||
|
box-shadow: 0 2px 10px rgb(15 23 42 / 8%);
|
||||||
|
font-family: "Songti SC", SimSun, serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-block {
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
|
||||||
|
&.is-list {
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-highlighted {
|
||||||
|
background: #fff0b8;
|
||||||
|
box-shadow: inset 3px 0 #f0b429;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.docx-block { font-size: 22px; }
|
||||||
|
h2.docx-block { font-size: 19px; }
|
||||||
|
h3.docx-block { font-size: 17px; }
|
||||||
|
h4.docx-block,
|
||||||
|
h5.docx-block,
|
||||||
|
h6.docx-block { font-size: 15px; }
|
||||||
|
|
||||||
|
.docx-table-wrap {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 12px 0 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid #9da5b2;
|
||||||
|
vertical-align: top;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-table-row.is-highlighted td {
|
||||||
|
background: #fff0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlsx-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: #fff;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlsx-grid {
|
||||||
|
min-width: 100%;
|
||||||
|
color: #344054;
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: separate;
|
||||||
|
table-layout: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 320px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-right: 1px solid #e4e7ec;
|
||||||
|
border-bottom: 1px solid #e4e7ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 2;
|
||||||
|
top: 0;
|
||||||
|
color: #475467;
|
||||||
|
background: #f2f4f7;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover td,
|
||||||
|
tbody tr:hover th {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-number-cell {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 1;
|
||||||
|
left: 0;
|
||||||
|
min-width: 54px !important;
|
||||||
|
width: 54px;
|
||||||
|
color: #98a2b3;
|
||||||
|
text-align: center !important;
|
||||||
|
background: #f8fafc;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead .row-number-cell {
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlsx-row.is-highlighted {
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
background: #fff0b8;
|
||||||
|
box-shadow: inset 0 2px #f0b429, inset 0 -2px #f0b429;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlsx-pagination {
|
||||||
|
display: flex;
|
||||||
|
min-height: 46px;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: #7d8798;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e5e8ee;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-empty {
|
||||||
|
display: flex;
|
||||||
|
min-height: 180px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.office-source-viewer {
|
||||||
|
height: 360px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.office-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-scroll {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-page {
|
||||||
|
padding: 34px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.docx-scroll,
|
||||||
|
.xlsx-scroll {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docx-block {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import OfficeSourceViewer from './OfficeSourceViewer.vue'
|
||||||
import PdfSourceViewer from './PdfSourceViewer.vue'
|
import PdfSourceViewer from './PdfSourceViewer.vue'
|
||||||
import { sourceLines } from './previewModel'
|
import { sourceLines } from './previewModel'
|
||||||
import type { PreviewItem, ProcessType } from './types'
|
import type { PreviewItem, ProcessType } from './types'
|
||||||
@@ -10,6 +11,7 @@ const props = defineProps<{
|
|||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
processType: ProcessType
|
processType: ProcessType
|
||||||
fileName: string
|
fileName: string
|
||||||
|
fileFormat?: string
|
||||||
taskId: string | number | null
|
taskId: string | number | null
|
||||||
sourceFileId: string | number | null
|
sourceFileId: string | number | null
|
||||||
files: { id: string; name: string; count: number; modifiedCount: number }[]
|
files: { id: string; name: string; count: number; modifiedCount: number }[]
|
||||||
@@ -34,7 +36,13 @@ const editorDraft = ref('')
|
|||||||
const lines = computed(() => sourceLines(props.sourceText))
|
const lines = computed(() => sourceLines(props.sourceText))
|
||||||
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
|
||||||
const editingItem = computed(() => props.items.find((item) => item.id === editingItemId.value))
|
const editingItem = computed(() => props.items.find((item) => item.id === editingItemId.value))
|
||||||
const isPdfSource = computed(() => /\.pdf$/i.test(props.fileName))
|
const normalizedFileFormat = computed(() => (
|
||||||
|
props.fileFormat?.toLowerCase().replace(/^\./, '')
|
||||||
|
|| props.fileName.split('.').pop()?.toLowerCase()
|
||||||
|
|| ''
|
||||||
|
))
|
||||||
|
const isPdfSource = computed(() => normalizedFileFormat.value === 'pdf')
|
||||||
|
const isOfficeSource = computed(() => ['docx', 'xlsx'].includes(normalizedFileFormat.value))
|
||||||
|
|
||||||
const filteredItems = computed(() => props.items.filter((item, index) => {
|
const filteredItems = computed(() => props.items.filter((item, index) => {
|
||||||
const matchesSearch = !search.value.trim()
|
const matchesSearch = !search.value.trim()
|
||||||
@@ -106,7 +114,7 @@ watch(selectedItem, async (item) => {
|
|||||||
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
|
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPdfSource.value || item.sourceStart == null) return
|
if (isPdfSource.value || isOfficeSource.value || item.sourceStart == null) return
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
|
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
|
||||||
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
|
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
|
||||||
@@ -176,6 +184,14 @@ function lineRange(item: PreviewItem) {
|
|||||||
:file-name="fileName"
|
:file-name="fileName"
|
||||||
:selected-item="selectedItem ?? null"
|
:selected-item="selectedItem ?? null"
|
||||||
/>
|
/>
|
||||||
|
<OfficeSourceViewer
|
||||||
|
v-else-if="isOfficeSource"
|
||||||
|
:task-id="taskId"
|
||||||
|
:source-file-id="sourceFileId"
|
||||||
|
:file-name="fileName"
|
||||||
|
:file-format="normalizedFileFormat"
|
||||||
|
:selected-item="selectedItem ?? null"
|
||||||
|
/>
|
||||||
<div v-else ref="sourceViewerRef" class="source-viewer" tabindex="0" aria-label="源文件内容">
|
<div v-else ref="sourceViewerRef" class="source-viewer" tabindex="0" aria-label="源文件内容">
|
||||||
<div
|
<div
|
||||||
v-for="line in lines"
|
v-for="line in lines"
|
||||||
|
|||||||
Reference in New Issue
Block a user