2 Commits

Author SHA1 Message Date
caoxiaozhu
8a6a6574bb feat(data-process): 完善 Word 与 Excel 原文件预览 2026-07-27 16:24:53 +08:00
caoxiaozhu
9025437a37 feat(data-process): 增加 Office 原文件预览接口 2026-07-27 16:12:50 +08:00
9 changed files with 1225 additions and 8 deletions

View File

@@ -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,

View 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",
]

View File

@@ -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(

View File

@@ -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')

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View 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>

View File

@@ -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"