第一次提交

This commit is contained in:
wangjiming
2026-07-27 09:12:47 +08:00
commit b4ff5db17b
579 changed files with 48768 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DatasetSplitOptions } from './types'
const props = withDefaults(defineProps<{
modelValue: DatasetSplitOptions
ariaLabelPrefix?: string
}>(), {
ariaLabelPrefix: '',
})
const emit = defineEmits<{
'update:modelValue': [value: DatasetSplitOptions]
}>()
const splitTotal = computed(() => {
const { train, validation, test } = props.modelValue
return train + validation + test
})
function updateField(field: keyof DatasetSplitOptions, value: number | undefined) {
emit('update:modelValue', {
...props.modelValue,
[field]: Math.min(100, Math.max(0, Number(value) || 0)),
})
}
function ariaLabel(label: string) {
return `${props.ariaLabelPrefix}${label}比例`
}
</script>
<template>
<div class="generation-option-row dataset-split-row">
<div class="generation-option-copy">
<strong>数据集划分</strong>
<small>按比例将生成后的问答对随机划分为训练集验证集和测试集</small>
</div>
<div class="dataset-split-config">
<div class="dataset-split-grid">
<label class="dataset-split-field">
<span>训练集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.train"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('训练集')"
@update:model-value="updateField('train', $event)"
/>
<span>%</span>
</span>
</label>
<label class="dataset-split-field">
<span>验证集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.validation"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('验证集')"
@update:model-value="updateField('validation', $event)"
/>
<span>%</span>
</span>
</label>
<label class="dataset-split-field">
<span>测试集</span>
<span class="dataset-split-input">
<el-input-number
:model-value="modelValue.test"
:min="0"
:max="100"
:step="1"
:precision="0"
controls-position="right"
:aria-label="ariaLabel('测试集')"
@update:model-value="updateField('test', $event)"
/>
<span>%</span>
</span>
</label>
</div>
<div class="dataset-split-summary" :class="{ 'is-invalid': splitTotal !== 100 }">
<span>总计 {{ splitTotal }}%</span>
<span v-if="splitTotal !== 100" role="alert">
训练集验证集和测试集比例总和必须为 100%
</span>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.dataset-split-row {
align-items: flex-start;
}
.dataset-split-config {
display: grid;
gap: 10px;
flex: 0 0 460px;
}
.dataset-split-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.dataset-split-field {
display: grid;
gap: 7px;
color: #5f6878;
font-size: 12px;
}
.dataset-split-input {
display: flex;
align-items: center;
gap: 8px;
:deep(.el-input-number) {
width: 100%;
}
}
.dataset-split-summary {
display: flex;
justify-content: space-between;
gap: 12px;
color: #2ca66a;
font-size: 12px;
&.is-invalid {
color: #dc2626;
}
}
@media (max-width: 900px) {
.dataset-split-row {
flex-direction: column;
align-items: stretch;
}
.dataset-split-config {
flex: auto;
}
.dataset-split-grid {
grid-template-columns: minmax(0, 1fr);
}
.dataset-split-summary {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,382 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ModelItem } from '@/types'
import type { GenerationControlOptions } from './types'
const props = defineProps<{
options: GenerationControlOptions
models?: ModelItem[]
section: 'model' | 'quality'
validationMessage?: string
}>()
const emit = defineEmits<{
'update:options': [value: GenerationControlOptions]
}>()
function updateField<K extends keyof GenerationControlOptions>(
field: K,
value: GenerationControlOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateQualityRules(value: Array<string | number>) {
const rules = value.filter((item): item is string => typeof item === 'string')
emit('update:options', {
...props.options,
filterLowQuality: rules.includes('low_quality'),
filterShortContent: rules.includes('short_content'),
})
}
const selectedQualityRules = () => [
props.options.filterLowQuality ? 'low_quality' : '',
props.options.filterShortContent ? 'short_content' : '',
].filter(Boolean)
function sectionValidationMessage() {
if (!props.validationMessage) return ''
const isModelMessage = props.validationMessage.includes('数据生成模型')
if (props.section === 'model') return isModelMessage ? props.validationMessage : ''
return isModelMessage ? '' : props.validationMessage
}
</script>
<template>
<div v-if="section === 'model'" class="generation-config-group model-config-group">
<div class="model-field">
<div class="field-copy">
<strong>数据生成模型 <em>*</em></strong>
<small>选择负责生成问答指令或结构化内容的大模型</small>
</div>
<el-select
class="model-select"
:model-value="options.generationModelId"
filterable
placeholder="请选择用于生成数据的模型"
aria-label="数据生成模型"
@update:model-value="updateField('generationModelId', $event)"
>
<el-option
v-for="model in models || []"
:key="model.id"
:label="model.name"
:value="model.id"
>
<div class="model-option">
<span>{{ model.name }}</span>
<small>{{ model.model_source === 'api' ? '在线模型' : '本地模型' }}</small>
</div>
</el-option>
<template #empty>
<div class="model-empty">暂无可用的大模型请先在模型管理中添加</div>
</template>
</el-select>
</div>
<div class="model-field">
<div class="field-copy">
<strong>默认提示语</strong>
<small>用于约束生成内容的格式语气和完整性可按任务需要修改</small>
</div>
<el-input
class="prompt-input"
:model-value="options.generationPrompt"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
resize="vertical"
placeholder="请输入模型生成内容时需要遵循的要求"
aria-label="模型默认提示语"
@update:model-value="updateField('generationPrompt', $event)"
/>
<div class="prompt-variables-hint">
提示可在文本中通过 <code v-text="'{{ content }}'" /> 引用当前正在处理的数据内容
</div>
</div>
<div class="advanced-settings-grid">
<label class="config-field">
<span class="config-field-label">生成温度 (Temperature)</span>
<el-slider
:model-value="options.temperature"
:min="0"
:max="1"
:step="0.1"
show-input
@update:model-value="updateField('temperature', Number($event) || 0)"
/>
<small>较低值输出更确定保守较高值输出更多样有创造性</small>
</label>
<label class="config-field">
<span class="config-field-label">最大输出长度 (Max Tokens)</span>
<span class="unit-input">
<el-input-number
:model-value="options.maxTokens"
:min="100"
:max="8192"
:step="256"
controls-position="right"
@update:model-value="updateField('maxTokens', Number($event) || 1024)"
/>
<span>Token</span>
</span>
<small>限制单次生成的最大内容长度防止过度发散</small>
</label>
</div>
<div class="json-mode-row">
<div class="field-copy">
<strong>强制 JSON 格式输出</strong>
<small>要求大模型返回纯 JSON 对象有助于提高下游结构化提取的稳定性</small>
</div>
<el-switch
:model-value="options.jsonMode"
inline-prompt
active-text=""
inactive-text=""
@update:model-value="updateField('jsonMode', Boolean($event))"
/>
</div>
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
{{ sectionValidationMessage() }}
</p>
</div>
<div v-else class="generation-config-group quality-config-group">
<div class="quality-switch-row">
<div class="config-group-heading">
<div>
<h4>质量筛选</h4>
<p>生成后自动过滤不符合要求的内容不影响源数据预处理</p>
</div>
</div>
<el-switch
:model-value="options.qualityFilterEnabled"
inline-prompt
active-text=""
inactive-text=""
aria-label="质量筛选"
@update:model-value="updateField('qualityFilterEnabled', Boolean($event))"
/>
</div>
<div v-if="options.qualityFilterEnabled" class="quality-rule-panel">
<el-checkbox-group
:model-value="selectedQualityRules()"
class="quality-rule-list"
@update:model-value="updateQualityRules"
>
<el-checkbox value="low_quality">
<span class="rule-copy">
<strong>过滤低质量内容</strong>
<small>按相关性完整性和可读性过滤明显不合格的生成结果</small>
</span>
</el-checkbox>
<el-checkbox value="short_content">
<span class="rule-copy">
<strong>过滤过短内容</strong>
<small>过滤正文长度低于指定字数的生成结果</small>
</span>
</el-checkbox>
</el-checkbox-group>
<label v-if="options.filterShortContent" class="minimum-length-field">
<span>最少字数</span>
<span class="minimum-length-input">
<el-input-number
:model-value="options.minOutputLength"
:min="1"
:max="1000"
:step="5"
:precision="0"
controls-position="right"
aria-label="生成内容最少字数"
@update:model-value="updateField('minOutputLength', Number($event) || 1)"
/>
<span></span>
</span>
<small>少于该字数的生成结果将被过滤</small>
</label>
<p v-if="!options.filterLowQuality && !options.filterShortContent" class="quality-rule-warning" role="alert">
请至少选择一项质量筛选规则
</p>
</div>
<p v-if="sectionValidationMessage()" class="generation-validation-message" role="alert">
{{ sectionValidationMessage() }}
</p>
</div>
</template>
<style scoped lang="scss">
.generation-config-group {
padding: 14px;
border: 1px solid #e2e5ec;
border-radius: 8px;
background: #fff;
}
.config-group-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h4 { margin: 0; color: #344054; font-size: 13px; font-weight: 600; }
p { margin: 4px 0 0; color: #8a93a3; font-size: 12px; line-height: 1.5; }
}
.model-config-group {
display: flex;
flex-direction: column;
gap: 20px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.model-config-group .advanced-settings-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.model-config-group .config-field {
padding: 0;
border: 0;
border-radius: 0;
}
.model-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.field-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
em { color: var(--el-color-danger); font-style: normal; }
}
.model-select { width: 100%; }
.prompt-input { width: 100%; }
.model-option { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.model-option small { color: #98a2b3; font-size: 11px; }
.model-empty { padding: 12px; color: #8a93a3; font-size: 12px; text-align: center; }
.prompt-variables-hint {
color: #8a93a3;
font-size: 12px;
code {
padding: 2px 4px;
color: #5b50f2;
background: #f0f0ff;
border-radius: 4px;
font-family: inherit;
}
}
.advanced-settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 14px;
color: #344054;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
> small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
:deep(.el-slider) {
margin: 0 8px;
}
}
.config-field-label {
font-size: 13px;
font-weight: 600;
}
.unit-input {
display: flex;
align-items: center;
gap: 8px;
color: #7b8495;
font-size: 12px;
:deep(.el-input-number) {
width: 100%;
}
}
.json-mode-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.quality-switch-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.quality-rule-panel { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e7eaf0; }
.quality-rule-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.quality-rule-list :deep(.el-checkbox) {
min-height: 62px; height: auto; margin-right: 0; padding: 10px 12px;
align-items: flex-start; border: 1px solid #e2e5ec; border-radius: 7px; background: #fff;
}
.quality-rule-list :deep(.el-checkbox__input) { margin-top: 3px; }
.quality-rule-list :deep(.el-checkbox__label) { min-width: 0; padding-left: 9px; white-space: normal; }
.rule-copy { display: grid; gap: 3px; }
.rule-copy strong { color: #344054; font-size: 12px; font-weight: 600; }
.rule-copy small { color: #8a93a3; font-size: 11px; line-height: 1.5; }
.minimum-length-field {
margin-top: 12px; display: grid; grid-template-columns: 110px 220px minmax(0, 1fr);
align-items: center; gap: 12px; color: #475467; font-size: 12px;
}
.minimum-length-input { display: flex; align-items: center; gap: 8px; }
.minimum-length-input :deep(.el-input-number) { width: 180px; }
.minimum-length-field > small { color: #8a93a3; }
.quality-rule-warning { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
.generation-validation-message { margin: 10px 0 0; color: #dc2626; font-size: 12px; }
@media (max-width: 900px) {
.advanced-settings-grid { grid-template-columns: 1fr; }
.quality-rule-list { grid-template-columns: 1fr; }
.minimum-length-field { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,215 @@
<script setup lang="ts">
import type { GenerationState, ProcessType } from './types'
defineProps<{
taskName: string
processType: ProcessType
fileName: string
previewCount: number
modifiedCount: number
generation: GenerationState
}>()
const emit = defineEmits<{
stop: []
retry: []
}>()
</script>
<template>
<section class="generation-step">
<div class="generation-layout">
<div class="summary-panel">
<div class="panel-heading">
<strong>任务摘要</strong>
<span>已完成预览确认</span>
</div>
<dl>
<div><dt>任务名称</dt><dd>{{ taskName }}</dd></div>
<div><dt>数据类型</dt><dd>{{ processType === 'unstructured' ? '非结构化数据' : processType === 'external' ? '外来数据源拉取' : '结构化数据' }}</dd></div>
<div><dt>源文件</dt><dd>{{ fileName }}</dd></div>
<div><dt>预览条目</dt><dd>{{ previewCount.toLocaleString() }} </dd></div>
<div><dt>已修改</dt><dd>{{ modifiedCount.toLocaleString() }} </dd></div>
</dl>
</div>
<div class="run-panel" :class="`is-${generation.status}`">
<div class="run-icon">
<i v-if="generation.status === 'success'" class="fa fa-check" />
<i v-else-if="generation.status === 'failed'" class="fa fa-exclamation" />
<i v-else-if="generation.status === 'running'" class="fa fa-cog fa-spin" />
<i v-else class="fa fa-play" />
</div>
<h3>
{{ generation.status === 'idle' ? '准备开始处理'
: generation.status === 'running' ? '正在生成数据'
: generation.status === 'success' ? '数据生成完成'
: '生成已停止' }}
</h3>
<p>{{ generation.message }}</p>
<el-progress
v-if="generation.status !== 'idle'"
:percentage="generation.progress"
:stroke-width="10"
:status="generation.status === 'success' ? 'success' : undefined"
/>
<div class="run-meta">
<span>解析源数据</span>
<span>应用预览修改</span>
<span>生成标准结果</span>
</div>
<el-button v-if="generation.status === 'running'" plain type="warning" @click="emit('stop')">
停止生成
</el-button>
<el-button v-if="generation.status === 'failed'" plain type="primary" @click="emit('retry')">
重新生成
</el-button>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.generation-step {
max-width: 1040px;
margin: 0 auto;
}
.generation-layout {
display: grid;
grid-template-columns: minmax(280px, 0.78fr) minmax(420px, 1.22fr);
gap: 28px;
}
.summary-panel,
.run-panel {
border: 1px solid #e2e5ec;
border-radius: 9px;
}
.summary-panel {
overflow: hidden;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 17px;
background: #fff;
border-bottom: 1px solid #e8ebf0;
strong {
color: #344054;
font-size: 14px;
}
span {
color: #2ca66a;
font-size: 11px;
}
}
dl {
margin: 0;
padding: 8px 17px;
div {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 13px 0;
border-bottom: 1px solid #eef0f5;
&:last-child {
border-bottom: 0;
}
}
}
dt,
dd {
margin: 0;
font-size: 12px;
}
dt {
color: #8a93a3;
}
dd {
max-width: 65%;
overflow: hidden;
color: #344054;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-panel {
display: flex;
min-height: 360px;
align-items: center;
flex-direction: column;
justify-content: center;
padding: 34px 48px;
text-align: center;
background: #fff;
h3 {
margin: 18px 0 8px;
color: #2e3646;
font-size: 18px;
}
p {
min-height: 22px;
margin: 0 0 24px;
color: #7b8495;
font-size: 12px;
}
:deep(.el-progress) {
width: 100%;
margin-bottom: 18px;
}
}
.run-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
color: #5b50f2;
font-size: 22px;
background: #f0efff;
border-radius: 50%;
}
.run-panel.is-success .run-icon {
color: #2ca66a;
background: #eaf8f1;
}
.run-panel.is-failed .run-icon {
color: #d97706;
background: #fff7e8;
}
.run-meta {
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: 24px;
color: #98a2b3;
font-size: 10px;
}
@media (max-width: 900px) {
.generation-layout {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { ModelItem } from '@/types'
import type { GenerationControlOptions } from './types'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: GenerationControlOptions
models: ModelItem[]
}>()
const emit = defineEmits<{
'update:options': [value: GenerationControlOptions]
}>()
const validationAttempted = ref(false)
const modelValidationMessage = computed(() => (
props.options.generationModelId === '' ? '请选择数据生成模型' : ''
))
function validate() {
validationAttempted.value = true
if (!modelValidationMessage.value) return true
ElMessage.error(modelValidationMessage.value)
return false
}
defineExpose({ validate })
</script>
<template>
<section class="model-selection-step" aria-labelledby="model-selection-title">
<div class="form-section">
<div class="section-title-row">
<div>
<h3 id="model-selection-title">大模型选择</h3>
<p>选择本次数据生成使用的模型并设置统一的输出要求</p>
</div>
</div>
<div class="model-form-content">
<GenerationOptionsPanel
:options="options"
:models="models"
section="model"
:validation-message="validationAttempted ? modelValidationMessage : ''"
@update:options="emit('update:options', $event)"
/>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.model-selection-step {
width: 100%;
}
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin: 0 0 5px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.6;
}
}
.model-form-content {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,585 @@
<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>

View File

@@ -0,0 +1,315 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { ResultItem } from './types'
const props = defineProps<{
items: ResultItem[]
selectedId: string | null
}>()
const emit = defineEmits<{
'update:selectedId': [value: string]
'update:field': [id: string, field: 'instruction' | 'input' | 'output', value: string]
'restore:item': [id: string]
}>()
const search = ref('')
const invalidOnly = ref(false)
const selectedItem = computed(() => props.items.find((item) => item.id === props.selectedId) ?? props.items[0])
const selectedIndex = computed(() => props.items.findIndex((item) => item.id === selectedItem.value?.id))
const filteredItems = computed(() => props.items.filter((item, index) => {
const keyword = search.value.trim().toLowerCase()
const matchesSearch = !keyword
|| item.instruction.toLowerCase().includes(keyword)
|| item.output.toLowerCase().includes(keyword)
|| String(index + 1).includes(keyword)
return matchesSearch && (!invalidOnly.value || item.status === 'invalid')
}))
function selectRelative(offset: number) {
if (!props.items.length) return
const nextIndex = Math.min(Math.max(selectedIndex.value + offset, 0), props.items.length - 1)
emit('update:selectedId', props.items[nextIndex].id)
}
</script>
<template>
<section class="result-step">
<div class="result-workspace">
<aside class="result-list-pane">
<div class="pane-header"><strong>生成结果</strong><span> {{ items.length }} </span></div>
<div class="result-toolbar">
<el-input v-model="search" clearable size="small" placeholder="搜索结果">
<template #prefix><i class="fa fa-search" /></template>
</el-input>
<el-checkbox v-model="invalidOnly">仅看错误</el-checkbox>
</div>
<div class="result-list">
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
class="result-item"
:class="{ 'is-active': item.id === selectedItem?.id }"
@click="emit('update:selectedId', item.id)"
>
<span class="result-index">#{{ String(items.findIndex((entry) => entry.id === item.id) + 1).padStart(3, '0') }}</span>
<span class="result-copy">
<strong>{{ item.instruction || '未填写指令' }}</strong>
<small>{{ item.output || '未填写输出' }}</small>
</span>
<i class="fa" :class="item.status === 'invalid' ? 'fa-exclamation-circle is-error' : 'fa-check-circle is-valid'" />
</button>
</div>
</aside>
<div v-if="selectedItem" class="result-editor-pane">
<div class="pane-header">
<div>
<strong>结果 #{{ String(selectedIndex + 1).padStart(3, '0') }}</strong>
<span v-if="selectedItem.status === 'modified'" class="modified-label">已修改</span>
</div>
<el-button link @click="emit('restore:item', selectedItem.id)"><i class="fa fa-undo" /> 恢复生成结果</el-button>
</div>
<div class="field-editor">
<label>Instruction <em>必填</em></label>
<el-input
:model-value="selectedItem.instruction"
type="textarea"
:rows="3"
@update:model-value="emit('update:field', selectedItem.id, 'instruction', $event)"
/>
</div>
<div class="field-editor">
<label>Input <span>选填</span></label>
<el-input
:model-value="selectedItem.input"
type="textarea"
:rows="2"
@update:model-value="emit('update:field', selectedItem.id, 'input', $event)"
/>
</div>
<div class="field-editor">
<label>Output <em>必填</em></label>
<el-input
:model-value="selectedItem.output"
type="textarea"
:rows="7"
@update:model-value="emit('update:field', selectedItem.id, 'output', $event)"
/>
</div>
<div v-if="selectedItem.error" class="validation-error">
<i class="fa fa-exclamation-circle" /> {{ selectedItem.error }}
</div>
<div v-else class="validation-success">
<i class="fa fa-check-circle" /> 字段校验通过
</div>
<div class="editor-pagination">
<el-button :disabled="selectedIndex <= 0" @click="selectRelative(-1)">上一条</el-button>
<span>{{ selectedIndex + 1 }} / {{ items.length }}</span>
<el-button :disabled="selectedIndex >= items.length - 1" @click="selectRelative(1)">下一条</el-button>
</div>
</div>
</div>
</section>
</template>
<style scoped lang="scss">
.result-step {
min-width: 0;
}
.result-workspace {
display: grid;
grid-template-columns: minmax(280px, 34fr) minmax(480px, 66fr);
min-height: clamp(420px, calc(100vh - 500px), 590px);
overflow: hidden;
border: 1px solid #e2e5ec;
border-radius: 9px;
}
.result-list-pane {
min-width: 0;
border-right: 1px solid #e5e8ee;
}
.pane-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 52px;
padding: 0 15px;
color: #344054;
background: #fff;
border-bottom: 1px solid #e8ebf0;
font-size: 13px;
> div {
display: flex;
align-items: center;
gap: 8px;
}
> span,
.modified-label {
color: #8a93a3;
font-size: 11px;
}
}
.result-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border-bottom: 1px solid #edf0f5;
}
.result-list {
height: 476px;
padding: 7px;
overflow: auto;
}
.result-item {
display: grid;
grid-template-columns: 46px minmax(0, 1fr) 18px;
align-items: center;
gap: 8px;
width: 100%;
min-height: 62px;
padding: 9px;
text-align: left;
background: #fff;
border: 1px solid transparent;
border-bottom-color: #edf0f5;
cursor: pointer;
&:hover,
&.is-active {
background: #f7f6ff;
}
&.is-active {
border-color: #5b50f2;
border-radius: 6px;
}
}
.result-index {
color: #667085;
font-size: 11px;
}
.result-copy {
min-width: 0;
strong,
small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: #344054;
font-size: 12px;
}
small {
margin-top: 5px;
color: #98a2b3;
font-size: 10px;
}
}
.is-valid {
color: #2ca66a;
}
.is-error {
color: #d97706;
}
.result-editor-pane {
min-width: 0;
}
.field-editor {
padding: 13px 18px 0;
label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 7px;
color: #344054;
font-size: 12px;
font-weight: 650;
}
em {
color: #e05252;
font-size: 10px;
font-style: normal;
font-weight: 400;
}
span {
color: #98a2b3;
font-size: 10px;
font-weight: 400;
}
}
.validation-error,
.validation-success {
margin: 12px 18px 0;
padding: 9px 11px;
font-size: 11px;
border-radius: 6px;
}
.validation-error {
color: #b45309;
background: #fff7e8;
}
.validation-success {
color: #25895c;
background: #edf9f3;
}
.editor-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 12px 18px;
span {
color: #8a93a3;
font-size: 11px;
}
}
@media (max-width: 900px) {
.result-workspace {
grid-template-columns: minmax(0, 1fr);
}
.result-list-pane {
border-right: 0;
border-bottom: 1px solid #e5e8ee;
}
.result-list {
height: 260px;
}
}
</style>

View File

@@ -0,0 +1,558 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { UploadFile } from 'element-plus'
import type { ExternalDataSource, ProcessType, UploadedDataFile } from './types'
const props = defineProps<{
processType: ProcessType
uploadedFiles: UploadedDataFile[]
externalSource: ExternalDataSource
externalPulling: boolean
externalConnected: boolean
}>()
const emit = defineEmits<{
'update:externalSource': [value: ExternalDataSource]
'file-change': [file: UploadFile]
'remove-file': [uid: string | number]
'use-sample': []
'test-connection': []
'pull-data': []
}>()
const DATA_SOURCE_TYPES = [
{ value: 'mysql', label: 'MySQL' },
{ value: 'postgresql', label: 'PostgreSQL' },
{ value: 'mongodb', label: 'MongoDB' },
{ value: 'api', label: 'REST API' },
]
const AUTH_MODES = [
{ value: 'none', label: '免鉴权' },
{ value: 'basic', label: '账号密码' },
{ value: 'token', label: 'Token' },
]
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
const isExternal = computed(() => props.processType === 'external')
const uploadAccept = computed(() => props.processType === 'unstructured'
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
: '.json,.jsonl,.csv,.xlsx,.xls')
const pagedUploadedFiles = computed(() => {
const start = (currentFilePage.value - 1) * FILE_PAGE_SIZE
return props.uploadedFiles.slice(start, start + FILE_PAGE_SIZE)
})
watch(() => props.uploadedFiles.length, (newLength, oldLength) => {
const totalPages = Math.max(1, Math.ceil(newLength / FILE_PAGE_SIZE))
if (newLength > oldLength) {
currentFilePage.value = totalPages
return
}
currentFilePage.value = Math.min(currentFilePage.value, totalPages)
})
watch(() => props.processType, () => {
currentFilePage.value = 1
})
function updateExternalField<K extends keyof ExternalDataSource>(field: K, value: ExternalDataSource[K]) {
emit('update:externalSource', { ...props.externalSource, [field]: value })
}
function formatSize(size: number) {
if (!size) return '0 KB'
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`
return `${(size / 1024).toFixed(1)} KB`
}
</script>
<template>
<section class="source-upload-step" aria-labelledby="source-upload-title">
<div v-if="isExternal" class="form-section external-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">数据源配置</h3>
<p>配置并验证外部数据源拉取成功后可在下一步预览数据内容</p>
</div>
</div>
<div class="external-form">
<el-form label-position="top" class="external-grid">
<el-form-item label="数据源类型">
<el-select
:model-value="externalSource.type"
placeholder="请选择数据源类型"
aria-label="数据源类型"
@update:model-value="updateExternalField('type', $event)"
>
<el-option
v-for="item in DATA_SOURCE_TYPES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="地址 / URL">
<el-input
:model-value="externalSource.url"
placeholder="例如mysql://host:3306/db 或 https://api.example.com/data"
aria-label="数据源地址或 URL"
@update:model-value="updateExternalField('url', $event)"
/>
</el-form-item>
<el-form-item label="鉴权方式">
<el-select
:model-value="externalSource.authMode"
aria-label="鉴权方式"
@update:model-value="updateExternalField('authMode', $event)"
>
<el-option
v-for="item in AUTH_MODES"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="账号">
<el-input
:model-value="externalSource.username"
autocomplete="username"
placeholder="请输入账号"
aria-label="数据源账号"
@update:model-value="updateExternalField('username', $event)"
/>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'basic'" label="密码">
<el-input
:model-value="externalSource.password"
type="password"
show-password
autocomplete="current-password"
placeholder="请输入密码"
aria-label="数据源密码"
@update:model-value="updateExternalField('password', $event)"
/>
</el-form-item>
<el-form-item v-if="externalSource.authMode === 'token'" label="Token">
<el-input
:model-value="externalSource.token"
type="password"
show-password
autocomplete="off"
placeholder="请输入访问 Token"
aria-label="数据源访问 Token"
@update:model-value="updateExternalField('token', $event)"
/>
</el-form-item>
<el-form-item label="拉取条数">
<el-input-number
:model-value="externalSource.limit"
:min="1"
:max="100000"
:step="100"
controls-position="right"
aria-label="数据拉取条数"
@update:model-value="updateExternalField('limit', Number($event) || 0)"
/>
</el-form-item>
</el-form>
<div class="external-actions">
<el-button
:loading="externalPulling && !externalConnected"
:disabled="externalPulling"
plain
@click="emit('test-connection')"
>
测试连接
</el-button>
<el-button
type="primary"
:loading="externalPulling"
:disabled="externalPulling"
@click="emit('pull-data')"
>
拉取数据
</el-button>
<span v-if="externalConnected" class="external-status is-connected" role="status">
<i class="fa fa-check-circle" aria-hidden="true" /> 连接正常
</span>
</div>
<section v-if="uploadedFiles.length" class="uploaded-file-list" aria-label="已拉取数据列表">
<div class="uploaded-file-list-header">
<span>已拉取 {{ uploadedFiles.length }} 个数据集</span>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-cloud-download" aria-hidden="true" /></span>
<div class="file-main">
<strong :title="file.name">{{ file.name }}</strong>
<span>
{{ formatSize(file.size) }}
<template v-if="file.count"> · {{ file.count.toLocaleString() }} </template>
</span>
</div>
<span class="file-status"><i class="fa fa-check-circle" aria-hidden="true" /> 拉取成功</span>
<el-button
link
type="danger"
:aria-label="`删除数据集 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<el-pagination
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
v-model:current-page="currentFilePage"
:page-size="FILE_PAGE_SIZE"
:total="uploadedFiles.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="uploaded-file-pagination"
aria-label="已拉取数据分页"
/>
</section>
</div>
</div>
<div v-else class="form-section upload-section">
<div class="section-title-row">
<div>
<h3 id="source-upload-title">源数据上传</h3>
<p>上传后可在下一步检查内容和切分效果支持同时添加多个文件</p>
</div>
<el-button v-if="uploadedFiles.length === 0" link type="primary" @click="emit('use-sample')">
使用示例数据
</el-button>
</div>
<el-upload
v-if="uploadedFiles.length === 0"
drag
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="选择或拖拽源数据文件"
>
<i class="fa fa-cloud-upload upload-icon" aria-hidden="true" />
<div class="el-upload__text">拖拽文件到此处,或<em>点击选择文件</em></div>
<template #tip>
<div class="el-upload__tip">
{{ processType === 'unstructured'
? '支持 TXT、Markdown、PDF、Word、JSON、JSONL单文件不超过 200MB'
: '支持 JSON、JSONL、CSV、Excel单文件不超过 200MB' }}
</div>
</template>
</el-upload>
<section v-else class="uploaded-file-list" aria-label="已上传文件列表">
<div class="uploaded-file-list-header">
<span>已添加 {{ uploadedFiles.length }} 个文件</span>
<div class="continue-upload">
<el-upload
multiple
:accept="uploadAccept"
:auto-upload="false"
:show-file-list="false"
:on-change="(file: UploadFile) => emit('file-change', file)"
aria-label="继续添加源数据文件"
>
<el-button size="small" type="primary">继续上传</el-button>
</el-upload>
</div>
</div>
<div class="uploaded-file-items">
<div v-for="file in pagedUploadedFiles" :key="file.uid" class="uploaded-file">
<span class="file-icon"><i class="fa fa-file-text-o" aria-hidden="true" /></span>
<div class="file-main">
<strong :title="file.name">{{ file.name }}</strong>
<span>
{{ formatSize(file.size) }}
<template v-if="file.count"> · {{ file.count.toLocaleString() }} 条</template>
</span>
</div>
<span class="file-status"><i class="fa fa-check-circle" aria-hidden="true" /> 校验通过</span>
<el-button
link
type="danger"
:aria-label="`删除文件 ${file.name}`"
@click="emit('remove-file', file.uid)"
>
删除
</el-button>
</div>
</div>
<el-pagination
v-if="uploadedFiles.length > FILE_PAGE_SIZE"
v-model:current-page="currentFilePage"
:page-size="FILE_PAGE_SIZE"
:total="uploadedFiles.length"
:pager-count="5"
small
background
layout="prev, pager, next"
class="uploaded-file-pagination"
aria-label="已上传文件分页"
/>
</section>
</div>
</section>
</template>
<style scoped lang="scss">
.source-upload-step {
width: 100%;
}
.form-section {
padding: 0;
h3 {
margin: 0 0 5px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.6;
}
}
.external-section {
.external-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px 20px;
margin-top: 16px;
}
:deep(.el-form-item) {
margin-bottom: 0;
}
:deep(.el-select),
:deep(.el-input),
:deep(.el-input-number) {
width: 100%;
}
}
.external-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 22px;
}
.external-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
&.is-connected {
color: #2ca66a;
}
}
.upload-section :deep(.el-upload) {
width: 100%;
margin-top: 16px;
}
.upload-section :deep(.el-upload-dragger) {
width: 100%;
min-height: 154px;
padding: 32px 20px;
background: #fff;
border-color: #dfe3ea;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover,
&:focus-visible {
background: #fafaff;
border-color: #8b82f4;
}
&:focus-visible {
outline: 2px solid #5b50f2;
outline-offset: 2px;
}
}
.upload-icon {
margin-bottom: 12px;
color: #5b50f2;
font-size: 30px;
}
.uploaded-file-list {
margin-top: 20px;
overflow: hidden;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 8px;
}
.uploaded-file-list-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 14px;
color: #5f6878;
font-size: 12px;
background: #fff;
border-bottom: 1px solid #edf0f5;
}
.continue-upload {
flex: 0 0 auto;
}
.continue-upload :deep(.el-upload) {
width: auto;
margin-top: 0;
}
.uploaded-file-pagination {
display: flex;
justify-content: flex-end;
padding: 10px 14px;
border-top: 1px solid #edf0f5;
}
.uploaded-file {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 8px 14px;
border-bottom: 1px solid #edf0f5;
&:last-child {
border-bottom: 0;
}
}
.file-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
color: #5b50f2;
font-size: 14px;
background: #f0efff;
border-radius: 7px;
}
.file-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
strong {
overflow: hidden;
color: #273142;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
color: #8a93a3;
font-size: 12px;
}
}
.file-status {
color: #2ca66a;
font-size: 12px;
}
.uploaded-file :deep(.el-button) {
flex: 0 0 auto;
}
@media (max-width: 900px) {
.external-section .external-grid {
grid-template-columns: minmax(0, 1fr);
}
.uploaded-file {
gap: 8px;
padding: 8px 10px;
}
.file-status {
flex: 0 1 auto;
line-height: 1.4;
white-space: normal;
}
.uploaded-file-pagination {
justify-content: center;
}
}
@media (max-width: 560px) {
.section-title-row {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file-list-header {
align-items: flex-start;
flex-direction: column;
}
.uploaded-file {
align-items: flex-start;
flex-wrap: wrap;
}
.file-main {
min-width: calc(100% - 40px);
}
.file-status {
margin-left: 38px;
}
}
@media (prefers-reduced-motion: reduce) {
.upload-section :deep(.el-upload-dragger) {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,240 @@
<script setup lang="ts">
import type {
GenerationControlOptions,
PreprocessOption,
StructuredProcessOptions,
} from './types'
import DatasetSplitEditor from './DatasetSplitEditor.vue'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: StructuredProcessOptions
validationAttempted: boolean
qualityValidationMessage: string
}>()
const emit = defineEmits<{
'update:options': [value: StructuredProcessOptions]
}>()
const PREPROCESS_OPTIONS: Array<{
value: PreprocessOption
label: string
description: string
}> = [
{ value: 'clean_invalid', label: '清理无效数据', description: '处理空行、空列和残缺行' },
{ value: 'detect_structure', label: '识别表格结构', description: '识别表头、多级表头和合并单元格' },
{ value: 'deduplicate', label: '重复数据去重', description: '删除完全重复或关键字段重复的数据' },
{ value: 'normalize_format', label: '数据格式标准化', description: '统一日期、数字、单位和枚举值格式' },
{ value: 'filter_anomaly', label: '异常数据过滤', description: '过滤乱码、无效内容和异常记录' },
{ value: 'desensitize', label: '敏感信息脱敏', description: '处理姓名、手机号、邮箱等敏感信息' },
]
function updateField<K extends keyof StructuredProcessOptions>(
field: K,
value: StructuredProcessOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateGenerationOptions(value: GenerationControlOptions) {
emit('update:options', { ...props.options, ...value })
}
function updatePreprocessOptions(value: Array<string | number | boolean>) {
const allowedValues = new Set(PREPROCESS_OPTIONS.map((option) => option.value))
const preprocessOptions = value.filter(
(option): option is PreprocessOption => typeof option === 'string' && allowedValues.has(option as PreprocessOption),
)
updateField('preprocessOptions', preprocessOptions)
}
</script>
<template>
<div class="form-section structured-options-section">
<div class="section-title-row">
<div>
<h3>预处理选项</h3>
<p>选择在生成问答对之前需要执行的数据处理方式</p>
</div>
</div>
<el-checkbox-group
:model-value="options.preprocessOptions"
class="preprocess-option-grid"
@update:model-value="updatePreprocessOptions"
>
<el-checkbox
v-for="option in PREPROCESS_OPTIONS"
:key="option.value"
:value="option.value"
class="preprocess-option"
>
<span class="preprocess-option-copy">
<strong>{{ option.label }}</strong>
<small>{{ option.description }}</small>
</span>
</el-checkbox>
</el-checkbox-group>
</div>
<div class="form-section generation-options-section">
<div class="section-title-row">
<div>
<h3>生成选项</h3>
<p>配置每条结构化记录生成问答对的方式和数量</p>
</div>
</div>
<div class="generation-option-list">
<GenerationOptionsPanel
:options="options"
section="quality"
:validation-message="validationAttempted ? qualityValidationMessage : ''"
@update:options="updateGenerationOptions"
/>
<div class="generation-option-row">
<div class="generation-option-copy">
<strong>每行生成数量</strong>
<small>每行结构化数据生成的问答对数量</small>
</div>
<el-input-number
:model-value="options.qaPairsPerRow"
:min="1"
:max="5"
:step="1"
controls-position="right"
@update:model-value="updateField('qaPairsPerRow', Number($event) || 1)"
/>
</div>
<DatasetSplitEditor
:model-value="options.datasetSplit"
@update:model-value="updateField('datasetSplit', $event)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
h3 {
margin: 0 0 16px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin-bottom: 5px;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
}
}
.preprocess-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.preprocess-option {
display: flex;
gap: 10px;
width: 100%;
min-height: 64px;
margin-right: 0;
padding: 12px 14px;
box-sizing: border-box;
align-items: flex-start;
border: 1px solid #e2e5ec;
border-radius: 8px;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover {
border-color: #b7b2f7;
}
&.is-checked {
background: #fafaff;
border-color: #8b82f4;
}
:deep(.el-checkbox__input) {
margin-top: 3px;
}
:deep(.el-checkbox__label) {
min-width: 0;
padding-left: 10px;
white-space: normal;
}
}
.preprocess-option-copy,
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.generation-option-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
@media (max-width: 900px) {
.preprocess-option-grid {
grid-template-columns: minmax(0, 1fr);
}
.generation-option-row {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import type {
GenerationControlOptions,
ProcessType,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
import StructuredOptionsPanel from './StructuredOptionsPanel.vue'
import UnstructuredOptionsPanel from './UnstructuredOptionsPanel.vue'
const props = defineProps<{
name: string
description: string
processType: ProcessType
structuredOptions: StructuredProcessOptions
unstructuredOptions: UnstructuredProcessOptions
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:processType': [value: ProcessType]
'update:structuredOptions': [value: StructuredProcessOptions]
'update:unstructuredOptions': [value: UnstructuredProcessOptions]
}>()
const formRef = ref<FormInstance>()
const unstructuredOptionsPanelRef = ref<InstanceType<typeof UnstructuredOptionsPanel>>()
const validationAttempted = ref(false)
const formModel = computed(() => ({
name: props.name,
processType: props.processType,
}))
const splitTotal = computed(() => {
const { train, validation, test } = props.structuredOptions.datasetSplit
return train + validation + test
})
const unstructuredSplitTotal = computed(() => {
const { train, validation, test } = props.unstructuredOptions.datasetSplit
return train + validation + test
})
const activeGenerationOptions = computed<GenerationControlOptions | null>(() => {
if (props.processType === 'structured') return props.structuredOptions
if (props.processType === 'unstructured') return props.unstructuredOptions
return null
})
const qualityValidationMessage = computed(() => {
const options = activeGenerationOptions.value
if (!options) return ''
if (options.qualityFilterEnabled && !options.filterLowQuality && !options.filterShortContent) {
return '开启质量筛选后,请至少选择一项筛选规则'
}
if (options.qualityFilterEnabled && options.filterShortContent && options.minOutputLength < 1) {
return '最少字数必须大于 0'
}
return ''
})
const chunkValidationMessage = computed(() => {
if (props.unstructuredOptions.chunkOverlap >= props.unstructuredOptions.chunkSize) {
return '重叠长度必须小于切片长度'
}
if (props.unstructuredOptions.minChunkSize > props.unstructuredOptions.chunkSize) {
return '最小切片长度不能大于切片长度'
}
if (
props.unstructuredOptions.chunkOverlap + props.unstructuredOptions.minChunkSize
> props.unstructuredOptions.chunkSize
) {
return '重叠长度与最小切片长度之和不能大于切片长度'
}
if (props.unstructuredOptions.chunkMethod === 'custom' && !props.unstructuredOptions.customDelimiter.trim()) {
return '请输入自定义分隔符'
}
return ''
})
const rules: FormRules = {
name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ max: 50, message: '任务名称不能超过 50 个字符', trigger: 'blur' },
],
processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }],
}
async function validate() {
validationAttempted.value = true
if (!formRef.value) return false
try {
await formRef.value.validate()
if (props.processType === 'structured' && splitTotal.value !== 100) {
ElMessage.error('结构化数据处理:数据集划分比例总和必须为 100%')
return false
}
if (props.processType === 'unstructured') {
if (unstructuredSplitTotal.value !== 100) {
ElMessage.error('非结构化数据处理:数据集划分比例总和必须为 100%')
return false
}
if (chunkValidationMessage.value) {
unstructuredOptionsPanelRef.value?.revealValidation()
ElMessage.error(chunkValidationMessage.value)
return false
}
}
if (qualityValidationMessage.value) {
ElMessage.error(qualityValidationMessage.value)
return false
}
return true
} catch {
ElMessage.error('请完善标红的必填项')
return false
}
}
defineExpose({ validate })
</script>
<template>
<section class="task-setup-step">
<el-form ref="formRef" :model="formModel" :rules="rules" label-position="top" scroll-to-error>
<div class="form-section">
<h3>基本信息</h3>
<div class="basic-grid">
<el-form-item label="任务名称" prop="name" required>
<el-input
:model-value="name"
maxlength="50"
show-word-limit
placeholder="例如:金融问答清洗任务"
@update:model-value="emit('update:name', $event)"
/>
</el-form-item>
<el-form-item label="任务描述(选填)">
<el-input
:model-value="description"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="简要说明本次数据处理目标"
@update:model-value="emit('update:description', $event)"
/>
</el-form-item>
</div>
</div>
<div class="form-section">
<div class="section-title-row">
<div>
<h3>处理类型</h3>
<p>类型会影响下一步支持的数据源格式和后续预览方式</p>
</div>
</div>
<el-form-item prop="processType" class="type-form-item">
<div class="type-options">
<button
type="button"
class="type-option"
:class="{ 'is-active': processType === 'structured' }"
@click="emit('update:processType', 'structured')"
>
<span class="type-icon"><i class="fa fa-table" /></span>
<span>
<strong>结构化数据</strong>
<small>适用于 CSVExcelJSONL 等固定字段数据</small>
</span>
<i class="fa fa-check-circle selection-mark" />
</button>
<button
type="button"
class="type-option"
:class="{ 'is-active': processType === 'unstructured' }"
@click="emit('update:processType', 'unstructured')"
>
<span class="type-icon"><i class="fa fa-file-text-o" /></span>
<span>
<strong>非结构化数据</strong>
<small>适用于文档文本问答等需要切分的数据</small>
</span>
<i class="fa fa-check-circle selection-mark" />
</button>
<button
type="button"
class="type-option"
:class="{ 'is-active': processType === 'external' }"
@click="emit('update:processType', 'external')"
>
<span class="type-icon"><i class="fa fa-cloud-download" /></span>
<span>
<strong>外来数据源拉取</strong>
<small>适用于数据库API 接口等需远程拉取的外部数据</small>
</span>
<i class="fa fa-check-circle selection-mark" />
</button>
</div>
</el-form-item>
</div>
<StructuredOptionsPanel
v-if="processType === 'structured'"
:options="structuredOptions"
:validation-attempted="validationAttempted"
:quality-validation-message="qualityValidationMessage"
@update:options="emit('update:structuredOptions', $event)"
/>
<UnstructuredOptionsPanel
v-if="processType === 'unstructured'"
ref="unstructuredOptionsPanelRef"
:options="unstructuredOptions"
:validation-attempted="validationAttempted"
:quality-validation-message="qualityValidationMessage"
:chunk-validation-message="chunkValidationMessage"
@update:options="emit('update:unstructuredOptions', $event)"
/>
</el-form>
</section>
</template>
<style scoped lang="scss">
.task-setup-step {
width: 100%;
}
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
h3 {
margin: 0 0 16px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.basic-grid {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin-bottom: 5px;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
}
}
.type-form-item {
margin-top: 16px;
}
.type-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
width: 100%;
}
.type-option {
position: relative;
display: flex;
align-items: center;
gap: 14px;
min-height: 96px;
padding: 18px;
color: #4b5563;
text-align: left;
background: #fff;
border: 1px solid #dfe3ea;
border-radius: 9px;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover {
border-color: #a8a3ff;
}
&.is-active {
background: #fafaff;
border-color: #5b50f2;
box-shadow: 0 0 0 1px rgba(91, 80, 242, 0.08);
}
strong,
small {
display: block;
}
strong {
margin-bottom: 6px;
color: #262d3d;
font-size: 14px;
}
small {
color: #7b8495;
font-size: 12px;
line-height: 1.6;
}
}
.type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 40px;
height: 40px;
color: #5b50f2;
font-size: 18px;
background: #f0efff;
border-radius: 9px;
}
.selection-mark {
position: absolute;
top: 12px;
right: 12px;
color: #5b50f2;
opacity: 0;
}
.type-option.is-active .selection-mark {
opacity: 1;
}
@media (max-width: 900px) {
.type-options {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,512 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type {
ChunkMethod,
GenerationControlOptions,
UnstructuredPreprocessOption,
UnstructuredProcessOptions,
} from './types'
import DatasetSplitEditor from './DatasetSplitEditor.vue'
import GenerationOptionsPanel from './GenerationOptionsPanel.vue'
const props = defineProps<{
options: UnstructuredProcessOptions
validationAttempted: boolean
qualityValidationMessage: string
chunkValidationMessage: string
}>()
const emit = defineEmits<{
'update:options': [value: UnstructuredProcessOptions]
}>()
const SMART_PREPROCESS_OPTIONS: UnstructuredPreprocessOption[] = [
'clean_invalid_content',
'detect_document_structure',
'merge_short_content',
'filter_low_quality',
'deduplicate_content',
'preserve_context',
]
const CHUNK_METHODS: Array<{ value: ChunkMethod; label: string }> = [
{ value: 'semantic', label: '自动语义切分' },
{ value: 'heading', label: '按标题和段落' },
{ value: 'fixed', label: '按固定长度' },
{ value: 'custom', label: '自定义分隔符' },
]
const UNSTRUCTURED_NUMBER_LIMITS = {
chunkSize: { min: 200, max: 2000 },
chunkOverlap: { min: 0, max: 500 },
minChunkSize: { min: 20, max: 500 },
qaPairsPerChunk: { min: 1, max: 3 },
} as const
type UnstructuredNumberField = keyof typeof UNSTRUCTURED_NUMBER_LIMITS
const smartPreprocessEnabled = computed(() => SMART_PREPROCESS_OPTIONS.every(
(option) => props.options.preprocessOptions.includes(option),
))
const desensitizeEnabled = computed(() => props.options.preprocessOptions.includes('desensitize'))
const preserveSpecialContentEnabled = computed(() => (
props.options.preserveTables
&& props.options.preserveCodeBlocks
&& props.options.preserveLists
))
function updateField<K extends keyof UnstructuredProcessOptions>(
field: K,
value: UnstructuredProcessOptions[K],
) {
emit('update:options', { ...props.options, [field]: value })
}
function updateGenerationOptions(value: GenerationControlOptions) {
emit('update:options', { ...props.options, ...value })
}
function updateSmartPreprocess(value: string | number | boolean) {
const enabled = Boolean(value)
const remainingOptions = props.options.preprocessOptions.filter(
(option) => !SMART_PREPROCESS_OPTIONS.includes(option),
)
updateField(
'preprocessOptions',
enabled ? [...SMART_PREPROCESS_OPTIONS, ...remainingOptions] : remainingOptions,
)
}
function updateDesensitize(value: string | number | boolean) {
const preprocessOptions: UnstructuredPreprocessOption[] = props.options.preprocessOptions.filter(
(option) => option !== 'desensitize',
)
if (Boolean(value)) preprocessOptions.push('desensitize')
updateField('preprocessOptions', preprocessOptions)
}
function updateSpecialContentProtection(value: string | number | boolean) {
const enabled = Boolean(value)
emit('update:options', {
...props.options,
preserveTables: enabled,
preserveCodeBlocks: enabled,
preserveLists: enabled,
})
}
function updateUnstructuredNumber(field: UnstructuredNumberField, value: number | undefined) {
const limits = UNSTRUCTURED_NUMBER_LIMITS[field]
const parsedValue = Number(value)
const nextValue = Number.isFinite(parsedValue) ? parsedValue : limits.min
updateField(field, Math.min(limits.max, Math.max(limits.min, nextValue)))
}
function revealValidation() {
// Advanced settings are now flattened, no need to open toggle
}
defineExpose({ revealValidation })
</script>
<template>
<div class="form-section unstructured-options-section">
<div class="section-title-row">
<div>
<h3>预处理选项</h3>
<p>默认使用推荐策略只需决定是否需要脱敏</p>
</div>
</div>
<div class="preprocess-option-grid">
<label class="preprocess-option" :class="{ 'is-checked': smartPreprocessEnabled }">
<el-checkbox
:model-value="smartPreprocessEnabled"
@update:model-value="updateSmartPreprocess"
/>
<span class="preprocess-option-copy">
<strong>智能预处理</strong>
<small>自动清理解析去重及保留上下文</small>
</span>
</label>
<label class="preprocess-option" :class="{ 'is-checked': desensitizeEnabled }">
<el-checkbox
:model-value="desensitizeEnabled"
@update:model-value="updateDesensitize"
/>
<span class="preprocess-option-copy">
<strong>敏感信息脱敏</strong>
<small>处理姓名手机号等隐私信息</small>
</span>
</label>
<label class="preprocess-option" :class="{ 'is-checked': preserveSpecialContentEnabled }">
<el-checkbox
:model-value="preserveSpecialContentEnabled"
@update:model-value="updateSpecialContentProtection"
/>
<span class="preprocess-option-copy">
<strong>保护表格代码和列表</strong>
<small>避免切分点破坏特殊内容块的完整性</small>
</span>
</label>
</div>
</div>
<div class="form-section chunk-options-section">
<div class="section-title-row">
<div>
<h3>切分选项</h3>
<p>以语义完整为优先将长文档拆成可独立生成问答的内容块</p>
</div>
</div>
<div class="chunk-settings-grid is-basic">
<label class="config-field">
<span class="config-field-label">切分方式</span>
<el-select
:model-value="options.chunkMethod"
aria-label="切分方式"
@update:model-value="updateField('chunkMethod', $event)"
>
<el-option
v-for="method in CHUNK_METHODS"
:key="method.value"
:label="method.label"
:value="method.value"
/>
</el-select>
<small>推荐使用自动语义切分在长度限制内优先保留完整句段</small>
</label>
<label class="config-field">
<span class="config-field-label">切片长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.chunkSize"
:min="200"
:max="2000"
:step="50"
:precision="0"
controls-position="right"
aria-label="切片长度"
@update:model-value="updateUnstructuredNumber('chunkSize', $event)"
/>
<span>Token</span>
</span>
<small>单个切片的目标上限默认 800 Token</small>
</label>
<label class="config-field">
<span class="config-field-label">重叠长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.chunkOverlap"
:min="0"
:max="500"
:step="10"
:precision="0"
controls-position="right"
aria-label="重叠长度"
@update:model-value="updateUnstructuredNumber('chunkOverlap', $event)"
/>
<span>Token</span>
</span>
<small>在相邻切片中最多重复保留的上下文默认 100 Token</small>
</label>
<label class="config-field">
<span class="config-field-label">最小切片长度</span>
<span class="unit-input">
<el-input-number
:model-value="options.minChunkSize"
:min="20"
:max="500"
:step="10"
:precision="0"
controls-position="right"
aria-label="最小切片长度"
@update:model-value="updateUnstructuredNumber('minChunkSize', $event)"
/>
<span>Token</span>
</span>
<small>过短的尾部内容会尽量并入前一个切片</small>
</label>
<label v-if="options.chunkMethod === 'custom'" class="config-field">
<span class="config-field-label">自定义分隔符</span>
<el-input
:model-value="options.customDelimiter"
maxlength="40"
show-word-limit
placeholder="例如:--- 或 ###"
aria-label="自定义分隔符"
@update:model-value="updateField('customDelimiter', $event)"
/>
<small>系统会优先在分隔符位置结束当前切片</small>
</label>
</div>
<p class="chunk-estimation-note">
Token 数为轻量估算值实际长度以训练使用的模型分词器为准
</p>
<p v-if="chunkValidationMessage" class="option-validation-message" role="alert">
{{ chunkValidationMessage }}
</p>
</div>
<div class="form-section generation-options-section">
<div class="section-title-row">
<div>
<h3>生成选项</h3>
<p>只保留会直接影响输出的核心设置</p>
</div>
</div>
<div class="generation-option-list">
<GenerationOptionsPanel
:options="options"
section="quality"
:validation-message="validationAttempted ? qualityValidationMessage : ''"
@update:options="updateGenerationOptions"
/>
<div class="generation-option-row">
<div class="generation-option-copy">
<strong>每个切片生成数量</strong>
<small>每个内容切片最多生成 3 个不同角度的问答对</small>
</div>
<el-input-number
:model-value="options.qaPairsPerChunk"
:min="1"
:max="3"
:step="1"
:precision="0"
controls-position="right"
aria-label="每个切片生成数量"
@update:model-value="updateUnstructuredNumber('qaPairsPerChunk', $event)"
/>
</div>
<DatasetSplitEditor
:model-value="options.datasetSplit"
aria-label-prefix="非结构化"
@update:model-value="updateField('datasetSplit', $event)"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.form-section {
padding: 0 0 26px;
margin-bottom: 26px;
border-bottom: 1px solid #edf0f5;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
h3 {
margin: 0 0 16px;
color: #2f3747;
font-size: 15px;
font-weight: 650;
}
}
.section-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin-bottom: 5px;
}
p {
margin: 0;
color: #8a93a3;
font-size: 12px;
}
}
.generation-option-list {
display: grid;
gap: 12px;
margin-top: 16px;
}
.preprocess-option-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.preprocess-option {
display: flex;
gap: 10px;
width: 100%;
min-height: 64px;
margin-right: 0;
padding: 12px 14px;
box-sizing: border-box;
align-items: flex-start;
border: 1px solid #e2e5ec;
border-radius: 8px;
transition: border-color 0.18s ease, background-color 0.18s ease;
&:hover {
border-color: #b7b2f7;
}
&.is-checked {
background: #fafaff;
border-color: #8b82f4;
}
:deep(.el-checkbox__input) {
margin-top: 3px;
}
:deep(.el-checkbox__label) {
min-width: 0;
padding-left: 10px;
white-space: normal;
}
}
.preprocess-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.generation-option-row {
display: flex;
min-height: 64px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 12px 14px;
box-sizing: border-box;
border: 1px solid #e2e5ec;
border-radius: 8px;
}
.generation-option-copy {
display: grid;
gap: 4px;
strong {
color: #344054;
font-size: 13px;
font-weight: 600;
}
small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
}
.chunk-settings-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.config-field {
display: grid;
align-content: start;
gap: 8px;
min-width: 0;
padding: 14px;
color: #344054;
background: #fff;
border: 1px solid #e2e5ec;
border-radius: 8px;
> small {
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
:deep(.el-select),
:deep(.el-input) {
width: 100%;
}
}
.config-field-label {
font-size: 13px;
font-weight: 600;
}
.unit-input {
display: flex;
align-items: center;
gap: 8px;
color: #7b8495;
font-size: 12px;
:deep(.el-input-number) {
width: 100%;
}
}
.option-validation-message {
display: block;
margin: 8px 0 0;
color: #dc2626;
font-size: 12px;
line-height: 1.5;
}
.chunk-estimation-note {
margin: 9px 0 0;
color: #8a93a3;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 900px) {
.chunk-settings-grid {
grid-template-columns: minmax(0, 1fr);
}
.generation-option-row {
align-items: flex-start;
flex-direction: column;
}
.compact-option-list .generation-option-row {
align-items: center;
flex-direction: row;
}
.preprocess-option-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,211 @@
.create-wizard-layout {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid #eef0f5;
border-radius: 8px;
background: #fff;
}
.wizard-main {
flex: 1;
overflow-y: auto;
padding: 32px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
}
.wizard-main-inner {
min-height: 100%;
box-sizing: border-box;
}
.wizard-steps-container {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px dashed #e2e8f0;
}
.custom-wizard-steps {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1040px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
align-items: center;
flex: none;
}
.step-connector {
flex: 1;
height: 2px;
margin: 0 16px;
background-color: #e2e8f0;
transition: background-color 0.3s;
}
.step-connector.is-active {
background-color: #5146e5;
}
.step-node {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 10px;
}
.step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #64748b;
font-size: 13px;
font-weight: 650;
background-color: #fff;
border: 2px solid #cbd5e1;
border-radius: 50%;
transition: all 0.3s;
}
.step-title {
color: #64748b;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.step-item.is-active {
.step-icon {
color: #5146e5;
background-color: #eef2ff;
border-color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
.step-item.is-completed {
.step-icon {
color: #fff;
background-color: #5146e5;
border-color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
.wizard-content {
min-height: 400px;
}
.wizard-footer {
display: grid;
flex-shrink: 0;
grid-template-columns: 1fr auto 1fr;
align-items: center;
height: 64px;
padding: 0 32px;
background: #fff;
border-top: 1px solid #e2e8f0;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.02);
.footer-left {
justify-self: start;
}
.footer-center {
justify-self: center;
}
.footer-right {
justify-self: end;
}
}
.wizard-primary-action {
min-width: 160px;
}
@media (max-width: 1100px) {
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-node {
gap: 7px;
}
.step-title {
display: none;
}
.step-item.is-active .step-title {
display: block;
font-size: 12px;
white-space: nowrap;
}
}
@media (max-width: 760px) {
.wizard-main {
padding: 24px 20px;
}
.custom-wizard-steps {
padding: 0;
}
.step-connector {
margin: 0 8px;
}
.step-node {
gap: 6px;
}
.step-title {
max-width: 72px;
line-height: 1.35;
}
.wizard-footer {
padding: 0 20px;
}
}
@media (max-width: 600px) {
.step-title {
display: none;
}
.step-connector {
margin: 0 6px;
}
.wizard-primary-action {
min-width: 0;
}
}

View File

@@ -0,0 +1,54 @@
import type { StructuredProcessOptions, UnstructuredProcessOptions } from './types'
export const DEFAULT_GENERATION_PROMPT = '你是一名专业的数据生成助手。请根据输入内容生成准确、完整、可直接用于模型训练的问答数据。仅输出符合目标格式的内容,答案应事实清晰、语言自然,不要添加分析过程、说明或无关内容。'
export function createDefaultStructuredOptions(): StructuredProcessOptions {
return {
preprocessOptions: ['clean_invalid', 'detect_structure', 'deduplicate', 'normalize_format'],
semanticEnrichment: false,
qaPairsPerRow: 1,
datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT,
temperature: 0.7,
maxTokens: 1024,
jsonMode: false,
qualityFilterEnabled: false,
filterLowQuality: true,
filterShortContent: true,
minOutputLength: 20,
}
}
export function createDefaultUnstructuredOptions(): UnstructuredProcessOptions {
return {
preprocessOptions: [
'clean_invalid_content',
'detect_document_structure',
'merge_short_content',
'filter_low_quality',
'deduplicate_content',
'preserve_context',
],
chunkMethod: 'semantic',
chunkSize: 800,
chunkOverlap: 100,
minChunkSize: 100,
customDelimiter: '',
preserveTables: true,
preserveCodeBlocks: true,
preserveLists: true,
semanticEnrichment: false,
qaPairsPerChunk: 1,
datasetSplit: { train: 80, validation: 10, test: 10 },
generationModelId: '',
generationPrompt: DEFAULT_GENERATION_PROMPT,
temperature: 0.7,
maxTokens: 1024,
jsonMode: false,
qualityFilterEnabled: false,
filterLowQuality: true,
filterShortContent: true,
minOutputLength: 20,
}
}

View File

@@ -0,0 +1,540 @@
import type {
PreviewItem,
ProcessType,
ResultItem,
SourceLine,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
export const DEFAULT_SOURCE_TEXT = [
'问:如何看待当前的通货膨胀风险?',
'答:当前通胀水平总体可控,但仍需关注能源价格与供给扰动。',
'问:美联储下一次议息会议何时召开?',
'答:会议时间以美联储官方日历为准,市场会重点关注利率路径指引。',
'问:人民币汇率未来走势如何?',
'答:人民币汇率取决于中美利差、经济基本面与政策预期。',
'问:银行理财产品收益率为何持续走低?',
'答:主要与市场利率下行、资产端收益下降以及风险偏好变化有关。',
'问:什么是复利?',
'答:复利是指在计算利息时,将上一期利息加入本金,再计算下一期利息。',
'问:如何评估股票的投资价值?',
'答:评估股票投资价值可以从以下几个方面进行:',
'1. 公司基本面:分析公司的财务状况、盈利能力、成长性等。',
'2. 行业前景:考察公司所处行业的发展趋势和竞争格局。',
'3. 估值水平:通过市盈率、市净率等指标判断估值是否合理。',
'4. 财务健康:关注公司的负债情况、现金流状况等。',
'5. 管理团队:评估管理层的能力和过往业绩。',
'此外,还需要关注宏观经济环境、政策变化等因素对股票市场的影响。',
'问:债券和股票的主要区别是什么?',
'答:债券收益相对稳定但上行有限,股票波动更大且承担更高风险。',
'问:什么是市盈率?',
'答:市盈率是股票价格与每股收益的比值,常用于衡量估值水平。',
'问:如何进行资产配置?',
'答:应根据投资目标、风险承受能力和市场环境合理分配资产。',
].join('\n')
export function sourceLines(sourceText: string): SourceLine[] {
const rawLines = sourceText.split('\n')
let cursor = 0
return rawLines.map((content, index) => {
const start = cursor
const end = start + content.length
cursor = end + (index < rawLines.length - 1 ? 1 : 0)
return { number: index + 1, content, start, end }
})
}
interface SourceRange {
start: number
end: number
}
interface ProtectedRange extends SourceRange {
kind: 'code' | 'table' | 'list'
}
const DEFAULT_CHUNK_SIZE = 800
const DEFAULT_CHUNK_OVERLAP = 100
const DEFAULT_MIN_CHUNK_SIZE = 100
function finiteInteger(value: number | undefined, fallback: number, min: number): number {
return Number.isFinite(value) ? Math.max(min, Math.round(value as number)) : fallback
}
function trimSourceRange(sourceText: string, start: number, end: number): SourceRange {
let nextStart = Math.max(0, start)
let nextEnd = Math.min(sourceText.length, end)
while (nextStart < nextEnd && /\s/.test(sourceText[nextStart])) nextStart += 1
while (nextEnd > nextStart && /\s/.test(sourceText[nextEnd - 1])) nextEnd -= 1
return { start: nextStart, end: nextEnd }
}
function normalizeDelimiter(delimiter: string | undefined): string {
return (delimiter ?? '').replace(/\\n/g, '\n').replace(/\\t/g, '\t')
}
function overlapsRange(line: SourceLine, range: SourceRange): boolean {
return line.start < range.end && line.end > range.start
}
function isLineProtected(line: SourceLine, ranges: SourceRange[]): boolean {
return ranges.some((range) => overlapsRange(line, range))
}
function detectCodeBlockRanges(sourceText: string, lines: SourceLine[]): ProtectedRange[] {
const ranges: ProtectedRange[] = []
let openFence: { start: number; marker: string; length: number } | null = null
for (const line of lines) {
const fence = line.content.match(/^\s*(`{3,}|~{3,})/)
if (!fence) continue
const marker = fence[1][0]
if (!openFence) {
openFence = { start: line.start, marker, length: fence[1].length }
continue
}
if (marker === openFence.marker && fence[1].length >= openFence.length) {
ranges.push({ start: openFence.start, end: line.end, kind: 'code' })
openFence = null
}
}
if (openFence) ranges.push({ start: openFence.start, end: sourceText.length, kind: 'code' })
return ranges
}
function isTableSeparator(content: string): boolean {
const normalized = content.trim().replace(/^\|/, '').replace(/\|$/, '')
const cells = normalized.split('|').map((cell) => cell.trim())
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
function detectTableRanges(lines: SourceLine[], codeRanges: SourceRange[]): ProtectedRange[] {
const ranges: ProtectedRange[] = []
for (let index = 0; index < lines.length - 1; index += 1) {
const header = lines[index]
const separator = lines[index + 1]
if (
isLineProtected(header, codeRanges)
|| isLineProtected(separator, codeRanges)
|| !header.content.includes('|')
|| !isTableSeparator(separator.content)
) {
continue
}
let endIndex = index + 1
while (
endIndex + 1 < lines.length
&& !isLineProtected(lines[endIndex + 1], codeRanges)
&& lines[endIndex + 1].content.trim()
&& lines[endIndex + 1].content.includes('|')
) {
endIndex += 1
}
ranges.push({ start: header.start, end: lines[endIndex].end, kind: 'table' })
index = endIndex
}
return ranges
}
function isListItem(content: string): boolean {
return /^\s*(?:[-+*]|\d+[.)])\s+\S/.test(content)
}
function isListContinuation(content: string): boolean {
return /^\s{2,}\S/.test(content)
}
function detectListRanges(
lines: SourceLine[],
excludedRanges: SourceRange[],
): ProtectedRange[] {
const ranges: ProtectedRange[] = []
for (let index = 0; index < lines.length; index += 1) {
if (isLineProtected(lines[index], excludedRanges) || !isListItem(lines[index].content)) continue
let endIndex = index
let itemCount = 1
while (endIndex + 1 < lines.length && !isLineProtected(lines[endIndex + 1], excludedRanges)) {
const nextContent = lines[endIndex + 1].content
if (isListItem(nextContent)) {
itemCount += 1
endIndex += 1
continue
}
if (isListContinuation(nextContent)) {
endIndex += 1
continue
}
break
}
if (itemCount >= 2) {
ranges.push({ start: lines[index].start, end: lines[endIndex].end, kind: 'list' })
index = endIndex
}
}
return ranges
}
function mergeProtectedRanges(ranges: ProtectedRange[]): ProtectedRange[] {
return ranges
.sort((left, right) => left.start - right.start || left.end - right.end)
.reduce<ProtectedRange[]>((merged, range) => {
const previous = merged[merged.length - 1]
if (previous && range.start < previous.end) {
previous.end = Math.max(previous.end, range.end)
return merged
}
merged.push({ ...range })
return merged
}, [])
}
function protectedRangesForOptions(
sourceText: string,
options?: UnstructuredProcessOptions,
): ProtectedRange[] {
if (!options?.preserveCodeBlocks && !options?.preserveTables && !options?.preserveLists) return []
const lines = sourceLines(sourceText)
const codeRanges = detectCodeBlockRanges(sourceText, lines)
const tableRanges = detectTableRanges(lines, codeRanges)
const listRanges = detectListRanges(lines, [...codeRanges, ...tableRanges])
const enabledRanges = [
...(options?.preserveCodeBlocks ? codeRanges : []),
...(options?.preserveTables ? tableRanges : []),
...(options?.preserveLists ? listRanges : []),
]
return mergeProtectedRanges(enabledRanges)
}
function protectedRangeContaining(
ranges: ProtectedRange[],
offset: number,
): ProtectedRange | undefined {
return ranges.find((range) => range.start < offset && offset < range.end)
}
function normalizeChunkStart(
sourceText: string,
cursor: number,
protectedRanges: ProtectedRange[],
): number {
let start = Math.max(0, Math.min(cursor, sourceText.length))
const overlapBlock = protectedRangeContaining(protectedRanges, start)
if (overlapBlock) start = overlapBlock.end
while (start < sourceText.length && /\s/.test(sourceText[start])) start += 1
// 去除块前空白时可能进入缩进代码块/列表;此时恢复到完整块起点。
const blockAfterTrim = protectedRangeContaining(protectedRanges, start)
if (blockAfterTrim) return cursor <= blockAfterTrim.start ? blockAfterTrim.start : blockAfterTrim.end
return start
}
function protectChunkEnd(
proposedEnd: number,
start: number,
minimumEnd: number,
protectedRanges: ProtectedRange[],
): number {
const splitBlock = protectedRangeContaining(protectedRanges, proposedEnd)
if (!splitBlock) return proposedEnd
// 优先在块前结束;块前不足最小切片长度时,将整个块收入当前切片。
return splitBlock.start > start && splitBlock.start >= minimumEnd
? splitBlock.start
: splitBlock.end
}
function restoreProtectedEdges(
range: SourceRange,
rawStart: number,
rawEnd: number,
protectedRanges: ProtectedRange[],
): SourceRange {
const nextRange = { ...range }
const startBlock = protectedRangeContaining(protectedRanges, nextRange.start)
if (startBlock && rawStart <= startBlock.start) nextRange.start = startBlock.start
const endBlock = protectedRangeContaining(protectedRanges, nextRange.end)
if (endBlock && rawEnd >= endBlock.end) nextRange.end = endBlock.end
return nextRange
}
function lastBoundaryInRange(
sourceText: string,
idealEnd: number,
minimumEnd: number,
): number | null {
const candidates: number[] = []
const boundaryTokens = ['\n\n', '\n', '。', '', '', '', '.', '!', '?', ';']
boundaryTokens.forEach((token) => {
const tokenStart = sourceText.lastIndexOf(token, idealEnd - token.length)
const boundary = tokenStart === -1 ? -1 : tokenStart + token.length
if (boundary >= minimumEnd && boundary <= idealEnd) candidates.push(boundary)
})
return candidates.length ? Math.max(...candidates) : null
}
function lastHeadingBoundary(
sourceText: string,
start: number,
idealEnd: number,
minimumEnd: number,
): number | null {
const section = sourceText.slice(start, idealEnd)
const headingPattern = /^(?:#{1,6}\s+|第[一二三四五六七八九十百]+[章节篇部分]|\d+(?:\.\d+)*[、.\s])/gm
let boundary: number | null = null
let match: RegExpExecArray | null
while ((match = headingPattern.exec(section))) {
const absoluteStart = start + match.index
if (absoluteStart >= minimumEnd) boundary = absoluteStart
}
return boundary
}
function resolveChunkEnd(
sourceText: string,
start: number,
idealEnd: number,
minimumEnd: number,
options: UnstructuredProcessOptions | undefined,
): number {
const method = options?.chunkMethod ?? 'semantic'
if (method === 'fixed') return idealEnd
if (method === 'custom') {
const delimiter = normalizeDelimiter(options?.customDelimiter)
if (!delimiter) return idealEnd
const delimiterStart = sourceText.lastIndexOf(delimiter, idealEnd - delimiter.length)
const boundary = delimiterStart === -1 ? -1 : delimiterStart + delimiter.length
return boundary >= minimumEnd ? boundary : idealEnd
}
if (method === 'heading') {
const headingBoundary = lastHeadingBoundary(sourceText, start, idealEnd, minimumEnd)
if (headingBoundary !== null) return headingBoundary
}
return lastBoundaryInRange(sourceText, idealEnd, minimumEnd) ?? idealEnd
}
function buildUnstructuredRanges(
sourceText: string,
options?: UnstructuredProcessOptions,
): SourceRange[] {
// 预览统一沿用“约 2 个字符 = 1 token”的轻量估算避免引入分词器依赖。
const targetCharacters = finiteInteger(options?.chunkSize, DEFAULT_CHUNK_SIZE, 1) * 2
const minimumCharacters = Math.min(
targetCharacters,
finiteInteger(options?.minChunkSize, DEFAULT_MIN_CHUNK_SIZE, 1) * 2,
)
const requestedOverlap = finiteInteger(options?.chunkOverlap, DEFAULT_CHUNK_OVERLAP, 0) * 2
const protectedRanges = protectedRangesForOptions(sourceText, options)
const ranges: SourceRange[] = []
let cursor = 0
while (cursor < sourceText.length) {
const start = normalizeChunkStart(sourceText, cursor, protectedRanges)
if (start >= sourceText.length) break
const idealEnd = Math.min(sourceText.length, start + targetCharacters)
const minimumEnd = Math.min(idealEnd, start + minimumCharacters)
let end = idealEnd === sourceText.length
? idealEnd
: resolveChunkEnd(sourceText, start, idealEnd, minimumEnd, options)
end = protectChunkEnd(end, start, minimumEnd, protectedRanges)
// 所有自定义边界都必须向前推进;异常配置回退到固定长度切分。
if (end <= start) end = Math.min(sourceText.length, start + targetCharacters)
let range = trimSourceRange(sourceText, start, end)
range = restoreProtectedEdges(range, start, end, protectedRanges)
if (end < sourceText.length && range.end - range.start < minimumCharacters) {
range.end = Math.min(end, range.start + minimumCharacters)
}
if (range.end <= range.start) {
cursor = Math.max(cursor + 1, end)
continue
}
const isLastRange = end >= sourceText.length
if (isLastRange && range.end - range.start < minimumCharacters && ranges.length) {
ranges[ranges.length - 1].end = range.end
break
}
ranges.push(range)
if (isLastRange) break
// overlap 是允许的最大重叠量;按当前切片动态收缩,保证每轮至少推进最小切片长度。
const maximumOverlap = Math.max(0, range.end - range.start - minimumCharacters)
const actualOverlap = Math.min(requestedOverlap, maximumOverlap)
const nextCursor = range.end - actualOverlap
cursor = nextCursor > start ? nextCursor : range.end
}
return ranges
}
function lineNumberAtOffset(lines: SourceLine[], offset: number): number | null {
if (!lines.length) return null
let low = 0
let high = lines.length - 1
let result = 0
while (low <= high) {
const middle = Math.floor((low + high) / 2)
if (lines[middle].start <= offset) {
result = middle
low = middle + 1
} else {
high = middle - 1
}
}
return lines[result].number
}
function previewItemFromRange(
sourceText: string,
lines: SourceLine[],
range: SourceRange,
sourceFileId: string,
index: number,
): PreviewItem {
const content = sourceText.slice(range.start, range.end)
return {
id: `preview-${sourceFileId}-${index + 1}`,
sourceFileId,
originalContent: content,
editedContent: content,
sourceStart: range.start,
sourceEnd: range.end,
sourceStartLine: lineNumberAtOffset(lines, range.start),
sourceEndLine: lineNumberAtOffset(lines, Math.max(range.start, range.end - 1)),
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
status: 'original',
}
}
export function buildPreviewItems(
sourceText: string,
processType: ProcessType,
sourceFileId = 'default-source',
unstructuredOptions?: UnstructuredProcessOptions,
): PreviewItem[] {
const lines = sourceLines(sourceText)
if (processType === 'unstructured') {
return buildUnstructuredRanges(sourceText, unstructuredOptions).map((range, index) => (
previewItemFromRange(sourceText, lines, range, sourceFileId, index)
))
}
const meaningfulLines = lines.filter((line) => line.content.trim())
const groupSize = processType === 'structured' ? 1 : 3
const items: PreviewItem[] = []
for (let index = 0; index < meaningfulLines.length; index += groupSize) {
const group = meaningfulLines.slice(index, index + groupSize)
if (!group.length) continue
const sourceStart = group[0].start
const sourceEnd = group[group.length - 1].end
const content = sourceText.slice(sourceStart, sourceEnd)
items.push({
id: `preview-${sourceFileId}-${items.length + 1}`,
sourceFileId,
originalContent: content,
editedContent: content,
sourceStart,
sourceEnd,
sourceStartLine: group[0].number,
sourceEndLine: group[group.length - 1].number,
tokenCount: Math.max(1, Math.ceil(content.length / 2)),
status: 'original',
})
}
return items
}
const SEMANTIC_PREFIXES = [
'请结合实际情况,说明一下:',
'如果方便的话,请详细解答:',
'请用通俗易懂的方式说明:',
'请从实际应用角度说明:',
'请简洁、自然地说明:',
]
export function createResults(
items: PreviewItem[],
options?: StructuredProcessOptions | UnstructuredProcessOptions,
): ResultItem[] {
const resultCount = options && 'qaPairsPerChunk' in options
? Math.min(3, finiteInteger(options.qaPairsPerChunk, 1, 1))
: Math.min(5, finiteInteger(options?.qaPairsPerRow, 1, 1))
const generatedResults = items.flatMap((item, index) => {
if (options?.qualityFilterEnabled && options.filterLowQuality) {
if (item.status === 'invalid' || !item.editedContent.trim()) return []
}
const [firstLine = '', ...rest] = item.editedContent.split('\n')
const output = rest.join('\n').trim() || item.editedContent.trim()
const baseInstruction = firstLine.replace(/^问[:]\s*/, '').trim() || `数据条目 ${index + 1}`
return Array.from({ length: resultCount }, (_, variantIndex) => {
const instruction = options?.semanticEnrichment
? `${SEMANTIC_PREFIXES[variantIndex]}${baseInstruction}`
: variantIndex === 0
? baseInstruction
: `${baseInstruction}(问法 ${variantIndex + 1}`
return {
id: resultCount === 1 ? `result-${index + 1}` : `result-${index + 1}-${variantIndex + 1}`,
instruction,
input: '',
output,
originalInstruction: instruction,
originalInput: '',
originalOutput: output,
status: 'valid' as const,
}
})
})
if (!options?.qualityFilterEnabled) return generatedResults
return generatedResults.filter((result) => {
if (options.filterLowQuality && (!result.instruction.trim() || !result.output.trim())) return false
if (options.filterShortContent && result.output.trim().length < options.minOutputLength) return false
return true
})
}

View File

@@ -0,0 +1,118 @@
export type ProcessType = 'structured' | 'unstructured' | 'external'
export type StepId = 'create' | 'model' | 'upload' | 'preview' | 'generate' | 'results'
export type PreprocessOption =
| 'clean_invalid'
| 'detect_structure'
| 'deduplicate'
| 'normalize_format'
| 'filter_anomaly'
| 'desensitize'
export interface DatasetSplitOptions {
train: number
validation: number
test: number
}
export interface GenerationControlOptions {
generationModelId: string | number | ''
generationPrompt: string
temperature: number
maxTokens: number
jsonMode: boolean
qualityFilterEnabled: boolean
filterLowQuality: boolean
filterShortContent: boolean
minOutputLength: number
}
export interface StructuredProcessOptions extends GenerationControlOptions {
preprocessOptions: PreprocessOption[]
semanticEnrichment: boolean
qaPairsPerRow: number
datasetSplit: DatasetSplitOptions
}
export type UnstructuredPreprocessOption =
| 'clean_invalid_content'
| 'detect_document_structure'
| 'merge_short_content'
| 'filter_low_quality'
| 'deduplicate_content'
| 'desensitize'
| 'preserve_context'
export type ChunkMethod = 'semantic' | 'heading' | 'fixed' | 'custom'
export interface UnstructuredProcessOptions extends GenerationControlOptions {
preprocessOptions: UnstructuredPreprocessOption[]
chunkMethod: ChunkMethod
chunkSize: number
chunkOverlap: number
minChunkSize: number
customDelimiter: string
preserveTables: boolean
preserveCodeBlocks: boolean
preserveLists: boolean
semanticEnrichment: boolean
qaPairsPerChunk: number
datasetSplit: DatasetSplitOptions
}
export interface ExternalDataSource {
type: string
url: string
authMode: string
username?: string
password?: string
token?: string
limit: number
}
export interface UploadedDataFile {
uid: string | number
name: string
size: number
count: number
content: string
}
export interface SourceLine {
number: number
content: string
start: number
end: number
}
export interface PreviewItem {
id: string
sourceFileId: string
originalContent: string
editedContent: string
sourceStart: number | null
sourceEnd: number | null
sourceStartLine: number | null
sourceEndLine: number | null
tokenCount: number
status: 'original' | 'modified' | 'manual' | 'invalid'
}
export interface GenerationState {
status: 'idle' | 'running' | 'success' | 'failed'
progress: number
message: string
}
export interface ResultItem {
id: string
instruction: string
input: string
output: string
originalInstruction: string
originalInput: string
originalOutput: string
status: 'valid' | 'modified' | 'invalid'
error?: string
}

View File

@@ -0,0 +1,147 @@
import { nextTick, type Reactive, type Ref } from 'vue'
import { ElMessage } from 'element-plus'
import type {
ExternalDataSource,
ProcessType,
StepId,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
export const DATA_PROCESS_DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
export const DATA_PROCESS_DRAFT_SCHEMA_VERSION = 6
interface DraftSnapshot {
schemaVersion?: number
currentStepId?: StepId
task?: { name?: string; description?: string }
processType?: ProcessType
structuredOptions?: Partial<StructuredProcessOptions>
unstructuredOptions?: Partial<UnstructuredProcessOptions>
externalSource?: Partial<ExternalDataSource>
}
interface DraftBindings {
currentStepId: Readonly<Ref<StepId>>
task: Reactive<{ name: string; description: string }>
processType: Ref<ProcessType>
structuredOptions: Ref<StructuredProcessOptions>
unstructuredOptions: Ref<UnstructuredProcessOptions>
externalSource: Reactive<ExternalDataSource>
restoringDraft: Ref<boolean>
dirty: Ref<boolean>
goToStep: (stepId: StepId) => void
}
function sanitizeExternalSource(source: Partial<ExternalDataSource>) {
return {
type: typeof source.type === 'string' ? source.type : 'mysql',
url: typeof source.url === 'string' ? source.url : '',
authMode: typeof source.authMode === 'string' ? source.authMode : 'none',
username: typeof source.username === 'string' ? source.username : '',
limit: Number.isFinite(source.limit) ? Number(source.limit) : 1000,
}
}
function isDraftSnapshot(value: unknown): value is DraftSnapshot {
return Boolean(value && typeof value === 'object')
}
/**
* 只持久化可重建的配置。密码、令牌、文件正文、预览与结果都不进入 localStorage。
*/
export function useDataProcessDraft(bindings: DraftBindings) {
function draftSnapshot(): DraftSnapshot {
return {
schemaVersion: DATA_PROCESS_DRAFT_SCHEMA_VERSION,
currentStepId: bindings.currentStepId.value,
task: { ...bindings.task },
processType: bindings.processType.value,
structuredOptions: {
...bindings.structuredOptions.value,
preprocessOptions: [...bindings.structuredOptions.value.preprocessOptions],
datasetSplit: { ...bindings.structuredOptions.value.datasetSplit },
},
unstructuredOptions: {
...bindings.unstructuredOptions.value,
preprocessOptions: [...bindings.unstructuredOptions.value.preprocessOptions],
datasetSplit: { ...bindings.unstructuredOptions.value.datasetSplit },
},
externalSource: sanitizeExternalSource(bindings.externalSource),
}
}
function writeDraft(showWarning: boolean) {
try {
localStorage.setItem(DATA_PROCESS_DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
} catch {
if (showWarning) ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
}
}
function persistDraft() {
if (!bindings.restoringDraft.value) writeDraft(true)
}
function restoreDraft() {
try {
const raw = localStorage.getItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
if (!raw) return
const snapshot: unknown = JSON.parse(raw)
if (!isDraftSnapshot(snapshot)) return
bindings.restoringDraft.value = true
bindings.goToStep('create')
bindings.task.name = snapshot.task?.name || ''
bindings.task.description = snapshot.task?.description || ''
bindings.processType.value = snapshot.processType === 'unstructured' || snapshot.processType === 'external'
? snapshot.processType
: 'structured'
if (snapshot.structuredOptions) {
bindings.structuredOptions.value = {
...bindings.structuredOptions.value,
...snapshot.structuredOptions,
preprocessOptions: Array.isArray(snapshot.structuredOptions.preprocessOptions)
? snapshot.structuredOptions.preprocessOptions
: bindings.structuredOptions.value.preprocessOptions,
datasetSplit: {
...bindings.structuredOptions.value.datasetSplit,
...snapshot.structuredOptions.datasetSplit,
},
}
}
if (snapshot.unstructuredOptions) {
bindings.unstructuredOptions.value = {
...bindings.unstructuredOptions.value,
...snapshot.unstructuredOptions,
preprocessOptions: Array.isArray(snapshot.unstructuredOptions.preprocessOptions)
? snapshot.unstructuredOptions.preprocessOptions
: bindings.unstructuredOptions.value.preprocessOptions,
datasetSplit: {
...bindings.unstructuredOptions.value.datasetSplit,
...snapshot.unstructuredOptions.datasetSplit,
},
}
}
Object.assign(bindings.externalSource, sanitizeExternalSource(snapshot.externalSource || {}), {
password: '',
token: '',
})
bindings.dirty.value = false
nextTick(() => {
bindings.restoringDraft.value = false
// 立即覆盖 v5 及更早草稿,清除其中可能存在的敏感值和大段正文。
writeDraft(false)
})
ElMessage.info('已恢复上次的任务配置,请重新上传或拉取源数据')
} catch {
localStorage.removeItem(DATA_PROCESS_DRAFT_STORAGE_KEY)
}
}
return { draftSnapshot, persistDraft, restoreDraft }
}

View File

@@ -0,0 +1,127 @@
import { reactive, ref, type Ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createResults } from './previewModel'
import type {
GenerationState,
PreviewItem,
ProcessType,
ResultItem,
StructuredProcessOptions,
UnstructuredProcessOptions,
} from './types'
interface GenerationBindings {
previewItems: Ref<PreviewItem[]>
processType: Ref<ProcessType>
structuredOptions: Ref<StructuredProcessOptions>
unstructuredOptions: Ref<UnstructuredProcessOptions>
dirty: Ref<boolean>
}
export function useDataProcessGeneration(bindings: GenerationBindings) {
const results = ref<ResultItem[]>([])
const selectedResultId = ref<string | null>(null)
const generation = reactive<GenerationState>({
status: 'idle',
progress: 0,
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
})
let generationTimer: ReturnType<typeof setInterval> | null = null
function stopGenerationTimer() {
if (generationTimer) clearInterval(generationTimer)
generationTimer = null
}
function resetDownstream() {
stopGenerationTimer()
generation.status = 'idle'
generation.progress = 0
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
results.value = []
selectedResultId.value = null
}
function startGeneration() {
stopGenerationTimer()
generation.status = 'running'
generation.progress = 0
generation.message = '正在应用预览修改并生成标准化结果,请稍候。'
generationTimer = setInterval(() => {
generation.progress = Math.min(100, generation.progress + 8)
if (generation.progress < 100) return
stopGenerationTimer()
generation.status = 'success'
results.value = createResults(
bindings.previewItems.value,
bindings.processType.value === 'structured'
? bindings.structuredOptions.value
: bindings.processType.value === 'unstructured'
? bindings.unstructuredOptions.value
: undefined,
)
generation.message = `已完成 ${results.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
selectedResultId.value = results.value[0]?.id ?? null
bindings.dirty.value = true
ElMessage.success('数据处理完成')
}, 180)
}
function stopGeneration() {
stopGenerationTimer()
generation.status = 'failed'
generation.message = '任务已停止,预览修改仍然保留,可以重新生成。'
}
function updateResultField(id: string, field: 'instruction' | 'input' | 'output', value: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item[field] = value
const valid = item.instruction.trim() && item.output.trim()
item.error = valid ? undefined : 'Instruction 和 Output 不能为空'
const changed = item.instruction !== item.originalInstruction
|| item.input !== item.originalInput
|| item.output !== item.originalOutput
item.status = item.error ? 'invalid' : changed ? 'modified' : 'valid'
bindings.dirty.value = true
}
function restoreResult(id: string) {
const item = results.value.find((entry) => entry.id === id)
if (!item) return
item.instruction = item.originalInstruction
item.input = item.originalInput
item.output = item.originalOutput
item.error = undefined
item.status = 'valid'
bindings.dirty.value = true
}
function validateResults() {
let firstInvalidId: string | null = null
for (const item of results.value) {
if (!item.instruction.trim() || !item.output.trim()) {
item.error = 'Instruction 和 Output 不能为空'
item.status = 'invalid'
firstInvalidId ??= item.id
}
}
if (firstInvalidId) selectedResultId.value = firstInvalidId
return firstInvalidId == null
}
return {
generation,
results,
selectedResultId,
resetDownstream,
restoreResult,
startGeneration,
stopGeneration,
stopGenerationTimer,
updateResultField,
validateResults,
}
}