Files
YG_FT/frontend/src/views/data-process/create/PreviewCompareStep.vue
caoxiaozhu 5c469a1778 feat: 数据处理向导新增生成控制选项
提取 GenerationOptionsPanel 组件统一管理生成模型、温度、最大长度、JSON 模式与质量过滤配置,StructuredProcessOptions 与 UnstructuredProcessOptions 继承 GenerationControlOptions,CreateView、TaskSetupStep 及各子步骤同步接入,详情/列表小幅调整,回归脚本扩充生成选项断言。
2026-07-13 11:47:33 +08:00

586 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { sourceLines } from './previewModel'
import type { PreviewItem, ProcessType } from './types'
const props = defineProps<{
sourceText: string
items: PreviewItem[]
selectedId: string | null
processType: ProcessType
fileName: string
files: { id: string; name: string; count: number; modifiedCount: number }[]
selectedFileId: string | null
}>()
const emit = defineEmits<{
'update:selectedId': [value: string]
'update:selectedFileId': [value: string]
'update:item-content': [id: string, value: string]
'remove:item': [id: string]
}>()
const sourceViewerRef = ref<HTMLElement | null>(null)
const search = ref('')
const currentPage = ref(1)
const PREVIEW_PAGE_SIZE = 6
const editingItemId = ref<string | null>(null)
const editorDraft = ref('')
const lines = computed(() => sourceLines(props.sourceText))
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 filteredItems = computed(() => props.items.filter((item, index) => {
const matchesSearch = !search.value.trim()
|| item.editedContent.toLowerCase().includes(search.value.trim().toLowerCase())
|| String(index + 1).includes(search.value.trim())
return matchesSearch
}))
const pagedItems = computed(() => {
const start = (currentPage.value - 1) * PREVIEW_PAGE_SIZE
return filteredItems.value.slice(start, start + PREVIEW_PAGE_SIZE)
})
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
function isLineHighlighted(lineStart: number, lineEnd: number) {
const item = selectedItem.value
if (!item || item.sourceStart == null || item.sourceEnd == null) return false
return lineEnd >= item.sourceStart && lineStart <= item.sourceEnd
}
function selectItem(id: string) {
emit('update:selectedId', id)
}
function openEditor(item: PreviewItem) {
selectItem(item.id)
editingItemId.value = item.id
editorDraft.value = item.editedContent
}
function closeEditor() {
editingItemId.value = null
editorDraft.value = ''
}
function saveEditor() {
if (!editingItem.value) return
emit('update:item-content', editingItem.value.id, editorDraft.value)
closeEditor()
}
function removeItem(item: PreviewItem) {
selectItem(item.id)
emit('remove:item', item.id)
}
function handlePageChange() {
closeEditor()
}
watch(search, () => {
currentPage.value = 1
closeEditor()
})
watch(() => props.selectedFileId, closeEditor)
watch(selectedItem, async (item) => {
if (!item) return
const visibleIndex = filteredItems.value.findIndex((entry) => entry.id === item.id)
if (visibleIndex >= 0) {
currentPage.value = Math.floor(visibleIndex / PREVIEW_PAGE_SIZE) + 1
}
if (item.sourceStart == null) return
await nextTick()
const target = sourceViewerRef.value?.querySelector<HTMLElement>(`[data-source-start="${item.sourceStart}"]`)
?? sourceViewerRef.value?.querySelector<HTMLElement>('.source-line.is-highlighted')
target?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}, { immediate: true })
function itemNumber(item: PreviewItem) {
return props.items.findIndex((entry) => entry.id === item.id) + 1
}
function lineRange(item: PreviewItem) {
if (item.sourceStartLine == null || item.sourceEndLine == null) return '手动新增,无源文件定位'
return item.sourceStartLine === item.sourceEndLine
? `来源:第 ${item.sourceStartLine}`
: `来源:第 ${item.sourceStartLine}${item.sourceEndLine}`
}
</script>
<template>
<section class="preview-step">
<div class="preview-file-switcher">
<div class="file-switcher-control">
<span>当前文件</span>
<el-select
:model-value="selectedFileId"
filterable
placeholder="选择文件"
aria-label="选择当前预览文件"
@update:model-value="emit('update:selectedFileId', $event)"
>
<el-option
v-for="file in files"
:key="file.id"
:label="file.name"
:value="file.id"
>
<div class="file-option">
<strong :title="file.name">{{ file.name }}</strong>
<span>{{ file.count.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}</span>
<em v-if="file.modifiedCount">{{ file.modifiedCount }} 处已修改</em>
</div>
</el-option>
</el-select>
</div>
<span class="file-switcher-summary">
{{ files.length }} 个文件 · 当前文件 {{ items.length.toLocaleString() }} {{ processType === 'unstructured' ? '个切片' : '条记录' }}
</span>
</div>
<div class="preview-workspace">
<div class="source-pane">
<div class="pane-header">
<div>
<strong>源文件 · {{ fileName }}</strong>
</div>
</div>
<div ref="sourceViewerRef" class="source-viewer" tabindex="0" aria-label="源文件内容">
<div
v-for="line in lines"
:key="line.number"
class="source-line"
:class="{ 'is-highlighted': isLineHighlighted(line.start, line.end) }"
:data-source-start="line.start"
>
<span class="line-number">{{ line.number }}</span>
<span class="line-content">{{ line.content || ' ' }}</span>
</div>
</div>
</div>
<div class="preview-pane">
<div class="pane-header">
<strong>{{ processType === 'unstructured' ? '切片内容' : '记录内容' }}</strong>
<span> {{ items.length.toLocaleString() }} </span>
</div>
<template v-if="!editingItem">
<div class="preview-toolbar">
<el-input v-model="search" clearable placeholder="搜索编号或内容" size="small">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
</div>
<div class="preview-list" aria-label="预览条目列表">
<div
v-for="item in pagedItems"
:key="item.id"
class="preview-item"
:class="{ 'is-active': item.id === selectedItem?.id }"
role="button"
tabindex="0"
@click="selectItem(item.id)"
@keydown.enter="selectItem(item.id)"
@keydown.space.prevent="selectItem(item.id)"
>
<span class="item-name">{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(item)).padStart(3, '0') }}</span>
<span class="item-source">{{ lineRange(item) }}</span>
<span class="item-actions">
<el-button link aria-label="编辑切片" title="编辑" @click.stop="openEditor(item)">
<i class="fa fa-pencil" />
</el-button>
<el-button link type="danger" aria-label="删除切片" title="删除" @click.stop="removeItem(item)">
<i class="fa fa-trash-o" />
</el-button>
</span>
</div>
<div v-if="!filteredItems.length" class="empty-result">没有符合条件的内容</div>
</div>
<el-pagination
v-if="filteredItems.length > PREVIEW_PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PREVIEW_PAGE_SIZE"
:total="filteredItems.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="preview-pagination"
@current-change="handlePageChange"
/>
</template>
<template v-else>
<div class="preview-editor">
<div class="editor-heading">
<div>
<strong>{{ processType === 'unstructured' ? '切片' : '记录' }} #{{ String(itemNumber(editingItem)).padStart(3, '0') }} 正文</strong>
<small>{{ lineRange(editingItem) }}</small>
</div>
</div>
<el-input
v-model="editorDraft"
type="textarea"
:rows="7"
resize="none"
/>
<div class="editor-actions">
<div>
<el-button @click="closeEditor">取消</el-button>
<el-button type="primary" @click="saveEditor">保存修改</el-button>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.preview-step {
min-width: 0;
}
.preview-file-switcher {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
padding: 10px 14px;
margin-bottom: 12px;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 9px;
}
.file-switcher-control {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
> span {
flex: none;
color: #667085;
font-size: 12px;
}
:deep(.el-select) {
width: min(360px, 42vw);
}
}
.file-switcher-summary {
color: #7d8798;
font-size: 12px;
text-align: right;
white-space: nowrap;
}
.file-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
max-width: 440px;
strong,
span,
em {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: #344054;
font-size: 13px;
font-style: normal;
}
span {
color: #98a2b3;
font-size: 11px;
}
em {
color: #5549dc;
font-size: 11px;
font-style: normal;
}
}
.preview-workspace {
display: grid;
grid-template-columns: minmax(0, 58fr) minmax(380px, 42fr);
height: clamp(560px, calc(100vh - 370px), 720px);
overflow: hidden;
border: 1px solid #e2e5ec;
border-radius: 9px;
}
.source-pane,
.preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
background: #fff;
}
.source-pane {
border-right: 1px solid #e5e8ee;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 0 15px;
color: #313949;
background: #fff;
border-bottom: 1px solid #e8ebf0;
font-size: 13px;
> div {
display: flex;
align-items: center;
gap: 10px;
}
> span {
color: #8a93a3;
font-size: 12px;
}
}
.source-viewer {
flex: 1;
height: 538px;
padding: 12px 0 24px;
overflow: auto;
outline: none;
background: #fff;
scroll-behavior: smooth;
}
.source-line {
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
min-height: 29px;
color: #424b5d;
font-size: 12px;
line-height: 1.8;
border-left: 3px solid transparent;
transition: background-color 0.18s ease, border-color 0.18s ease;
&.is-highlighted {
background: #eeedff;
border-left-color: #5b50f2;
}
}
.line-number {
padding-right: 11px;
color: #a0a7b4;
text-align: right;
user-select: none;
}
.line-content {
min-width: 0;
padding: 2px 14px 2px 0;
white-space: pre-wrap;
word-break: break-word;
}
.preview-toolbar {
padding: 10px 12px;
border-bottom: 1px solid #edf0f5;
}
.preview-list {
flex: 1 1 auto;
min-height: 0;
padding: 8px;
overflow: auto;
border-bottom: 1px solid #e8ebf0;
}
.preview-pagination {
display: flex;
justify-content: flex-end;
min-height: 38px;
padding: 6px 12px;
border-bottom: 1px solid #e8ebf0;
}
.preview-item {
display: grid;
grid-template-columns: 94px minmax(130px, 1fr) auto;
align-items: center;
width: 100%;
min-height: 40px;
padding: 0 10px;
color: #6b7382;
text-align: left;
background: #fff;
border: 1px solid transparent;
border-bottom-color: #edf0f5;
cursor: pointer;
&:hover {
background: #fafaff;
}
&.is-active {
color: #3f36c8;
background: #f6f5ff;
border-color: #5b50f2;
border-radius: 6px;
}
}
.item-name {
color: #344054;
font-size: 12px;
font-weight: 650;
}
.item-source,
.item-actions {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-actions {
display: flex;
align-items: center;
gap: 2px;
:deep(.el-button) {
width: 28px;
min-height: 28px;
padding: 0;
margin: 0;
}
}
.empty-result {
padding: 34px 16px;
color: #98a2b3;
text-align: center;
font-size: 12px;
}
.preview-editor {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
padding: 13px;
overflow-y: auto;
}
.editor-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
strong,
small {
display: block;
}
strong {
color: #344054;
font-size: 12px;
}
small {
margin-top: 4px;
color: #98a2b3;
font-size: 10px;
}
}
.preview-editor :deep(.el-textarea__inner) {
min-height: 260px !important;
color: #3f4756;
font-size: 12px;
line-height: 1.75;
}
.editor-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: auto;
padding-top: 8px;
}
@media (max-width: 1100px) {
.preview-workspace {
grid-template-columns: minmax(0, 52fr) minmax(360px, 48fr);
}
.preview-item {
grid-template-columns: 88px minmax(0, 1fr) auto;
}
}
@media (max-width: 900px) {
.preview-file-switcher {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.file-switcher-control {
width: 100%;
:deep(.el-select) {
flex: 1;
width: auto;
}
}
.file-switcher-summary {
text-align: left;
white-space: normal;
}
.preview-workspace {
grid-template-columns: minmax(0, 1fr);
height: auto;
}
.source-pane {
border-right: 0;
border-bottom: 1px solid #e5e8ee;
}
.source-viewer {
height: 320px;
flex: none;
}
}
</style>