<script setup lang="ts">
|
import type { DraftField } from '../domain/draft';
|
import type { FieldConflict } from '../domain/conflict';
|
import type { FillFieldValue, RepeatGroup, RepeatRow } from '../domain/types';
|
|
import { DeleteOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons-vue';
|
import { computed, ref } from 'vue';
|
|
import { parseTag } from '../domain/tags';
|
import FieldDraftList from './FieldDraftList.vue';
|
|
interface RepeatRowView extends RepeatRow {
|
fields: DraftField[];
|
}
|
|
interface RepeatGroupView extends Omit<RepeatGroup, 'rows'> {
|
rows: RepeatRowView[];
|
}
|
|
const props = withDefaults(defineProps<{
|
appendDisabled: boolean;
|
conflicts?: Record<string, FieldConflict>;
|
disabled: boolean;
|
errors: Record<string, string>;
|
fields: DraftField[];
|
groups: RepeatGroup[];
|
focusedTag?: null | string;
|
}>(), {
|
conflicts: () => ({}),
|
focusedTag: null,
|
});
|
const emit = defineEmits<{
|
append: [groupCode: string];
|
delete: [groupCode: string, rowId: string, reason: string];
|
locate: [tag: string];
|
resolve: [tag: string, resolution: 'document' | 'draft'];
|
restore: [groupCode: string, rowId: string];
|
update: [tag: string, value: FillFieldValue];
|
}>();
|
const deleteTarget = ref<null | { groupCode: string; rowId: string }>(null);
|
const deleteReason = ref('');
|
|
function updateField(tag: string, value: FillFieldValue) {
|
emit('update', tag, value);
|
}
|
|
function resolveField(tag: string, resolution: 'document' | 'draft') {
|
emit('resolve', tag, resolution);
|
}
|
|
function activeRows(group: RepeatGroupView): number {
|
return group.rows.filter((row) => row.status === 'ACTIVE').length;
|
}
|
|
const groupViews = computed<RepeatGroupView[]>(() => {
|
const result = new Map<string, RepeatGroupView>();
|
for (const group of props.groups) {
|
result.set(group.groupCode, {
|
activeRowCount: group.activeRowCount,
|
groupCode: group.groupCode,
|
maxRows: group.maxRows,
|
minRows: group.minRows,
|
rows: group.rows.map((row) => ({ ...row, fields: [] })),
|
});
|
}
|
for (const field of props.fields) {
|
const tag = parseTag(field.tag);
|
if (tag?.type !== 'repeat') continue;
|
let group = result.get(tag.groupCode);
|
if (!group) {
|
group = { activeRowCount: 0, groupCode: tag.groupCode, maxRows: 100, minRows: 0, rows: [] };
|
result.set(tag.groupCode, group);
|
}
|
if (tag.rowId === 'prototype') continue;
|
let row = group.rows.find((item) => item.rowId === tag.rowId);
|
if (!row) {
|
row = {
|
fields: [], groupCode: tag.groupCode, rowId: tag.rowId,
|
rowOrder: group.rows.length + 1, status: 'ACTIVE',
|
};
|
group.rows.push(row);
|
group.activeRowCount = group.rows.length;
|
}
|
row.fields.push(field);
|
}
|
return [...result.values()].map((group) => ({
|
...group,
|
rows: [...group.rows].sort((left, right) => left.rowOrder - right.rowOrder),
|
}));
|
});
|
|
function startDelete(groupCode: string, rowId: string) {
|
deleteTarget.value = { groupCode, rowId };
|
deleteReason.value = '';
|
}
|
|
function cancelDelete() {
|
deleteTarget.value = null;
|
deleteReason.value = '';
|
}
|
|
function confirmDelete() {
|
const reason = deleteReason.value.trim();
|
if (!deleteTarget.value || !reason) return;
|
emit('delete', deleteTarget.value.groupCode, deleteTarget.value.rowId, reason);
|
cancelDelete();
|
}
|
</script>
|
|
<template>
|
<section v-for="group in groupViews" :key="group.groupCode" class="repeat-group" :data-group="group.groupCode">
|
<div class="repeat-group-header">
|
<div><strong>{{ group.groupCode }}</strong><span class="repeat-count">{{ group.activeRowCount }} / {{ group.maxRows }} 行</span></div>
|
<a-button
|
size="small"
|
type="text"
|
:disabled="appendDisabled || group.activeRowCount >= group.maxRows"
|
:aria-label="`新增 ${group.groupCode} 行`"
|
@click="emit('append', group.groupCode)">
|
<template #icon><PlusOutlined /></template>
|
新增行
|
</a-button>
|
</div>
|
<a-empty v-if="!group.rows.length" :image="false" description="暂无数据行" />
|
<div v-for="(row, index) in group.rows" :key="row.rowId" class="repeat-row" :data-row-id="row.rowId">
|
<div class="repeat-row-header">
|
<div class="repeat-row-title">第 {{ index + 1 }} 行<a-tag v-if="row.status === 'DELETE_PREPARED'" color="warning">待删除</a-tag></div>
|
<a-tooltip v-if="row.status === 'ACTIVE'" title="删除行">
|
<a-button
|
type="text"
|
danger
|
size="small"
|
:disabled="disabled || activeRows(group) <= group.minRows"
|
:aria-label="`删除 ${group.groupCode} 第 ${index + 1} 行`"
|
@click="startDelete(group.groupCode, row.rowId)">
|
<template #icon><DeleteOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
<a-tooltip v-else-if="row.status === 'DELETE_PREPARED'" title="恢复行">
|
<a-button
|
type="text"
|
size="small"
|
:disabled="disabled"
|
:aria-label="`恢复 ${group.groupCode} 第 ${index + 1} 行`"
|
@click="emit('restore', group.groupCode, row.rowId)">
|
<template #icon><UndoOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
</div>
|
<a-alert
|
v-if="row.status === 'DELETE_PREPARED' && row.deleteReason"
|
type="warning"
|
:message="row.deleteReason"
|
class="repeat-delete-state" />
|
<FieldDraftList
|
v-if="row.fields.length"
|
compact
|
:conflicts="conflicts"
|
:disabled="disabled"
|
:errors="errors"
|
:fields="row.fields"
|
:focused-tag="focusedTag"
|
@locate="emit('locate', $event)"
|
@resolve="resolveField"
|
@update="updateField" />
|
<div v-if="deleteTarget?.groupCode === group.groupCode && deleteTarget.rowId === row.rowId" class="repeat-delete-form">
|
<a-input v-model:value="deleteReason" :maxlength="500" placeholder="删除原因" />
|
<a-space>
|
<a-button size="small" @click="cancelDelete">取消</a-button>
|
<a-button class="delete-confirm" danger size="small" :disabled="!deleteReason.trim()" @click="confirmDelete">确认删除</a-button>
|
</a-space>
|
</div>
|
</div>
|
</section>
|
</template>
|