feat(data-process): 完善 Word 与 Excel 原文件预览

This commit is contained in:
caoxiaozhu
2026-07-27 16:24:53 +08:00
parent 9025437a37
commit 8a6a6574bb
6 changed files with 743 additions and 3 deletions

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"