Files
YG_FT/frontend/src/views/data-process/create/ResultEditorStep.vue
2026-07-23 15:10:13 +08:00

337 lines
8.2 KiB
Vue

<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>
<el-tag v-if="selectedItem.split" size="small" effect="plain">{{ selectedItem.split }}</el-tag>
<el-tag
v-if="selectedItem.qualityScore != null"
size="small"
:type="selectedItem.qualityScore >= 80 ? 'success' : selectedItem.qualityScore >= 60 ? 'warning' : 'danger'"
>质量 {{ selectedItem.qualityScore.toFixed(1) }}</el-tag>
</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 v-if="selectedItem.qualityFlags?.length" class="quality-flags">
<el-tag
v-for="flag in selectedItem.qualityFlags"
:key="flag"
size="small"
type="warning"
effect="plain"
>{{ flag }}</el-tag>
</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;
}
.quality-flags {
display: flex;
flex-wrap: wrap;
gap: 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>