This commit is contained in:
zlwu committed 2026-08-03 00:48:46 +08:00
1 parent 272c68af4b
commit 40fc54942b
76 files changed
+464684 -14130

No files matched your search

@@ -44,7 +44,12 @@
@click="file.fileUrl ? handleAttachmentClick(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="resolveFileUrl(file.fileUrl)" :alt="file.fileName" />
<img
:src="resolveFileUrl(file.fileUrl)"
:alt="file.fileName"
loading="lazy"
decoding="async"
/>
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="previewIconSize" color="#94a3b8"><Loading /></el-icon>
@@ -6,12 +6,29 @@
class="pl-carrier-list-attachment-cell__item"
:title="item.fullLabel"
>
<AttachmentUploadField
v-if="item.url"
:model-value="item.url"
disabled
:category-labels="item.categoryLabels"
/>
<div v-if="item.url" class="pl-carrier-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-carrier-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
>
<template #error>
<div class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-carrier-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-carrier-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
<span class="pl-carrier-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-carrier-list-attachment-cell__empty">
<span class="pl-carrier-list-attachment-cell__empty-text">未上传</span>
<span class="pl-carrier-list-attachment-cell__caption">{{ item.label }}</span>
@@ -21,152 +38,194 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Carrier } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import type { Carrier } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
const props = defineProps<{
carrier?: Pick<
Carrier,
'businessLicenseUrls' | 'roadTransportLicenseUrls' | 'transportAgreementUrls'
> | null
}>()
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
categoryLabels: Record<string, string>
}
function parseUrls(raw?: string | null): string[] {
const normalized = normalizeAttachmentUrls(raw)
if (!normalized) return []
return normalized
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
const items = computed<ItemDef[]>(() => {
const defs = [
const props = withDefaults(
defineProps<{
carrier?: Pick<
Carrier,
'businessLicenseUrls' | 'roadTransportLicenseUrls' | 'transportAgreementUrls'
> | null
/** KeepAlive 离开页时置 false,暂停缩略图请求 */
mediaActive?: boolean
}>(),
{
key: 'businessLicense',
label: '营业执照',
fullLabel: '营业执照',
raw: props.carrier?.businessLicenseUrls
},
{
key: 'roadTransportLicense',
label: '道路许可',
fullLabel: '道路经营许可证',
raw: props.carrier?.roadTransportLicenseUrls
},
{
key: 'transportAgreement',
label: '运输协议',
fullLabel: '运输协议',
raw: props.carrier?.transportAgreementUrls
carrier: null,
mediaActive: true
}
]
)
const result: ItemDef[] = []
for (const def of defs) {
const urls = parseUrls(def.raw)
if (!urls.length) {
result.push({
key: def.key,
label: def.label,
fullLabel: def.fullLabel,
url: '',
categoryLabels: {}
})
continue
}
urls.forEach((url, index) => {
result.push({
key: `${def.key}-${index}`,
label: def.label,
fullLabel: def.fullLabel,
url,
categoryLabels: { [url]: def.label }
})
})
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
isImage: boolean
ext: string
}
return result
})
function parseUrls(raw?: string | null): string[] {
const normalized = normalizeAttachmentUrls(raw)
if (!normalized) return []
return normalized
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function isImageUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
.split('?')[0]
.split('/')
.pop() || ''
const lastDot = name.lastIndexOf('.')
if (lastDot === -1) return ''
return name.slice(lastDot + 1).toUpperCase()
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
const items = computed<ItemDef[]>(() => {
const defs = [
{
key: 'businessLicense',
label: '营业执照',
fullLabel: '营业执照',
raw: props.carrier?.businessLicenseUrls
},
{
key: 'roadTransportLicense',
label: '道路许可',
fullLabel: '道路经营许可证',
raw: props.carrier?.roadTransportLicenseUrls
},
{
key: 'transportAgreement',
label: '运输协议',
fullLabel: '运输协议',
raw: props.carrier?.transportAgreementUrls
}
]
const result: ItemDef[] = []
for (const def of defs) {
const urls = parseUrls(def.raw)
if (!urls.length) {
result.push({
key: def.key,
label: def.label,
fullLabel: def.fullLabel,
url: '',
isImage: false,
ext: ''
})
continue
}
urls.forEach((url, index) => {
result.push({
key: `${def.key}-${index}`,
label: def.label,
fullLabel: def.fullLabel,
url,
isImage: isImageUrl(url),
ext: getFileExt(url)
})
})
}
return result
})
</script>
<style scoped lang="scss">
.pl-carrier-list-attachment-cell {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-carrier-list-attachment-cell {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-carrier-list-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-carrier-list-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-carrier-list-attachment-cell__empty {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-carrier-list-attachment-cell__thumb,
.pl-carrier-list-attachment-cell__empty {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
}
.pl-carrier-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
line-height: 1.3;
}
.pl-carrier-list-attachment-cell__empty {
border-style: dashed;
color: var(--mint-text);
font-size: 12px;
}
.pl-carrier-list-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-carrier-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
}
:deep(.attachment-upload) {
width: 100%;
}
.pl-carrier-list-attachment-cell__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 100%;
height: 100%;
margin-bottom: 14px;
}
:deep(.attachment-list) {
flex-wrap: nowrap;
}
.pl-carrier-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
}
:deep(.attachment-item) {
width: 96px;
margin-bottom: 0;
}
.pl-carrier-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
line-height: 1.3;
}
:deep(.attachment-preview) {
height: 56px;
}
:deep(.attachment-footer) {
display: none;
}
.pl-carrier-list-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
</style>
@@ -6,12 +6,29 @@
class="pl-cert-list-attachment-cell__item"
:title="item.fullLabel"
>
<AttachmentUploadField
v-if="item.url"
:model-value="item.url"
disabled
:category-labels="item.categoryLabels"
/>
<div v-if="item.url" class="pl-cert-list-attachment-cell__thumb">
<el-image
v-if="item.isImage"
class="pl-cert-list-attachment-cell__image"
fit="cover"
lazy
:src="displaySrc(item.url)"
:preview-src-list="mediaActive ? [resolvePhotoDisplayUrl(item.url)] : []"
preview-teleported
hide-on-click-modal
>
<template #error>
<div class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder">
<el-icon :size="20" color="#94a3b8"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
</div>
<span class="pl-cert-list-attachment-cell__caption">{{ item.label }}</span>
</div>
<div v-else class="pl-cert-list-attachment-cell__empty">
<span class="pl-cert-list-attachment-cell__empty-text">未上传</span>
<span class="pl-cert-list-attachment-cell__caption">{{ item.label }}</span>
@@ -22,162 +39,200 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CertRead } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
import { computed } from 'vue'
import { Document } from '@element-plus/icons-vue'
import type { CertRead } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { PL_VEHICLE_CERT_PART_TRAILER, normalizeVehicleCertPart } from '@/utils/pl-vehicle-cert'
const props = withDefaults(
defineProps<{
certList?: CertRead[] | null
/** 车辆证书:按主车/挂车加前缀 */
showVehiclePart?: boolean
}>(),
{
certList: () => [],
showVehiclePart: false
}
)
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
categoryLabels: Record<string, string>
}
function parseUrls(raw?: string | null): string[] {
const normalized = normalizeAttachmentUrls(raw)
if (!normalized) return []
return normalized
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function partPrefix(cert: CertRead, enablePart: boolean): string {
if (!enablePart) return ''
return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER ? '挂·' : '主·'
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
const enablePart =
props.showVehiclePart &&
certs.some((cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER)
const result: ItemDef[] = []
certs.forEach((cert, certIndex) => {
const certName = String(cert.certName || '').trim() || '证书'
const prefix = partPrefix(cert, enablePart)
const fullLabel = `${prefix}${certName}`
const label = fullLabel.length > 6 ? `${fullLabel.slice(0, 5)}…` : fullLabel
const urls = parseUrls(cert.attachmentUrls)
if (!urls.length) {
result.push({
key: `${cert.id || cert.certId || certIndex}-empty`,
label,
fullLabel,
url: '',
categoryLabels: {}
})
return
const props = withDefaults(
defineProps<{
certList?: CertRead[] | null
/** 车辆证书:按主车/挂车加前缀 */
showVehiclePart?: boolean
/** KeepAlive 离开页时置 false,暂停缩略图请求 */
mediaActive?: boolean
}>(),
{
certList: () => [],
showVehiclePart: false,
mediaActive: true
}
)
urls.forEach((url, urlIndex) => {
result.push({
key: `${cert.id || cert.certId || certIndex}-${urlIndex}`,
label,
fullLabel,
url,
categoryLabels: { [url]: label }
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
isImage: boolean
ext: string
}
function parseUrls(raw?: string | null): string[] {
const normalized = normalizeAttachmentUrls(raw)
if (!normalized) return []
return normalized
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function partPrefix(cert: CertRead, enablePart: boolean): string {
if (!enablePart) return ''
return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
? '挂·'
: '主·'
}
function isImageUrl(url: string) {
const path = String(url || '')
.split('?')[0]
.toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(path)
}
function getFileExt(url: string) {
const name =
String(url || '')
.split('?')[0]
.split('/')
.pop() || ''
const lastDot = name.lastIndexOf('.')
if (lastDot === -1) return ''
return name.slice(lastDot + 1).toUpperCase()
}
function displaySrc(url: string) {
if (!props.mediaActive) return ''
return resolvePhotoDisplayUrl(url)
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
const enablePart =
props.showVehiclePart &&
certs.some(
(cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
)
const result: ItemDef[] = []
certs.forEach((cert, certIndex) => {
const certName = String(cert.certName || '').trim() || '证书'
const prefix = partPrefix(cert, enablePart)
const fullLabel = `${prefix}${certName}`
const label = fullLabel.length > 6 ? `${fullLabel.slice(0, 5)}…` : fullLabel
const urls = parseUrls(cert.attachmentUrls)
if (!urls.length) {
result.push({
key: `${cert.id || cert.certId || certIndex}-empty`,
label,
fullLabel,
url: '',
isImage: false,
ext: ''
})
return
}
urls.forEach((url, urlIndex) => {
result.push({
key: `${cert.id || cert.certId || certIndex}-${urlIndex}`,
label,
fullLabel,
url,
isImage: isImageUrl(url),
ext: getFileExt(url)
})
})
})
return result
})
return result
})
</script>
<style scoped lang="scss">
.pl-cert-list-attachment-cell {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-cert-list-attachment-cell {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-cert-list-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-cert-list-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-cert-list-attachment-cell__empty {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-cert-list-attachment-cell__thumb,
.pl-cert-list-attachment-cell__empty {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
line-height: 1.3;
}
.pl-cert-list-attachment-cell__empty {
border-style: dashed;
color: var(--mint-text);
font-size: 12px;
}
.pl-cert-list-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-cert-list-attachment-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-cert-list-attachment-cell__none {
color: var(--mint-text);
}
.pl-cert-list-attachment-cell__placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
width: 100%;
height: 100%;
margin-bottom: 14px;
}
:deep(.attachment-upload) {
width: 100%;
}
.pl-cert-list-attachment-cell__ext {
font-size: 10px;
line-height: 1.2;
color: #64748b;
}
:deep(.attachment-list) {
flex-wrap: nowrap;
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
line-height: 1.3;
}
:deep(.attachment-item) {
width: 96px;
margin-bottom: 0;
}
.pl-cert-list-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
:deep(.attachment-preview) {
height: 56px;
}
:deep(.attachment-footer) {
display: none;
}
.pl-cert-list-attachment-cell__none {
color: var(--mint-text);
}
</style>
@@ -61,12 +61,6 @@
{{ row.model || row.productModel || '—' }}
</template>
</el-table-column>
<el-table-column prop="categoryName" label="产品分类" min-width="110" show-overflow-tooltip />
<el-table-column label="税率(%)" min-width="90" align="center">
<template #default="{ row }">
{{ row.taxRate != null ? `${row.taxRate}%` : '—' }}
</template>
</el-table-column>
<el-table-column label="单位" min-width="80" align="center">
<template #default="{ row }">
<DictTag dict-type="crm_product_unit" :value="row.unit" />
@@ -51,7 +51,7 @@
<section class="pl-plan-form-section">
<h3 class="pl-plan-form-section-title">基础信息</h3>
<el-form ref="basicFormRef" :model="formData" :rules="basicRules" label-position="right" label-width="100px" class="pl-dialog-form">
<div class="form-grid-4">
<div class="form-grid-5">
<el-form-item label="创建日期" prop="createDate">
<el-date-picker
v-model="formData.createDate"
@@ -28,8 +28,14 @@
class="pl-plan-list-cert-cell__cert-text"
>
{{ slot.label }}(<span
:class="{ 'pl-plan-list-cert-cell__cert-status--missing': !isProductCertSlotFilled(product, slot.key) }"
>{{ isProductCertSlotFilled(product, slot.key) ? '有' : '无' }}</span>)
:class="{
'pl-plan-list-cert-cell__cert-status--missing': !isProductCertSlotFilled(
product,
slot.key
)
}"
>{{ isProductCertSlotFilled(product, slot.key) ? '有' : '无' }}</span
>)
</span>
</div>
<div
@@ -56,8 +62,10 @@
<template v-if="getProductCertUrl(product, slot.key)">
<img
v-if="isCertImageUrl(getProductCertUrl(product, slot.key))"
:src="resolveCertFileUrl(getProductCertUrl(product, slot.key))"
:src="displayCertSrc(getProductCertUrl(product, slot.key))"
:alt="slot.label"
loading="lazy"
decoding="async"
/>
<div
v-else
@@ -85,11 +93,40 @@
:key="slot.key"
class="pl-plan-list-cert-cell__cert-item"
>
<AttachmentUploadField
<div
v-if="getProductCertUrl(product, slot.key)"
:model-value="getProductCertUrl(product, slot.key)"
disabled
/>
class="pl-plan-list-cert-cell__thumb pl-plan-list-cert-cell__thumb--filled"
>
<el-image
v-if="isCertImageUrl(getProductCertUrl(product, slot.key))"
class="pl-plan-list-cert-cell__image"
fit="cover"
lazy
:src="displayCertSrc(getProductCertUrl(product, slot.key))"
:preview-src-list="
mediaActive ? [resolveCertFileUrl(getProductCertUrl(product, slot.key))] : []
"
preview-teleported
hide-on-click-modal
>
<template #error>
<div class="pl-plan-list-cert-cell__thumb-icon">
<el-icon :size="20"><Document /></el-icon>
</div>
</template>
</el-image>
<div
v-else
class="pl-plan-list-cert-cell__thumb-icon"
:class="
isCertPdfUrl(getProductCertUrl(product, slot.key))
? 'pl-plan-list-cert-cell__thumb-icon--pdf'
: 'pl-plan-list-cert-cell__thumb-icon--doc'
"
>
<el-icon :size="20"><Document /></el-icon>
</div>
</div>
<span v-else class="pl-plan-list-cert-cell__missing">未上传</span>
<span class="pl-plan-list-cert-cell__cert-label">{{ slot.label }}</span>
</div>
@@ -100,277 +137,276 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Upload } from '@element-plus/icons-vue'
import type { PlanProduct } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import {
getProductCertUrl,
getRequiredCertSlots,
isHazardProduct,
isProductCertSlotFilled
} from '@/composables/usePlToxicProductCertCheck'
import { computed } from 'vue'
import { Document, Upload } from '@element-plus/icons-vue'
import type { PlanProduct } from '@/api/pl'
import {
getProductCertUrl,
getRequiredCertSlots,
isHazardProduct,
isProductCertSlotFilled
} from '@/composables/usePlToxicProductCertCheck'
const props = withDefaults(
defineProps<{
products?: PlanProduct[]
/** 列表紧凑模式:文字展示证书有无,不显示缩略图 */
compact?: boolean
/** 隐藏产品名称行(产品信息由独立列展示时使用) */
hideProduct?: boolean
/** 缩略图模式,点击触发上传 */
clickable?: boolean
/** 禁止点击上传(仍展示缩略图) */
clickableDisabled?: boolean
/** 危化标签短文案:毒 / 爆 */
shortHazardTag?: boolean
}>(),
{
products: () => [],
compact: false,
hideProduct: false,
clickable: false,
clickableDisabled: false,
shortHazardTag: false
const props = withDefaults(
defineProps<{
products?: PlanProduct[]
/** 列表紧凑模式:文字展示证书有无,不显示缩略图 */
compact?: boolean
/** 隐藏产品名称行(产品信息由独立列展示时使用) */
hideProduct?: boolean
/** 缩略图模式,点击触发上传 */
clickable?: boolean
/** 禁止点击上传(仍展示缩略图) */
clickableDisabled?: boolean
/** 危化标签短文案:毒 / 爆 */
shortHazardTag?: boolean
/** KeepAlive 离开页时置 false,暂停缩略图请求 */
mediaActive?: boolean
}>(),
{
products: () => [],
compact: false,
hideProduct: false,
clickable: false,
clickableDisabled: false,
shortHazardTag: false,
mediaActive: true
}
)
const emit = defineEmits<{
click: []
}>()
const useShortHazardTag = computed(() => props.compact || props.shortHazardTag)
const hazardProducts = computed(() =>
(props.products || []).filter((product) => isHazardProduct(product))
)
function resolveCertFileUrl(url: string) {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
if (/^https?:\/\//i.test(trimmed)) return trimmed
const base = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${trimmed.replace(/^\//, '')}` : trimmed
}
)
const emit = defineEmits<{
click: []
}>()
function displayCertSrc(url: string) {
if (!props.mediaActive) return ''
return resolveCertFileUrl(url)
}
const useShortHazardTag = computed(() => props.compact || props.shortHazardTag)
function getCertFileName(url: string) {
return (
String(url || '')
.trim()
.split('/')
.pop() || ''
)
}
const hazardProducts = computed(() => (props.products || []).filter(product => isHazardProduct(product)))
function isCertImageUrl(url: string) {
const name = getCertFileName(url).toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(name)
}
function resolveCertFileUrl(url: string) {
const trimmed = String(url || '').trim()
if (!trimmed) return ''
if (/^https?:\/\//i.test(trimmed)) return trimmed
const base = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
return base ? `${base}/${trimmed.replace(/^\//, '')}` : trimmed
}
function isCertPdfUrl(url: string) {
const name = getCertFileName(url).toLowerCase()
const normalized = String(url || '').toLowerCase()
return /\.pdf(\?|$)/i.test(name) || /\.pdf(\?|$)/i.test(normalized)
}
function getCertFileName(url: string) {
return String(url || '').trim().split('/').pop() || ''
}
function isCertImageUrl(url: string) {
const name = getCertFileName(url).toLowerCase()
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|$)/i.test(name)
}
function isCertPdfUrl(url: string) {
const name = getCertFileName(url).toLowerCase()
const normalized = String(url || '').toLowerCase()
return /\.pdf(\?|$)/i.test(name) || /\.pdf(\?|$)/i.test(normalized)
}
function handleCertClick() {
if (props.clickableDisabled) return
emit('click')
}
function handleCertClick() {
if (props.clickableDisabled) return
emit('click')
}
</script>
<style scoped lang="scss">
.pl-plan-list-cert-cell {
display: flex;
flex-direction: column;
gap: 10px;
}
.pl-plan-list-cert-cell__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.pl-plan-list-cert-cell__product {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 13px;
font-weight: 600;
color: var(--text, #1e293b);
}
.pl-plan-list-cert-cell__certs {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-plan-list-cert-cell__cert-item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-plan-list-cert-cell__cert-label {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.pl-plan-list-cert-cell__missing {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-plan-list-cert-cell__certs--clickable {
.pl-plan-list-cert-cell__cert-item {
cursor: pointer;
&:hover:not(.is-disabled) .pl-plan-list-cert-cell__thumb {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.72;
}
.pl-plan-list-cert-cell {
display: flex;
flex-direction: column;
gap: 10px;
}
}
.pl-plan-list-cert-cell__thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pl-plan-list-cert-cell__thumb--filled {
align-items: stretch;
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-plan-list-cert-cell__thumb--empty {
flex-direction: column;
gap: 2px;
border-style: dashed;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 11px;
}
.pl-plan-list-cert-cell__upload-hint {
max-width: 88px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-plan-list-cert-cell__thumb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #64748b;
}
.pl-plan-list-cert-cell__thumb-icon--pdf {
color: #dc2626;
}
.pl-plan-list-cert-cell__thumb-icon--doc {
color: #2563eb;
}
.pl-plan-list-cert-cell__empty {
color: var(--mint-text);
}
:deep(.attachment-upload) {
width: 100%;
}
:deep(.attachment-list) {
flex-wrap: nowrap;
}
:deep(.attachment-item) {
width: 96px;
margin-bottom: 0;
}
:deep(.attachment-preview) {
height: 56px;
}
:deep(.attachment-footer) {
display: none;
}
.pl-plan-list-cert-cell--compact {
gap: 6px;
.pl-plan-list-cert-cell__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.pl-plan-list-cert-cell__product {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 12px;
font-size: 13px;
font-weight: 600;
color: var(--text, #1e293b);
}
.pl-plan-list-cert-cell__certs--text {
flex-direction: row;
.pl-plan-list-cert-cell__certs {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.pl-plan-list-cert-cell__cert-text {
.pl-plan-list-cert-cell__cert-item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-plan-list-cert-cell__cert-label {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
text-align: center;
}
.pl-plan-list-cert-cell__cert-status--missing {
color: var(--danger, #dc2626);
font-weight: 500;
.pl-plan-list-cert-cell__missing {
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-plan-list-cert-cell__certs--clickable {
.pl-plan-list-cert-cell__cert-item {
cursor: pointer;
&:hover:not(.is-disabled) .pl-plan-list-cert-cell__thumb {
border-color: var(--primary, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.12);
}
&.is-disabled {
cursor: not-allowed;
opacity: 0.72;
}
}
}
.pl-plan-list-cert-cell__thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px solid var(--border, #d9e8e2);
border-radius: 8px;
background: #fff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.pl-plan-list-cert-cell__thumb--filled {
align-items: stretch;
}
.pl-plan-list-cert-cell__cert-caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-plan-list-cert-cell__thumb--empty {
flex-direction: column;
gap: 2px;
border-style: dashed;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 11px;
}
.pl-plan-list-cert-cell__upload-hint {
max-width: 88px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-plan-list-cert-cell__thumb-icon {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #64748b;
}
.pl-plan-list-cert-cell__thumb-icon--pdf {
color: #dc2626;
}
.pl-plan-list-cert-cell__thumb-icon--doc {
color: #2563eb;
}
.pl-plan-list-cert-cell__empty {
color: var(--mint-text);
}
.pl-plan-list-cert-cell__image {
width: 100%;
height: 100%;
cursor: pointer;
}
.pl-plan-list-cert-cell--compact {
gap: 6px;
.pl-plan-list-cert-cell__group {
gap: 4px;
}
.pl-plan-list-cert-cell__name {
font-size: 12px;
}
.pl-plan-list-cert-cell__certs--text {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.pl-plan-list-cert-cell__cert-text {
color: var(--mint-text);
font-size: 12px;
line-height: 1.4;
white-space: nowrap;
}
.pl-plan-list-cert-cell__cert-status--missing {
color: var(--danger, #dc2626);
font-weight: 500;
}
}
}
</style>
@@ -181,6 +181,9 @@ function getHazardFlags(product: PlanProduct) {
.pl-plan-list-product-cell__text {
min-width: 0;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
}
.pl-plan-list-product-cell__mark {
@@ -128,6 +128,12 @@
</script>
<style scoped lang="scss">
/* 避免 Element Plus 默认灰底(--el-text-color-disabled)透过 SVG 镂空形成「蒙层」 */
:deep(.el-avatar) {
--el-avatar-bg-color: #e2e8f0;
background: #e2e8f0;
}
.user-avatar-wrapper {
display: inline-flex;
flex-shrink: 0;