<script lang="ts" setup>
|
import type { SharedFilled, SharedFillPreview, SharedFillTarget } from '#/api/x/eln/records';
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
|
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
import { Alert, Button, Checkbox, Empty, Input, Spin, Tag } from 'ant-design-vue';
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { createRecordsBatch, executeSharedFill, previewSharedFill } from '#/api/x/eln/records';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { buildSharedFillCommand, defaultTargetIds, ElnSharedFillParamError, requiresTargetOverwrite, resolveTestWorkItemIds } from './sharedFillView';
|
|
defineOptions({ name: 'XElnRecordSharedFill' });
|
|
const testWorkItemIds = ref<string[]>([]);
|
const loading = ref(true);
|
const creatingMissing = ref(false);
|
const submitting = ref(false);
|
const errorMessage = ref('');
|
const preview = ref<SharedFillPreview>();
|
const result = ref<SharedFilled>();
|
const selectedFieldIds = ref<string[]>([]);
|
const selectedTargetIds = ref<string[]>([]);
|
const fieldValues = reactive<Record<string, string>>({});
|
const overwriteReasons = reactive<Record<string, string>>({});
|
const operation = reactive({ code: '', fingerprint: '' });
|
|
const selectedFields = computed(() => preview.value?.fields.filter((field) => selectedFieldIds.value.includes(field.fieldVersionId)) || []);
|
const hasPreview = computed(() => Boolean(preview.value));
|
const missingTargets = computed(() => preview.value?.targets.filter((target) => target.errorCode === 'RECORD_NOT_FOUND') || []);
|
|
function fieldChecked(fieldVersionId: string) {
|
return selectedFieldIds.value.includes(fieldVersionId);
|
}
|
|
function targetChecked(testWorkItemId: string) {
|
return selectedTargetIds.value.includes(testWorkItemId);
|
}
|
|
function targetSupported(target: SharedFillTarget) {
|
return (
|
target.eligible && selectedFields.value.length > 0 && selectedFields.value.every((field) => field.targetTestWorkItemIds.includes(target.testWorkItemId))
|
);
|
}
|
|
function targetRequiresOverwrite(testWorkItemId: string) {
|
if (!preview.value) return false;
|
return requiresTargetOverwrite(preview.value, testWorkItemId, selectedFieldIds.value, fieldValues);
|
}
|
|
function markChanged() {
|
result.value = undefined;
|
}
|
|
function toggleField(fieldVersionId: string, checked: boolean) {
|
selectedFieldIds.value = checked ? [...selectedFieldIds.value, fieldVersionId] : selectedFieldIds.value.filter((id) => id !== fieldVersionId);
|
if (preview.value) selectedTargetIds.value = defaultTargetIds(preview.value, selectedFieldIds.value);
|
markChanged();
|
}
|
|
function toggleTarget(testWorkItemId: string, checked: boolean) {
|
selectedTargetIds.value = checked ? [...selectedTargetIds.value, testWorkItemId] : selectedTargetIds.value.filter((id) => id !== testWorkItemId);
|
markChanged();
|
}
|
|
function currentValue(target: SharedFillTarget) {
|
if (!selectedFields.value.length) return '-';
|
return selectedFields.value.map((field) => `${field.fieldName || field.fieldCode}: ${target.currentValues[field.fieldVersionId] || '-'}`).join(' / ');
|
}
|
|
function requestFingerprint() {
|
return JSON.stringify({
|
fieldValues: selectedFieldIds.value.map((id) => [id, fieldValues[id] || '']),
|
overwriteReasons: selectedTargetIds.value.map((id) => [id, overwriteReasons[id] || '']),
|
previewToken: preview.value?.previewToken,
|
selectedFieldIds: selectedFieldIds.value,
|
selectedTargetIds: selectedTargetIds.value,
|
testWorkItemIds: testWorkItemIds.value,
|
});
|
}
|
|
function operationCode() {
|
const fingerprint = requestFingerprint();
|
if (operation.fingerprint !== fingerprint) {
|
operation.fingerprint = fingerprint;
|
operation.code = `shared-fill-${uuidv4()}`;
|
}
|
return operation.code;
|
}
|
|
async function loadPreview() {
|
loading.value = true;
|
errorMessage.value = '';
|
result.value = undefined;
|
try {
|
testWorkItemIds.value = resolveTestWorkItemIds(onlineUtils.getViewParam('testWorkItemIds'));
|
const response = await previewSharedFill({ testWorkItemIds: testWorkItemIds.value });
|
preview.value = response.data;
|
selectedFieldIds.value = [];
|
selectedTargetIds.value = [];
|
Object.keys(fieldValues).forEach((key) => delete fieldValues[key]);
|
Object.keys(overwriteReasons).forEach((key) => delete overwriteReasons[key]);
|
operation.code = '';
|
operation.fingerprint = '';
|
} catch (error: any) {
|
preview.value = undefined;
|
errorMessage.value = error instanceof ElnSharedFillParamError ? error.message : error?.message || '批量填写预览失败';
|
} finally {
|
loading.value = false;
|
}
|
}
|
|
async function createMissingRecords() {
|
const missingTestWorkItemIds = missingTargets.value.map((target) => target.testWorkItemId);
|
if (!missingTestWorkItemIds.length) return;
|
const targetNames = new Map(missingTargets.value.map((target) => [target.testWorkItemId, target.testWorkItemName || target.testWorkItemId]));
|
creatingMissing.value = true;
|
errorMessage.value = '';
|
try {
|
const response = await createRecordsBatch({
|
operationCode: `record-batch-${uuidv4()}`,
|
testWorkItemIds: missingTestWorkItemIds,
|
});
|
const failures = (response.data?.items || []).filter((item) => item.status === 'FAILED');
|
await loadPreview();
|
if (failures.length) {
|
errorMessage.value = failures
|
.map((item) => `${targetNames.get(item.testWorkItemId)}(${item.testWorkItemId}):${item.errorMessage || '电子记录创建失败'}`)
|
.join(';');
|
}
|
} catch (error: any) {
|
errorMessage.value = error?.message || '缺失电子记录创建失败';
|
} finally {
|
creatingMissing.value = false;
|
}
|
}
|
|
async function submit() {
|
if (!preview.value) return;
|
submitting.value = true;
|
errorMessage.value = '';
|
try {
|
const command = buildSharedFillCommand(preview.value, {
|
fieldValues,
|
operationCode: operationCode(),
|
overwriteReasons,
|
selectedFieldIds: selectedFieldIds.value,
|
selectedTargetIds: selectedTargetIds.value,
|
});
|
const response = await executeSharedFill(command);
|
result.value = response.data;
|
} catch (error: any) {
|
errorMessage.value = error instanceof ElnSharedFillParamError ? error.message : error?.message || '批量填写提交失败';
|
} finally {
|
submitting.value = false;
|
}
|
}
|
|
function resultColor(status: string) {
|
if (status === 'COMMITTED') return 'success';
|
if (status === 'UNCHANGED') return 'default';
|
return 'error';
|
}
|
|
function resultLabel(status: string) {
|
if (status === 'COMMITTED') return '已写入';
|
if (status === 'UNCHANGED') return '无需变更';
|
return '未写入';
|
}
|
|
onMounted(loadPreview);
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper shared-fill">
|
<div class="shared-fill__toolbar">
|
<div>
|
<h2>批量填写</h2>
|
<span v-if="preview" class="shared-fill__meta">
|
检验项目 {{ preview.testDefinitionName || preview.testDefinitionId }} · {{ preview.targets.length }} 个检验项
|
</span>
|
</div>
|
<Button :disabled="loading || submitting" @click="loadPreview">
|
<template #icon><ReloadOutlined /></template>
|
重新预览
|
</Button>
|
</div>
|
|
<Spin v-if="loading" class="shared-fill__state" data-test="shared-fill-loading" />
|
<Alert v-else-if="errorMessage && !hasPreview" class="shared-fill__alert" :message="errorMessage" show-icon type="error" data-test="shared-fill-error" />
|
<template v-else-if="preview">
|
<Alert
|
v-if="errorMessage"
|
class="shared-fill__alert"
|
:message="errorMessage"
|
closable
|
show-icon
|
type="error"
|
data-test="shared-fill-error"
|
@close="errorMessage = ''" />
|
<Alert
|
v-if="missingTargets.length"
|
class="shared-fill__alert"
|
:message="`${missingTargets.length} 条检验项尚未创建电子记录`"
|
show-icon
|
type="warning"
|
data-test="shared-fill-missing-records">
|
<template #action>
|
<Button data-test="shared-fill-create-missing" :loading="creatingMissing" size="small" @click="createMissingRecords">创建缺失记录并重新预览</Button>
|
</template>
|
</Alert>
|
|
<section class="shared-fill__section" data-test="shared-fill-fields">
|
<h3>共享字段</h3>
|
<div v-if="preview.fields.length" class="shared-fill__field-list">
|
<div v-for="field in preview.fields" :key="field.fieldVersionId" class="shared-fill__field-row">
|
<Checkbox :checked="fieldChecked(field.fieldVersionId)" @change="toggleField(field.fieldVersionId, $event.target.checked)">
|
{{ field.fieldName || field.fieldCode }}
|
</Checkbox>
|
<span class="shared-fill__field-code">{{ field.fieldCode }}</span>
|
<Input
|
:value="fieldValues[field.fieldVersionId] || ''"
|
:addon-after="field.unit || undefined"
|
:disabled="!fieldChecked(field.fieldVersionId)"
|
placeholder="填写统一值"
|
@change="
|
fieldValues[field.fieldVersionId] = $event.target.value || '';
|
markChanged();
|
" />
|
</div>
|
</div>
|
<Empty v-else description="所选记录没有可共享填写的字段" />
|
</section>
|
|
<section class="shared-fill__section" data-test="shared-fill-targets">
|
<h3>填写目标</h3>
|
<div class="shared-fill__table-wrap">
|
<table>
|
<thead>
|
<tr>
|
<th class="shared-fill__check"></th>
|
<th>检验项</th>
|
<th>当前值</th>
|
<th>状态 / 覆盖原因</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="target in preview.targets" :key="target.testWorkItemId">
|
<td class="shared-fill__check">
|
<Checkbox
|
:checked="targetChecked(target.testWorkItemId)"
|
:disabled="!targetSupported(target)"
|
@change="toggleTarget(target.testWorkItemId, $event.target.checked)" />
|
</td>
|
<td>{{ target.testWorkItemName || target.testWorkItemId }}</td>
|
<td>{{ currentValue(target) }}</td>
|
<td>
|
<span v-if="!target.eligible" class="shared-fill__rejected">{{ target.errorMessage || '当前不可填写' }}</span>
|
<Input
|
v-else-if="targetChecked(target.testWorkItemId) && targetRequiresOverwrite(target.testWorkItemId)"
|
:value="overwriteReasons[target.testWorkItemId] || ''"
|
placeholder="必填:说明覆盖原因"
|
@change="
|
overwriteReasons[target.testWorkItemId] = $event.target.value || '';
|
markChanged();
|
" />
|
<span v-else-if="targetRequiresOverwrite(target.testWorkItemId)" class="shared-fill__muted">已有不同值,默认不选</span>
|
<span v-else class="shared-fill__muted">可填写</span>
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</section>
|
|
<div class="shared-fill__actions">
|
<Button :loading="submitting" type="primary" @click="submit">
|
<template #icon><CheckOutlined /></template>
|
确认填写
|
</Button>
|
</div>
|
|
<section v-if="result" class="shared-fill__section" data-test="shared-fill-results">
|
<h3>提交结果</h3>
|
<div v-for="item in result.targets" :key="item.testWorkItemId" class="shared-fill__result-row">
|
<span>{{ item.testWorkItemId }}</span>
|
<Tag :color="resultColor(item.status)">{{ resultLabel(item.status) }}</Tag>
|
<span>{{ item.errorMessage || `记录版本 ${item.recordVersion}` }}</span>
|
</div>
|
</section>
|
</template>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.shared-fill {
|
flex-direction: column;
|
min-height: 100%;
|
padding: 16px 20px 24px;
|
overflow: auto;
|
background: #fff;
|
color: rgb(0 0 0 / 88%);
|
|
&__toolbar,
|
&__actions,
|
&__field-row,
|
&__result-row {
|
display: flex;
|
align-items: center;
|
}
|
|
&__toolbar {
|
justify-content: space-between;
|
gap: 16px;
|
padding-bottom: 12px;
|
border-bottom: 1px solid #e5e7eb;
|
|
h2 {
|
margin: 0;
|
font-size: 18px;
|
line-height: 28px;
|
}
|
}
|
|
&__meta,
|
&__muted,
|
&__field-code {
|
color: rgb(0 0 0 / 45%);
|
}
|
|
&__state {
|
display: block;
|
margin: 96px auto;
|
}
|
&__alert {
|
margin-top: 12px;
|
}
|
&__section {
|
padding: 16px 0;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
&__section h3 {
|
margin: 0 0 10px;
|
font-size: 15px;
|
line-height: 24px;
|
}
|
&__field-list {
|
border-top: 1px solid #f0f0f0;
|
}
|
&__field-row {
|
display: grid;
|
grid-template-columns: minmax(150px, 220px) minmax(140px, 1fr) minmax(220px, 360px);
|
gap: 12px;
|
min-height: 48px;
|
padding: 8px 0;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
&__field-code {
|
overflow: hidden;
|
font-family: monospace;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
&__table-wrap {
|
overflow-x: auto;
|
}
|
table {
|
width: 100%;
|
min-width: 760px;
|
border-collapse: collapse;
|
}
|
th,
|
td {
|
padding: 10px 12px;
|
border-bottom: 1px solid #f0f0f0;
|
text-align: left;
|
vertical-align: middle;
|
}
|
th {
|
background: #fafafa;
|
font-weight: 500;
|
}
|
&__check {
|
width: 44px;
|
text-align: center;
|
}
|
&__rejected {
|
color: #cf1322;
|
}
|
&__actions {
|
justify-content: flex-end;
|
padding: 16px 0;
|
}
|
&__result-row {
|
display: grid;
|
grid-template-columns: minmax(140px, 1fr) 100px minmax(180px, 2fr);
|
gap: 12px;
|
padding: 9px 0;
|
border-bottom: 1px solid #f0f0f0;
|
}
|
}
|
|
@media (max-width: 720px) {
|
.shared-fill {
|
padding: 12px;
|
&__toolbar {
|
align-items: flex-start;
|
}
|
&__field-row {
|
grid-template-columns: 1fr;
|
gap: 6px;
|
}
|
&__result-row {
|
grid-template-columns: 1fr auto;
|
}
|
&__result-row span:last-child {
|
grid-column: 1 / -1;
|
}
|
}
|
}
|
</style>
|