<script lang="ts" setup>
|
import type { SaveTemplateStepsParams, TemplateListItem, TemplateStep, TemplateStepConfiguration } from '#/api/x/eln/templates';
|
|
import { computed, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicDrawer, useDrawerInner } from '@jnpf/ui/drawer';
|
import { BasicModal, useModal } from '@jnpf/ui/modal';
|
|
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
import draggable from 'vuedraggable';
|
|
import { getTemplateSteps, saveTemplateSteps } from '#/api/x/eln/templates';
|
|
defineOptions({ name: 'ElnTemplateStepSettingsDrawer' });
|
|
const emit = defineEmits(['register', 'saved']);
|
|
interface StepDraft {
|
id?: string;
|
key: string;
|
name: string;
|
}
|
|
const { createMessage } = useMessage();
|
const version = ref<TemplateListItem>();
|
const steps = ref<StepDraft[]>([]);
|
const stepMigrations = ref<Record<string, string>>({});
|
const pendingDelete = ref<StepDraft>();
|
const migrationTargetId = ref<string>();
|
let localKeySequence = 0;
|
|
const editable = computed(() => version.value?.bizStatus === 'DRAFT');
|
const drawerTitle = computed(() => {
|
const item = version.value;
|
return item ? `步骤设置 - ${item.mubanBianhao} - ${item.mubanBanben}` : '步骤设置';
|
});
|
const migrationTargetOptions = computed(() => {
|
const deletingId = pendingDelete.value?.id;
|
return steps.value.filter((item) => item.id && item.id !== deletingId).map((item) => ({ label: item.name.trim() || '未命名步骤', value: item.id as string }));
|
});
|
|
const [registerMigrationModal, { closeModal: closeMigrationModal, openModal: openMigrationModal }] = useModal();
|
const [registerDrawer, { changeLoading, changeOkLoading, closeDrawer }] = useDrawerInner(init);
|
|
async function init(data: { version: TemplateListItem }) {
|
version.value = data.version;
|
steps.value = [];
|
stepMigrations.value = {};
|
pendingDelete.value = undefined;
|
migrationTargetId.value = undefined;
|
localKeySequence = 0;
|
changeLoading(true);
|
try {
|
const response = await getTemplateSteps(data.version.id);
|
applyConfiguration(response.data);
|
} catch (error) {
|
createMessage.error(formatRequestError(error));
|
} finally {
|
changeLoading(false);
|
}
|
}
|
|
function applyConfiguration(configuration: TemplateStepConfiguration) {
|
steps.value = [...(configuration.steps || [])].sort(compareOrder).map((item) => ({ id: item.id, key: item.id, name: item.name }));
|
}
|
|
function compareOrder(left: TemplateStep, right: TemplateStep) {
|
return left.order - right.order || left.id.localeCompare(right.id);
|
}
|
|
function addStep() {
|
if (!editable.value) return;
|
if (steps.value.length >= 20) {
|
createMessage.warning('模板步骤不能超过 20 个');
|
return;
|
}
|
const key = nextLocalKey('step');
|
steps.value.push({ key, name: `步骤 ${steps.value.length + 1}` });
|
}
|
|
function requestDeleteStep(step: StepDraft) {
|
if (!editable.value || steps.value.length <= 1) return;
|
if (!step.id) {
|
steps.value = steps.value.filter((item) => item.key !== step.key);
|
return;
|
}
|
if (Object.values(stepMigrations.value).includes(step.id)) {
|
createMessage.warning('该步骤已作为其他步骤的迁移目标');
|
return;
|
}
|
const targets = steps.value.filter((item) => item.id && item.id !== step.id);
|
if (targets.length === 0) {
|
createMessage.warning('至少保留一个已有步骤,可直接修改名称');
|
return;
|
}
|
pendingDelete.value = step;
|
migrationTargetId.value = undefined;
|
openMigrationModal(true);
|
}
|
|
function confirmDeleteStep() {
|
const deleting = pendingDelete.value;
|
const targetId = migrationTargetId.value;
|
if (!deleting?.id || !targetId) {
|
createMessage.warning('请选择迁移目标步骤');
|
return;
|
}
|
stepMigrations.value = { ...stepMigrations.value, [deleting.id]: targetId };
|
steps.value = steps.value.filter((item) => item.key !== deleting.key);
|
pendingDelete.value = undefined;
|
migrationTargetId.value = undefined;
|
closeMigrationModal();
|
}
|
|
async function save() {
|
const item = version.value;
|
if (!item || !editable.value) return;
|
const validationMessage = validateDraft();
|
if (validationMessage) {
|
createMessage.warning(validationMessage);
|
return;
|
}
|
const payload: SaveTemplateStepsParams = {
|
stepMigrations: { ...stepMigrations.value },
|
steps: steps.value.map((step) => ({ id: step.id, name: step.name.trim() })),
|
};
|
changeOkLoading(true);
|
try {
|
await saveTemplateSteps(item.id, payload);
|
createMessage.success('步骤设置已保存');
|
emit('saved', item.id);
|
closeDrawer();
|
} catch (error) {
|
createMessage.error(formatRequestError(error));
|
} finally {
|
changeOkLoading(false);
|
}
|
}
|
|
function validateDraft() {
|
if (steps.value.length < 1 || steps.value.length > 20) return '模板步骤数量必须为 1 至 20 个';
|
const stepNames = steps.value.map((item) => item.name.trim());
|
if (stepNames.some((name) => !name)) return '步骤名称不能为空';
|
if (new Set(stepNames.map((name) => name.toLocaleLowerCase())).size !== stepNames.length) return '步骤名称不能重复';
|
return '';
|
}
|
|
function nextLocalKey(kind: 'step') {
|
localKeySequence += 1;
|
return `local-${kind}-${localKeySequence}`;
|
}
|
|
function formatRequestError(error: unknown) {
|
return error instanceof Error && error.message ? error.message : '操作失败,请稍后重试';
|
}
|
</script>
|
|
<template>
|
<BasicDrawer
|
v-bind="$attrs"
|
@register="registerDrawer"
|
class="full-drawer"
|
keyboard
|
destroy-on-close
|
:show-footer="editable"
|
:title="drawerTitle"
|
width="min(900px, calc(100vw - 16px))"
|
@ok="save">
|
<div class="step-settings">
|
<section class="settings-section step-section">
|
<header class="section-header">
|
<div>
|
<h3>步骤</h3>
|
<span>{{ steps.length }} / 20</span>
|
</div>
|
<a-button v-if="editable" data-test="add-step" :disabled="steps.length >= 20" type="link" @click="addStep">
|
<template #icon><PlusOutlined /></template>
|
添加步骤
|
</a-button>
|
</header>
|
|
<div class="step-columns" :class="{ editable }">
|
<span>顺序</span>
|
<span>步骤名称</span>
|
<span v-if="editable"></span>
|
</div>
|
<draggable v-model="steps" :animation="200" data-test="step-list" :disabled="!editable" handle=".drag-handle" item-key="key">
|
<template #item="{ element, index }">
|
<div class="config-row step-row" :class="{ editable }">
|
<div class="step-order">
|
<button v-if="editable" class="drag-handle" title="拖动排序" type="button"><i class="icon-ym icon-ym-darg"></i></button>
|
<span>{{ index + 1 }}</span>
|
</div>
|
<a-input v-if="editable" v-model:value="element.name" :maxlength="100" placeholder="步骤名称" />
|
<span v-else class="readonly-name">{{ element.name }}</span>
|
<a-tooltip v-if="editable" :title="steps.length <= 1 ? '模板至少保留一个步骤' : '删除步骤'">
|
<a-button
|
danger
|
:data-test="`delete-step-${element.key}`"
|
:disabled="steps.length <= 1"
|
shape="circle"
|
type="text"
|
@click="requestDeleteStep(element)">
|
<DeleteOutlined />
|
</a-button>
|
</a-tooltip>
|
</div>
|
</template>
|
</draggable>
|
</section>
|
</div>
|
|
<BasicModal @register="registerMigrationModal" destroy-on-close title="迁移步骤内容" @ok="confirmDeleteStep">
|
<a-form-item label="迁移到" required>
|
<a-select v-model:value="migrationTargetId" data-test="migration-target" :options="migrationTargetOptions" placeholder="请选择已有步骤" />
|
</a-form-item>
|
</BasicModal>
|
</BasicDrawer>
|
</template>
|
|
<style lang="scss" scoped>
|
.step-settings {
|
min-height: 100%;
|
padding: 16px 24px 24px;
|
}
|
|
.section-header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
min-height: 40px;
|
border-bottom: 1px solid var(--border-color-base);
|
|
> div {
|
display: flex;
|
gap: 10px;
|
align-items: baseline;
|
}
|
|
h3 {
|
margin: 0;
|
font-size: 15px;
|
font-weight: 600;
|
}
|
|
span {
|
color: var(--text-color-secondary);
|
}
|
}
|
|
.config-row {
|
display: grid;
|
gap: 12px;
|
align-items: center;
|
min-height: 52px;
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.step-columns,
|
.step-row {
|
display: grid;
|
grid-template-columns: 72px minmax(180px, 1fr);
|
gap: 12px;
|
align-items: center;
|
}
|
|
.step-columns.editable,
|
.step-row.editable {
|
grid-template-columns: 72px minmax(180px, 1fr) 36px;
|
}
|
|
.step-columns {
|
min-height: 38px;
|
color: var(--text-color-secondary);
|
border-bottom: 1px solid var(--border-color-base);
|
}
|
|
.step-order {
|
display: flex;
|
gap: 8px;
|
align-items: center;
|
}
|
|
.drag-handle {
|
display: inline-flex;
|
align-items: center;
|
justify-content: center;
|
width: 28px;
|
height: 28px;
|
padding: 0;
|
color: var(--text-color-secondary);
|
cursor: move;
|
background: transparent;
|
border: 0;
|
}
|
|
.readonly-name {
|
min-width: 0;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
.row-index {
|
color: var(--text-color-secondary);
|
}
|
|
@media (max-width: 720px) {
|
.step-settings {
|
padding-inline: 16px;
|
}
|
|
.step-columns {
|
display: none;
|
}
|
|
.step-row,
|
.step-row.editable {
|
grid-template-columns: 48px minmax(0, 1fr) 36px;
|
padding-block: 10px;
|
}
|
|
}
|
</style>
|