feat: 实现业务视图页面

登录、模型调优、评测、推理、对比、模型管理、数据集、数据处理、工具、系统(硬件/日志/训练日志)等全部业务页面视图。
This commit is contained in:
caoxiaozhu
2026-07-10 16:45:55 +08:00
parent c1893cf82c
commit 6f6609dae5
30 changed files with 9383 additions and 0 deletions

View File

@@ -0,0 +1,762 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import TaskSetupStep from './create/TaskSetupStep.vue'
import PreviewCompareStep from './create/PreviewCompareStep.vue'
import GenerationStep from './create/GenerationStep.vue'
import ResultEditorStep from './create/ResultEditorStep.vue'
import { buildPreviewItems, createResults, DEFAULT_SOURCE_TEXT } from './create/previewModel'
import type { GenerationState, PreviewItem, ProcessType, ResultItem, StepId } from './create/types'
const router = useRouter()
const taskSetupRef = ref<InstanceType<typeof TaskSetupStep>>()
const DRAFT_STORAGE_KEY = 'yg-data-process-create-draft'
const WIZARD_STEPS = [
{ id: 'create', title: '创建任务', desc: '填写任务信息与上传源数据' },
{ id: 'preview', title: '数据预览', desc: '核对源文件与预览内容' },
{ id: 'generate', title: '开始生成', desc: '确认摘要并启动处理' },
{ id: 'results', title: '结果编辑与保存', desc: '检查、修改并保存结果' },
] as const satisfies ReadonlyArray<{ id: StepId; title: string; desc: string }>
const currentStep = ref(0)
const currentStepId = computed<StepId>(() => WIZARD_STEPS[currentStep.value]?.id ?? 'create')
const task = reactive({ name: '', description: '' })
const processType = ref<ProcessType>('unstructured')
interface UploadedDataFile {
uid: number | string
name: string
size: number
count: number
content: string
}
const uploadedFiles = ref<UploadedDataFile[]>([])
const fileName = computed(() => uploadedFiles.value.map(f => f.name).join(', '))
const fileSize = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.size, 0))
const fileCount = computed(() => uploadedFiles.value.reduce((sum, f) => sum + f.count, 0))
const previewSignature = ref('')
const previewItems = ref<PreviewItem[]>([])
const selectedPreviewFileId = ref<string | null>(null)
const selectedPreviewId = ref<string | null>(null)
const selectedPreviewIdsByFile = ref<Record<string, string>>({})
const results = ref<ResultItem[]>([])
const selectedResultId = ref<string | null>(null)
const dirty = ref(false)
const restoringDraft = ref(false)
let allowLeave = false
const generation = reactive<GenerationState>({
status: 'idle',
progress: 0,
message: '确认摘要后即可开始生成,过程中可查看实时进度。',
})
let generationTimer: ReturnType<typeof setInterval> | null = null
const modifiedPreviewCount = computed(() => previewItems.value.filter((item) => item.status !== 'original').length)
const activePreviewFile = computed(() => uploadedFiles.value.find((file) => String(file.uid) === selectedPreviewFileId.value))
const activePreviewItems = computed(() => previewItems.value.filter((item) => item.sourceFileId === selectedPreviewFileId.value))
const activeSourceText = computed(() => activePreviewFile.value?.content ?? '')
const previewFiles = computed(() => uploadedFiles.value.map((file) => {
const items = previewItems.value.filter((item) => item.sourceFileId === String(file.uid))
return {
id: String(file.uid),
name: file.name,
count: items.length,
modifiedCount: items.filter((item) => item.status !== 'original').length,
}
}))
const primaryActionLabel = computed(() => {
if (currentStepId.value === 'create') return '继续:数据预览'
if (currentStepId.value === 'preview') return '确认预览并继续'
if (currentStepId.value === 'results') return '保存任务'
if (generation.status === 'running') return '正在生成'
if (generation.status === 'success') return '查看生成结果'
if (generation.status === 'failed') return '重新生成'
return '开始生成'
})
const primaryActionIcon = computed(() => {
if (currentStepId.value === 'results') return 'fa-check'
if (currentStepId.value === 'generate' && generation.status !== 'success') return 'fa-play'
return 'fa-arrow-right'
})
const previousStepLabel = computed(() => currentStep.value > 0
? WIZARD_STEPS[currentStep.value - 1].title
: '')
function draftSnapshot() {
return {
currentStep: currentStep.value,
task: { ...task },
processType: processType.value,
uploadedFiles: uploadedFiles.value,
previewSignature: previewSignature.value,
previewItems: previewItems.value,
selectedPreviewFileId: selectedPreviewFileId.value,
selectedPreviewId: selectedPreviewId.value,
selectedPreviewIdsByFile: selectedPreviewIdsByFile.value,
results: results.value,
selectedResultId: selectedResultId.value,
generation: { ...generation },
}
}
function persistDraft() {
if (restoringDraft.value) return
try {
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draftSnapshot()))
} catch {
ElMessage.warning('草稿保存失败,请检查浏览器存储空间')
}
}
type DraftSnapshot = ReturnType<typeof draftSnapshot> & {
fileName?: string
fileSize?: number
fileCount?: number
sourceText?: string
}
function restoreDraft() {
try {
const raw = localStorage.getItem(DRAFT_STORAGE_KEY)
if (!raw) return
const snapshot = JSON.parse(raw) as DraftSnapshot
if (!snapshot?.uploadedFiles && (!snapshot?.fileName || !snapshot?.sourceText)) return
restoringDraft.value = true
currentStep.value = Math.min(Math.max(Number(snapshot.currentStep) || 0, 0), WIZARD_STEPS.length - 1)
task.name = snapshot.task?.name || ''
task.description = snapshot.task?.description || ''
processType.value = snapshot.processType === 'structured' ? 'structured' : 'unstructured'
if (snapshot.uploadedFiles) {
uploadedFiles.value = Array.isArray(snapshot.uploadedFiles) ? snapshot.uploadedFiles : []
} else if (snapshot.fileName && snapshot.sourceText) {
// Migrate old draft
uploadedFiles.value = [{
uid: 'migrated-draft',
name: snapshot.fileName,
size: Number(snapshot.fileSize) || 0,
count: Number(snapshot.fileCount) || 0,
content: snapshot.sourceText
}]
}
previewSignature.value = snapshot.previewSignature || ''
const defaultSourceFileId = String(uploadedFiles.value[0]?.uid ?? '')
previewItems.value = Array.isArray(snapshot.previewItems)
? snapshot.previewItems.map((item) => ({ ...item, sourceFileId: item.sourceFileId ?? defaultSourceFileId }))
: []
selectedPreviewFileId.value = snapshot.selectedPreviewFileId || defaultSourceFileId || null
selectedPreviewIdsByFile.value = snapshot.selectedPreviewIdsByFile || {}
selectedPreviewId.value = snapshot.selectedPreviewId
|| selectedPreviewIdsByFile.value[selectedPreviewFileId.value ?? '']
|| activePreviewItems.value[0]?.id
|| null
results.value = Array.isArray(snapshot.results) ? snapshot.results : []
selectedResultId.value = snapshot.selectedResultId || results.value[0]?.id || null
Object.assign(generation, snapshot.generation || {})
dirty.value = false
nextTick(() => { restoringDraft.value = false })
ElMessage.info('已恢复上次保存的草稿')
} catch {
localStorage.removeItem(DRAFT_STORAGE_KEY)
}
}
watch(
[() => task.name, () => task.description, processType],
() => {
if (!restoringDraft.value) dirty.value = true
},
)
watch(
[currentStep, task, processType, uploadedFiles, previewSignature,
previewItems, selectedPreviewFileId, selectedPreviewId, selectedPreviewIdsByFile,
results, selectedResultId, generation],
persistDraft,
{ deep: true },
)
function scrollToStepTop() {
document.querySelector<HTMLElement>('.layout-content')?.scrollTo({ top: 0, behavior: 'smooth' })
}
watch(currentStep, () => nextTick(scrollToStepTop))
async function handleFileChange(uploadFile: UploadFile) {
const raw = uploadFile.raw
if (!raw) return
if (raw.size > 200 * 1024 * 1024) {
ElMessage.warning('单文件不能超过 200MB')
return
}
const extension = raw.name.split('.').pop()?.toLowerCase() ?? ''
const textExtensions = ['txt', 'md', 'json', 'jsonl', 'csv']
let content = ''
if (textExtensions.includes(extension)) {
try {
content = await raw.text()
} catch {
content = ''
}
}
const fileContent = content.trim() ? content : DEFAULT_SOURCE_TEXT
const linesCount = fileContent.split('\n').filter((line) => line.trim()).length
// Prevent duplicate upload of the same file
if (!uploadedFiles.value.some(f => f.name === raw.name && f.size === raw.size)) {
uploadedFiles.value.push({
uid: uploadFile.uid || Date.now() + Math.random(),
name: raw.name,
size: raw.size,
count: linesCount,
content: fileContent
})
}
dirty.value = true
}
function useSampleFile() {
uploadedFiles.value = [{
uid: 'sample-1',
name: 'finance_qa.jsonl',
size: 128 * 1024 * 1024,
count: DEFAULT_SOURCE_TEXT.split('\n').filter((line) => line.trim()).length,
content: DEFAULT_SOURCE_TEXT
}]
if (!task.name) task.name = '金融问答清洗任务'
if (!task.description) task.description = '清洗金融领域问答数据,统一格式并生成高质量训练数据。'
dirty.value = true
}
function handleRemoveFile(uid: string | number) {
const index = uploadedFiles.value.findIndex(f => f.uid === uid)
if (index > -1) {
uploadedFiles.value.splice(index, 1)
previewSignature.value = ''
previewItems.value = []
selectedPreviewId.value = null
resetDownstream()
dirty.value = true
}
}
function resetDownstream() {
stopGenerationTimer()
generation.status = 'idle'
generation.progress = 0
generation.message = '确认摘要后即可开始生成,过程中可查看实时进度。'
results.value = []
selectedResultId.value = null
}
async function nextFromCreate() {
const valid = await taskSetupRef.value?.validate()
if (!valid) return
if (uploadedFiles.value.length === 0) {
ElMessage.warning('请上传至少一个源数据文件')
return
}
const signature = `${processType.value}:${uploadedFiles.value.map((file) => `${file.uid}:${file.content}`).join('|')}`
if (signature !== previewSignature.value) {
previewItems.value = uploadedFiles.value.flatMap((file) =>
buildPreviewItems(file.content, processType.value, String(file.uid)),
)
selectedPreviewFileId.value = String(uploadedFiles.value[0]?.uid ?? '') || null
selectedPreviewId.value = activePreviewItems.value[0]?.id ?? null
selectedPreviewIdsByFile.value = selectedPreviewId.value && selectedPreviewFileId.value
? { [selectedPreviewFileId.value]: selectedPreviewId.value }
: {}
previewSignature.value = signature
resetDownstream()
}
currentStep.value = 1
}
function selectPreviewFile(fileId: string) {
if (selectedPreviewFileId.value && selectedPreviewId.value) {
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
}
selectedPreviewFileId.value = fileId
selectedPreviewId.value = selectedPreviewIdsByFile.value[fileId]
|| activePreviewItems.value[0]?.id
|| null
}
function selectPreviewItem(id: string) {
selectedPreviewId.value = id
if (selectedPreviewFileId.value) selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = id
}
function updatePreviewContent(id: string, value: string) {
const item = previewItems.value.find((entry) => entry.id === id)
if (!item) return
item.editedContent = value
item.tokenCount = Math.max(1, Math.ceil(value.length / 2))
item.status = value === item.originalContent ? 'original' : item.sourceStart == null ? 'manual' : 'modified'
dirty.value = true
}
function restorePreviewItem(id: string) {
const item = previewItems.value.find((entry) => entry.id === id)
if (!item || item.sourceStart == null) return
item.editedContent = item.originalContent
item.tokenCount = Math.max(1, Math.ceil(item.originalContent.length / 2))
item.status = 'original'
dirty.value = true
}
function addPreviewItem() {
if (!selectedPreviewFileId.value) return
const id = `manual-${Date.now()}`
previewItems.value.push({
id,
sourceFileId: selectedPreviewFileId.value,
originalContent: '',
editedContent: '',
sourceStart: null,
sourceEnd: null,
sourceStartLine: null,
sourceEndLine: null,
tokenCount: 1,
status: 'manual',
})
selectPreviewItem(id)
dirty.value = true
}
async function removePreviewItem(id: string) {
try {
await ElMessageBox.confirm('删除只影响本次处理,不会修改源文件。确认删除吗?', '删除预览内容', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
} catch {
return
}
const index = previewItems.value.findIndex((item) => item.id === id)
if (index < 0) return
previewItems.value.splice(index, 1)
selectedPreviewId.value = activePreviewItems.value[Math.min(index, activePreviewItems.value.length - 1)]?.id ?? null
if (selectedPreviewFileId.value && selectedPreviewId.value) {
selectedPreviewIdsByFile.value[selectedPreviewFileId.value] = selectedPreviewId.value
}
dirty.value = true
}
function stopGenerationTimer() {
if (generationTimer) clearInterval(generationTimer)
generationTimer = 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'
generation.message = `已完成 ${previewItems.value.length.toLocaleString()} 条数据处理,可进入结果页检查。`
results.value = createResults(previewItems.value)
selectedResultId.value = results.value[0]?.id ?? null
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'
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'
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
}
async function handlePrimaryAction() {
if (currentStepId.value === 'create') {
await nextFromCreate()
return
}
if (currentStepId.value === 'preview') {
if (!previewItems.value.length) {
ElMessage.warning('当前没有可生成的预览内容')
return
}
currentStep.value = 2
return
}
if (currentStepId.value === 'generate') {
if (generation.status === 'success') {
currentStep.value = 3
} else if (generation.status !== 'running') {
startGeneration()
}
return
}
await saveTask()
}
function handleBack() {
if (generation.status === 'running') {
ElMessage.warning('请先停止当前生成任务')
return
}
if (currentStep.value > 0) currentStep.value -= 1
}
async function saveTask() {
if (!validateResults()) {
ElMessage.warning('请先修正校验失败的结果')
return
}
dirty.value = false
localStorage.removeItem(DRAFT_STORAGE_KEY)
allowLeave = true
ElMessage.success('数据处理任务已保存')
await router.push('/data-process')
}
async function handleCancel() {
if (!dirty.value) {
allowLeave = true
router.back()
return
}
try {
await ElMessageBox.confirm('当前存在未保存修改,确定离开吗?', '离开创建任务', {
confirmButtonText: '放弃修改',
cancelButtonText: '继续编辑',
type: 'warning',
})
allowLeave = true
router.back()
} catch {
// 用户继续编辑。
}
}
onBeforeRouteLeave((_to, _from, next) => {
if (allowLeave || !dirty.value) {
next()
return
}
next(window.confirm('当前存在未保存修改,确定离开吗?'))
})
onBeforeUnmount(stopGenerationTimer)
onMounted(restoreDraft)
</script>
<template>
<div class="create-wizard-layout">
<main class="wizard-main">
<div class="wizard-main-inner">
<div class="wizard-steps-container">
<div class="custom-wizard-steps">
<div
v-for="(step, index) in WIZARD_STEPS"
:key="step.id"
class="step-item"
:class="{
'is-active': currentStep === index,
'is-completed': currentStep > index
}"
>
<div v-if="index !== 0" class="step-connector"></div>
<div class="step-node">
<div class="step-icon">
<i v-if="currentStep > index" class="fa fa-check" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-text">
<div class="step-title">{{ step.title }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="wizard-content">
<TaskSetupStep
v-if="currentStepId === 'create'"
ref="taskSetupRef"
v-model:name="task.name"
v-model:description="task.description"
v-model:process-type="processType"
:uploaded-files="uploadedFiles"
@file-change="handleFileChange"
@remove-file="handleRemoveFile"
@use-sample="useSampleFile"
/>
<PreviewCompareStep
v-else-if="currentStepId === 'preview'"
:selected-id="selectedPreviewId"
:selected-file-id="selectedPreviewFileId"
:source-text="activeSourceText"
:items="activePreviewItems"
:process-type="processType"
:file-name="activePreviewFile?.name ?? ''"
:files="previewFiles"
@update:selected-id="selectPreviewItem"
@update:selected-file-id="selectPreviewFile"
@update:item-content="updatePreviewContent"
@restore:item="restorePreviewItem"
@add:item="addPreviewItem"
@remove:item="removePreviewItem"
/>
<GenerationStep
v-else-if="currentStepId === 'generate'"
:task-name="task.name"
:process-type="processType"
:file-name="fileName"
:preview-count="previewItems.length"
:modified-count="modifiedPreviewCount"
:generation="generation"
@stop="stopGeneration"
@retry="startGeneration"
/>
<ResultEditorStep
v-else
v-model:selected-id="selectedResultId"
:items="results"
@update:field="updateResultField"
@restore:item="restoreResult"
/>
</div>
</div>
</main>
<footer class="wizard-footer">
<div class="footer-left">
<el-button v-if="currentStep > 0" @click="handleBack">
<i class="fa fa-arrow-left" style="margin-right: 6px;" /> 返回{{ previousStepLabel }}
</el-button>
<el-button v-else @click="handleCancel">取消</el-button>
</div>
<div class="footer-center">
</div>
<div class="footer-right">
<el-button
class="wizard-primary-action"
type="primary"
:loading="generation.status === 'running'"
:disabled="currentStepId === 'generate' && generation.status === 'running'"
@click="handlePrimaryAction"
>
{{ primaryActionLabel }} <i class="fa" :class="primaryActionIcon" style="margin-left: 6px;" />
</el-button>
</div>
</footer>
</div>
</template>
<style scoped lang="scss">
.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: 860px;
margin: 0 auto;
padding: 0 20px;
}
.step-item {
display: flex;
align-items: center;
flex: 1;
&:first-child {
flex: 0;
}
}
.step-connector {
flex: 1;
height: 2px;
background-color: #e2e8f0;
margin: 0 16px;
transition: background-color 0.3s;
}
.step-item.is-completed .step-connector,
.step-item.is-active .step-connector {
background-color: #5146e5;
}
.step-node {
display: flex;
align-items: center;
gap: 10px;
}
.step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #cbd5e1;
background-color: #fff;
color: #64748b;
font-size: 13px;
font-weight: 650;
transition: all 0.3s;
}
.step-title {
font-size: 14px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
/* State: Active */
.step-item.is-active {
.step-icon {
border-color: #5146e5;
background-color: #eef2ff;
color: #5146e5;
}
.step-title {
color: #1e293b;
}
}
/* State: Completed */
.step-item.is-completed {
.step-icon {
background-color: #5146e5;
border-color: #5146e5;
color: #fff;
}
.step-title {
color: #1e293b;
}
}
.wizard-content {
min-height: 400px;
}
.wizard-footer {
flex-shrink: 0;
height: 64px;
background: #ffffff;
border-top: 1px solid #e2e8f0;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 0 32px;
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;
}
</style>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DataTablePage from '@/components/DataTablePage.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
/** 数据处理任务类型 */
interface DataProcessTask {
id: number | string
name: string
status: string
process_type: string
source_dataset: string
output_dataset?: string
create_time?: string
}
// TODO: 接入真实接口前,先用本地 mock 数据
const router = useRouter()
const dataList = ref<DataProcessTask[]>([
{
id: 1,
name: '客服问答数据清洗',
status: 'completed',
process_type: '去重清洗',
source_dataset: '客服对话原始集',
output_dataset: '客服对话清洗集',
create_time: '2026-07-08 14:23:00',
},
{
id: 2,
name: '指令微调数据构造',
status: 'running',
process_type: '指令构造',
source_dataset: '通用语料库',
output_dataset: 'SFT 指令集',
create_time: '2026-07-09 09:10:00',
},
{
id: 3,
name: '敏感信息脱敏处理',
status: 'pending',
process_type: '脱敏处理',
source_dataset: '用户反馈数据',
create_time: '2026-07-09 16:45:00',
},
{
id: 4,
name: '多轮对话拼接',
status: 'failed',
process_type: '格式转换',
source_dataset: '单轮问答集',
create_time: '2026-07-10 08:30:00',
},
])
const activeTab = ref('all')
const filteredDataList = computed(() => {
let list = dataList.value
if (activeTab.value !== 'all') {
if (activeTab.value === 'processing') {
list = list.filter(item => item.status === 'running' || item.status === 'pending')
} else if (activeTab.value === 'completed') {
list = list.filter(item => item.status === 'completed')
} else if (activeTab.value === 'failed') {
list = list.filter(item => item.status === 'failed')
}
}
return list
})
/** 新建数据处理任务 */
function handleCreate() {
router.push('/data-process/create')
}
/** 查看任务详情(功能开发中) */
function viewDetail(_row: DataProcessTask) {
ElMessage.info('查看详情功能开发中...')
}
/** 删除任务(功能开发中) */
function handleDelete(_row: DataProcessTask) {
ElMessage.info('删除功能开发中...')
}
function formatDateTime(value?: string) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
</script>
<template>
<div class="data-process-page" style="height: 100%;">
<DataTablePage
title=""
:data="filteredDataList"
searchable
:search-fields="['name']"
create-text="新建数据处理任务"
create-to="/data-process/create"
row-key="id"
:page-size="10"
>
<template #title>
<!-- 胶囊切换控件 -->
<div class="capsule-tabs">
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
全部任务
</button>
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'processing' }"
@click="activeTab = 'processing'"
>
处理中
</button>
<button
class="capsule-tab-item"
:class="{ active: activeTab === 'completed' }"
@click="activeTab = 'completed'"
>
已完成
</button>
</div>
</template>
<template #columns>
<el-table-column label="任务名称" prop="name" align="center" show-overflow-tooltip />
<el-table-column label="任务状态" align="center" width="110">
<template #default="{ row }">
<ModelStatusTag :status="row.status" />
</template>
</el-table-column>
<el-table-column label="处理类型" align="center" width="140">
<template #default="{ row }">{{ row.process_type || '-' }}</template>
</el-table-column>
<el-table-column label="源数据集" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.source_dataset || '-' }}</template>
</el-table-column>
<el-table-column label="输出数据集" align="center" show-overflow-tooltip>
<template #default="{ row }">{{ row.output_dataset || '-' }}</template>
</el-table-column>
<el-table-column label="创建时间" align="center" width="190">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
</template>
<template #actions="{ row }">
<div class="action-buttons">
<el-button type="primary" link size="small" @click="viewDetail(row)">
<i class="fa fa-file-text-o" style="margin-right: 4px" />详情
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
<i class="fa fa-trash-o" style="margin-right: 4px" />删除
</el-button>
</div>
</template>
</DataTablePage>
</div>
</template>
<style scoped lang="scss">
/* 胶囊切换栏样式 */
.capsule-tabs {
display: flex;
background: #f1f5f9;
padding: 3px;
border-radius: 8px;
gap: 2px;
border: 1px solid #e2e8f0;
}
.capsule-tab-item {
border: 0;
background: transparent;
padding: 6px 20px;
font-size: 13px;
font-weight: 500;
color: #64748b;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s ease;
outline: none;
&:hover {
color: #1e293b;
}
&.active {
background: #fff;
color: #4f46e5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
font-weight: 600;
}
}
.action-buttons {
display: flex;
justify-content: center;
gap: 8px;
}
</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' ? '非结构化数据' : '结构化数据' }}</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: #fbfcfe;
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,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: #fbfcfe;
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: #fbfcfe;
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,469 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import type { ProcessType } from './types'
const props = defineProps<{
name: string
description: string
processType: ProcessType
uploadedFiles: { uid: string | number; name: string; size: number; count: number }[]
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:description': [value: string]
'update:processType': [value: ProcessType]
'file-change': [file: UploadFile]
'remove-file': [uid: string | number]
'use-sample': []
}>()
const formRef = ref<FormInstance>()
const formModel = computed(() => ({
name: props.name,
processType: props.processType,
}))
const rules: FormRules = {
name: [
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{ max: 50, message: '任务名称不能超过 50 个字符', trigger: 'blur' },
],
processType: [{ required: true, message: '请选择数据处理类型', trigger: 'change' }],
}
const uploadAccept = computed(() => props.processType === 'unstructured'
? '.txt,.md,.pdf,.docx,.doc,.json,.jsonl'
: '.json,.jsonl,.csv,.xlsx,.xls')
const FILE_PAGE_SIZE = 10
const currentFilePage = ref(1)
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)
})
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`
}
async function validate() {
if (!formRef.value) return false
try {
await formRef.value.validate()
return true
} catch {
return false
}
}
defineExpose({ validate })
</script>
<template>
<section class="task-setup-step">
<el-form ref="formRef" :model="formModel" :rules="rules" label-position="top">
<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>
</div>
</el-form-item>
</div>
<div class="form-section upload-section">
<div class="section-title-row">
<div>
<h3>源数据上传</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)"
>
<i class="fa fa-cloud-upload upload-icon" />
<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)"
>
<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" /></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" /> 校验通过</span>
<el-button link type="danger" @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"
/>
</section>
</div>
</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;
&:last-child {
margin-bottom: 0;
border-bottom: 0;
}
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(2, 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,
.file-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;
}
.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: #fbfcfe;
border-color: #dfe3ea;
}
.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: #fbfcfe;
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 {
width: 28px;
height: 28px;
font-size: 14px;
border-radius: 7px;
}
.file-main {
display: flex;
flex: 1;
flex-direction: column;
gap: 5px;
min-width: 0;
strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #273142;
font-size: 14px;
}
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) {
.type-options {
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;
}
}
</style>

View File

@@ -0,0 +1,89 @@
import type { PreviewItem, ProcessType, ResultItem, SourceLine } 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 }
})
}
export function buildPreviewItems(sourceText: string, processType: ProcessType, sourceFileId = 'default-source'): PreviewItem[] {
const meaningfulLines = sourceLines(sourceText).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
}
export function createResults(items: PreviewItem[]): ResultItem[] {
return items.slice(0, 12).map((item, index) => {
const [firstLine = '', ...rest] = item.editedContent.split('\n')
const output = rest.join('\n').trim() || item.editedContent.trim()
const instruction = firstLine.replace(/^问[:]\s*/, '').trim() || `数据条目 ${index + 1}`
return {
id: `result-${index + 1}`,
instruction,
input: '',
output,
originalInstruction: instruction,
originalInput: '',
originalOutput: output,
status: 'valid',
}
})
}

View File

@@ -0,0 +1,41 @@
export type ProcessType = 'structured' | 'unstructured'
export type StepId = 'create' | 'preview' | 'generate' | 'results'
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
}