c402d68aea
主图角标按分组下标标在第一张上,既没有独立字段也不能切换,与司机端一致删除;v-for 不再需要下标。
292 lines
7.6 KiB
Vue
292 lines
7.6 KiB
Vue
<template>
|
|
<div v-if="groups.length" class="punch-record-photos">
|
|
<section v-for="group in groups" :key="group.type" class="punch-record-photos__group">
|
|
<div class="punch-record-photos__head">
|
|
<span class="punch-record-photos__title">{{ group.type }}</span>
|
|
<span v-if="editable" class="punch-record-photos__count">{{ countText(group) }}</span>
|
|
</div>
|
|
<div
|
|
class="punch-record-photos__grid"
|
|
:class="{ 'punch-record-photos__grid--edit': editable }"
|
|
>
|
|
<div
|
|
v-for="item in group.items"
|
|
:key="`${item.photo.url}-${item.index}`"
|
|
class="punch-record-photos__item"
|
|
@click="handlePreview(item.index)"
|
|
>
|
|
<el-image
|
|
:src="item.photo.url"
|
|
:preview-src-list="nativePreview ? previewUrls : []"
|
|
:initial-index="nativePreview ? item.index : 0"
|
|
:preview-teleported="nativePreview"
|
|
:lazy="lazy"
|
|
fit="cover"
|
|
/>
|
|
<el-link
|
|
v-if="editable"
|
|
class="punch-record-photos__remove"
|
|
type="danger"
|
|
:underline="false"
|
|
@click.stop="$emit('remove', item.index)"
|
|
>删除</el-link
|
|
>
|
|
</div>
|
|
<el-upload
|
|
v-if="editable && !group.full"
|
|
class="punch-record-photos__upload"
|
|
:action="uploadAction"
|
|
name="file"
|
|
:headers="uploadHeaders"
|
|
:show-file-list="false"
|
|
:before-upload="file => handleBeforeUpload(file, group.type)"
|
|
:on-success="res => $emit('upload-success', res, group.type)"
|
|
:on-error="error => $emit('upload-error', error, group.type)"
|
|
>
|
|
<div class="punch-record-photos__add">+ 添加</div>
|
|
</el-upload>
|
|
<div v-else-if="editable" class="punch-record-photos__add punch-record-photos__add--full">
|
|
已满 {{ maxPerType }} 张
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed } from 'vue';
|
|
|
|
// 打卡照片是每个打卡节点必有的一个照片类型,不由过程配置的凭证类型决定;
|
|
// 它与各凭证类型各自最多 9 张。
|
|
const PUNCH_PHOTO_TYPE = '打卡照片';
|
|
// 照片没带类型时的兜底名称,与后端落库时的兜底口径一致
|
|
const PHOTO_TYPE_FALLBACK = '凭证';
|
|
const MAX_PHOTOS_PER_TYPE = 9;
|
|
|
|
const props = defineProps({
|
|
// 扁平照片数组:管理端回显用 voucherType,司机端回显与旧数据用 type
|
|
photos: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
// 恒定展示的照片类型:「打卡照片」+ 该节点过程配置勾选的凭证类型。
|
|
// 留空则只按照片自身带的类型分组(只读回显用)。
|
|
groupTypes: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
// 编辑态:每个类型自带「添加」格与删除链接,并显示已选/上限
|
|
editable: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
// 每个类型的张数上限;0 表示不限制(在途打卡只有一个照片字段,张数由「只保留第一张」约束)
|
|
maxPerType: {
|
|
type: Number,
|
|
default: MAX_PHOTOS_PER_TYPE,
|
|
},
|
|
// 编辑态用 el-image 自带预览;只读态不发预览事件,把点击交给调用方开自己的看图器
|
|
nativePreview: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
lazy: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
uploadAction: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
uploadHeaders: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
// 返回 false 即中止本次上传;类型、体积、张数校验都在调用方
|
|
beforeUpload: {
|
|
type: Function,
|
|
default: null,
|
|
},
|
|
});
|
|
|
|
const emit = defineEmits(['preview', 'remove', 'upload-success', 'upload-error']);
|
|
|
|
const photoType = photo =>
|
|
String(photo?.voucherType || photo?.type || '').trim() || PHOTO_TYPE_FALLBACK;
|
|
|
|
const groups = computed(() => {
|
|
const photos = Array.isArray(props.photos) ? props.photos : [];
|
|
const names = [];
|
|
const buckets = new Map();
|
|
const ensure = name => {
|
|
if (!buckets.has(name)) {
|
|
buckets.set(name, []);
|
|
names.push(name);
|
|
}
|
|
return buckets.get(name);
|
|
};
|
|
// 恒定展示的类型先占位:一张照片都没有的类型也要出现,否则编辑态下没有添加入口
|
|
props.groupTypes.forEach(type => {
|
|
const name = String(type || '').trim();
|
|
if (name) ensure(name);
|
|
});
|
|
// 按上传顺序入组,组间顺序即类型首次出现的顺序,不重排照片
|
|
photos.forEach((photo, index) => {
|
|
if (!photo?.url) return;
|
|
ensure(photoType(photo)).push({ photo, index });
|
|
});
|
|
const list = names.map(type => {
|
|
const items = buckets.get(type);
|
|
return {
|
|
type,
|
|
items,
|
|
full: props.editable && props.maxPerType > 0 && items.length >= props.maxPerType,
|
|
};
|
|
});
|
|
// 编辑态至少要有一个分组,否则记录里还没有照片时就没有添加入口
|
|
if (props.editable && !list.length) {
|
|
return [{ type: props.groupTypes[0] || PUNCH_PHOTO_TYPE, items: [], full: false }];
|
|
}
|
|
return list;
|
|
});
|
|
|
|
// 预览列表按原始下标对齐,删除/分组都不改变点击某张时打开的是哪一张
|
|
const previewUrls = computed(() =>
|
|
(Array.isArray(props.photos) ? props.photos : []).map(photo => photo?.url || '')
|
|
);
|
|
|
|
const handlePreview = index => {
|
|
if (props.nativePreview) return;
|
|
emit('preview', index);
|
|
};
|
|
|
|
const handleBeforeUpload = (file, type) => {
|
|
if (typeof props.beforeUpload !== 'function') return true;
|
|
return props.beforeUpload(file, type);
|
|
};
|
|
|
|
const countText = group =>
|
|
props.maxPerType > 0 ? `${group.items.length}/${props.maxPerType}` : String(group.items.length);
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.punch-record-photos {
|
|
// 弹窗里本组件挂在 el-form-item__content 下,那是个 flex 容器:
|
|
// 不给宽度就会收缩成内容宽,里面的网格跟着塌成一小块
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
margin-top: 4px;
|
|
|
|
&__group {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
&__head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
line-height: 20px;
|
|
}
|
|
|
|
&__title {
|
|
font-weight: 600;
|
|
color: #303133;
|
|
word-break: break-all;
|
|
}
|
|
|
|
&__count {
|
|
font-size: 12px;
|
|
color: #909399;
|
|
}
|
|
|
|
// 只读回显:撑满容器宽度的五列网格
|
|
&__grid {
|
|
width: 100%;
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
|
|
// 编辑态沿用弹窗原本的固定 96px 方图 + 自动换行:尺寸不随容器宽窄变化
|
|
&__grid--edit {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
&__item {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 4px;
|
|
cursor: pointer;
|
|
|
|
.el-image {
|
|
display: block;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border: 1px solid #eff1f7;
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
background: #f5f7fa;
|
|
}
|
|
}
|
|
|
|
&__grid--edit &__item {
|
|
width: 96px;
|
|
|
|
.el-image {
|
|
width: 96px;
|
|
height: 96px;
|
|
aspect-ratio: auto;
|
|
}
|
|
}
|
|
|
|
&__remove {
|
|
align-self: center;
|
|
font-size: 12px;
|
|
}
|
|
|
|
&__upload {
|
|
width: 100%;
|
|
}
|
|
|
|
&__grid--edit &__upload {
|
|
width: 96px;
|
|
}
|
|
|
|
&__add {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
box-sizing: border-box;
|
|
width: 100%;
|
|
aspect-ratio: 1;
|
|
border: 1px dashed #dcdfe6;
|
|
border-radius: 4px;
|
|
font-size: 13px;
|
|
color: #909399;
|
|
cursor: pointer;
|
|
}
|
|
|
|
&__grid--edit &__add {
|
|
width: 96px;
|
|
height: 96px;
|
|
aspect-ratio: auto;
|
|
}
|
|
|
|
&__add--full {
|
|
color: #c0c4cc;
|
|
cursor: not-allowed;
|
|
}
|
|
}
|
|
</style>
|