This commit is contained in:
zlwu committed 2026-06-14 17:20:52 +08:00
1 parent a79aee2614
commit 427bcb1551
274 files changed
+59416 -38342

No files matched your search

@@ -55,10 +55,6 @@
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增样本编码
</el-button>
<el-button class="btn-ghost" @click="openBatchGenerate">
<el-icon><DocumentAdd /></el-icon>
批量生成
@@ -71,6 +67,10 @@
<el-icon><Promotion /></el-icon>
批量下发
</el-button>
<el-button class="btn-ghost" @click="handleAdd">
<el-icon><Plus /></el-icon>
样本手工编码
</el-button>
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
@@ -7,220 +7,256 @@
<h2>细胞样本基础信息维护</h2>
</div>
<el-tabs v-model="activeTab" class="panel-tabs">
<el-tabs v-model="activeTab" class="panel-pill-tabs">
<el-tab-pane label="细胞样本类型" name="type">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-form-item>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="类型编码/名称" clearable />
</el-form-item>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按编码、名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
</div>
</el-form>
</div>
<div class="filter-actions-row">
<span class="filter-hint">按编码、名称模糊查询。</span>
<div class="filter-actions-end">
<el-button class="btn-ghost" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" @click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增类型
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
</el-form>
</div>
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-button class="btn-main" type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新增类型
</el-button>
<el-button
class="btn-ghost"
type="danger"
:disabled="selectedRows.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</div>
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
prop="typeCode"
label="类型编码"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column
prop="typeName"
label="类型名称"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="storageYears"
label="存储年限(年)"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="storagePrice"
label="存储价格(元)"
min-width="120"
align="center"
header-align="center"
>
<template #default="{ row }">
{{ row.storagePrice != null ? row.storagePrice : '—' }}
</template>
</el-table-column>
<el-table-column
prop="createTime"
label="创建时间"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
label="操作"
min-width="120"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item" @click="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
<div class="list-body list-wrap">
<div v-loading="loading" class="sample-type-card-grid">
<div
v-for="row in tableData"
:key="row.id"
class="sample-type-card"
:class="{ 'is-selected': isRowSelected(row.id) }"
@dblclick="handleView(row)"
>
<div class="sample-type-card__select" @click.stop>
<el-checkbox
:model-value="isRowSelected(row.id)"
@change="(val) => toggleRowSelection(row, !!val)"
/>
</div>
<div class="sample-type-card__media">
<el-image
v-if="row.typeLogo"
:src="typeLogoUrl(row.typeLogo)"
fit="cover"
class="sample-type-card__logo"
/>
<div v-else class="sample-type-card__logo-placeholder">
<el-icon><Collection /></el-icon>
<span>暂无Logo</span>
</div>
</div>
<div class="sample-type-card__body">
<div class="sample-type-card__title" :title="row.typeName">{{
row.typeName
}}</div>
<div class="sample-type-card__code">{{ row.typeCode }}</div>
<div class="sample-type-card__meta">
<span v-if="row.storageYears != null">存储 {{ row.storageYears }} 年</span>
<span v-if="row.storagePrice != null"> · ¥{{ row.storagePrice }}</span>
</div>
</div>
<div class="sample-type-card__footer">
<el-button class="btn-op-item" @click.stop="handleView(row)">
<el-icon><View /></el-icon>查看
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="(val) => ((pagination.page = 1), (pagination.pageSize = val), loadData())"
@current-change="(val) => ((pagination.page = val), loadData())"
/>
</div>
</div>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="typeCode">
<el-input v-model="formData.typeCode" placeholder="租户内唯一" :disabled="isEdit" />
</el-form-item>
<el-form-item label="类型名称" prop="typeName">
<el-input v-model="formData.typeName" placeholder="必填" />
</el-form-item>
<el-form-item label="存储年限(年)">
<el-input
type="number"
step="any"
v-model="formData.storageYears"
:min="0"
:max="200"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="存储价格(元)">
<el-input
type="number"
step="any"
v-model="formData.storagePrice"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="可选" />
</el-form-item>
</div>
<el-empty
v-if="!loading && !tableData.length"
description="暂无细胞样本类型"
:image-size="80"
/>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.typeName || '类型详情'"
:icon="Collection"
:code="viewRow?.typeCode"
code-label="类型编码"
:sections="viewSections"
/>
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="640px"
class="dialog-form-layout"
align-center
destroy-on-close
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-position="top"
class="biz-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item label="类型编码" prop="typeCode">
<el-input
v-model="formData.typeCode"
placeholder="租户内唯一"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="类型名称" prop="typeName">
<el-input v-model="formData.typeName" placeholder="必填" />
</el-form-item>
<el-form-item label="类型Logo" style="grid-column: 1 / -1">
<div class="sample-type-logo-row">
<el-image
v-if="formData.typeLogo"
:src="typeLogoUrl(formData.typeLogo)"
fit="cover"
class="sample-type-logo-preview"
/>
<div v-else class="sample-type-logo-placeholder">暂无</div>
<el-upload
ref="typeLogoUploadRef"
accept="image/*"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:on-change="handleTypeLogoChange"
:on-exceed="handleTypeLogoExceed"
>
<el-button type="primary" plain>
<el-icon><Upload /></el-icon>上传Logo
</el-button>
</el-upload>
<el-button
v-if="formData.typeLogo"
class="btn-ghost"
@click="formData.typeLogo = ''"
>
<el-icon><Delete /></el-icon>移除
</el-button>
</div>
</el-form-item>
<el-form-item label="存储年限(年)">
<el-input
type="number"
step="any"
v-model="formData.storageYears"
:min="0"
:max="200"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="存储价格(元)">
<el-input
type="number"
step="any"
v-model="formData.storagePrice"
:min="0"
style="width: 100%"
/>
</el-form-item>
<el-form-item prop="orderNum">
<template #label>排序<span class="form-label-tip">数值越小越靠前</span></template>
<el-input-number
v-model="formData.orderNum"
:min="0"
:max="9999"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="备注" class="form-col-span-2">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="可选"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
<el-button class="btn-ghost" @click="dialogVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button
class="btn-main"
type="primary"
@click="handleSubmit"
:loading="submitLoading"
>
<el-icon><Check /></el-icon>
确定
</el-button>
</template>
</el-dialog>
<SystemViewDialog
v-model:visible="viewVisible"
:title="viewRow?.typeName || '类型详情'"
:icon="Collection"
:code="viewRow?.typeCode"
code-label="类型编码"
:sections="viewSections"
>
<template v-if="viewRow?.typeLogo" #type-logo>
<dl class="sample-type-view-kv-grid">
<div class="sample-type-view-kv">
<dt>Logo</dt>
<dd>
<el-image
:src="typeLogoUrl(viewRow.typeLogo)"
fit="cover"
class="sample-type-view-logo"
:preview-src-list="[typeLogoUrl(viewRow.typeLogo)]"
/>
</dd>
</div>
</dl>
</template>
</SystemViewDialog>
</el-tab-pane>
<el-tab-pane label="样本编码" name="sample-code" lazy>
@@ -244,8 +280,10 @@
Search,
More,
Edit,
Delete
Delete,
Upload
} from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import {
fetchCellSampleTypeList,
@@ -253,6 +291,7 @@
fetchUpdateCellSampleType,
fetchDeleteCellSampleType
} from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import SampleCodeTab from './SampleCodeTab.vue'
@@ -260,11 +299,48 @@
const route = useRoute()
const activeTab = ref('type')
const typeLogoUploadRef = ref()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function typeLogoUrl(url: string) {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url
return `${minioBaseUrl}/${url}`
}
async function uploadTypeLogoFile(file: File) {
try {
const fd = new FormData()
fd.append('folder', 'cell-sample-type')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
formData.typeLogo = res.fileUrl || ''
ElMessage.success('Logo上传成功')
} catch (e) {
console.error('样本类型Logo上传失败:', e)
ElMessage.error('Logo上传失败')
} finally {
typeLogoUploadRef.value?.clearFiles()
}
}
const handleTypeLogoChange = async (uploadFile: UploadFile) => {
const file: any = (uploadFile as any).raw
if (!file) return
const status = (uploadFile as any).status
if (status && status !== 'ready') return
await uploadTypeLogoFile(file)
}
const handleTypeLogoExceed = (files: File[]) => {
const file = files[0]
if (file) uploadTypeLogoFile(file)
}
const searchForm = reactive<{ keyword: string }>({ keyword: '' })
const tableData = ref<Api.BizCellSampleType.CellSampleTypeItem[]>([])
const selectedRows = ref<Api.BizCellSampleType.CellSampleTypeItem[]>([])
const loading = ref(false)
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
const dialogVisible = ref(false)
const dialogTitle = ref('新增类型')
const isEdit = ref(false)
@@ -272,7 +348,6 @@
const formRef = ref()
const editingId = ref<string | null>(null)
// ---- 查看弹窗 ----
const viewVisible = ref(false)
const viewRow = ref<Api.BizCellSampleType.CellSampleTypeItem | null>(null)
const viewSections = computed(() => {
@@ -286,12 +361,34 @@
{ label: '类型名称', value: row.typeName },
{ label: '存储年限(年)', value: row.storageYears },
{ label: '存储价格(元)', value: row.storagePrice },
{ label: '排序', value: row.orderNum },
{ label: '创建时间', value: row.createTime },
{ label: '备注', value: row.remark, span: true }
]
}
},
row.typeLogo
? {
title: '类型Logo',
fields: [],
slot: 'type-logo'
}
: {
title: '类型Logo',
fields: [{ label: 'Logo', value: '无' }]
}
]
})
const isRowSelected = (id: string) => selectedRows.value.some((r) => r.id === id)
const toggleRowSelection = (row: Api.BizCellSampleType.CellSampleTypeItem, checked: boolean) => {
if (checked) {
if (!isRowSelected(row.id)) selectedRows.value = [...selectedRows.value, row]
return
}
selectedRows.value = selectedRows.value.filter((r) => r.id !== row.id)
}
function handleView(row: Api.BizCellSampleType.CellSampleTypeItem) {
viewRow.value = row
viewVisible.value = true
@@ -300,6 +397,7 @@
const formData = reactive({
typeCode: '',
typeName: '',
typeLogo: '',
storageYears: null as number | null,
storagePrice: null as number | null,
orderNum: 0,
@@ -311,39 +409,35 @@
typeName: [{ required: true, message: '请输入类型名称', trigger: 'blur' }]
}
const handleSelectionChange = (rows: Api.BizCellSampleType.CellSampleTypeItem[]) => {
selectedRows.value = rows
}
async function loadData() {
loading.value = true
try {
const res = await fetchCellSampleTypeList({
page: pagination.page,
pageSize: pagination.pageSize,
page: 1,
pageSize: 100,
keyword: searchForm.keyword || undefined
})
tableData.value = res.list || []
pagination.total = res.total ?? 0
const validIds = new Set(tableData.value.map((r) => r.id))
selectedRows.value = selectedRows.value.filter((r) => validIds.has(r.id))
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.keyword = ''
pagination.page = 1
loadData()
}
function resetForm() {
formData.typeCode = ''
formData.typeName = ''
formData.typeLogo = ''
formData.storageYears = null
formData.storagePrice = null
formData.orderNum = 0
@@ -365,6 +459,7 @@
editingId.value = row.id
formData.typeCode = row.typeCode || ''
formData.typeName = row.typeName || ''
formData.typeLogo = row.typeLogo || ''
formData.storageYears = row.storageYears ?? null
formData.storagePrice = row.storagePrice ?? null
formData.orderNum = row.orderNum ?? 0
@@ -380,6 +475,7 @@
const payload = {
typeCode: formData.typeCode.trim(),
typeName: formData.typeName.trim(),
typeLogo: formData.typeLogo?.trim() ?? '',
storageYears: formData.storageYears,
storagePrice: formData.storagePrice,
orderNum: formData.orderNum,
@@ -388,6 +484,7 @@
if (isEdit.value && editingId.value) {
await fetchUpdateCellSampleType(editingId.value, {
typeName: payload.typeName,
typeLogo: payload.typeLogo,
storageYears: payload.storageYears,
storagePrice: payload.storagePrice,
orderNum: payload.orderNum,
@@ -408,8 +505,12 @@
function handleDropdownCommand(command: string, row: any) {
switch (command) {
case 'edit': handleEdit(row); break
case 'delete': handleDelete(row); break
case 'edit':
handleEdit(row)
break
case 'delete':
handleDelete(row)
break
}
}
@@ -433,11 +534,10 @@
await loadData()
}, fullscreenLoadingText.batchDelete)
}
// 输入检索条件时自动搜索(防抖 300ms)
watchDebounced(
searchForm,
() => {
pagination.page = 1
loadData()
},
{ debounce: 300, deep: true }
@@ -458,4 +558,180 @@
margin-bottom: 0;
}
}
.sample-type-logo-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.sample-type-logo-preview {
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px solid var(--mint-border, #e2e8f0);
}
.sample-type-logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px dashed var(--mint-border, #e2e8f0);
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
background: #f8fafc;
}
.sample-type-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
min-height: 120px;
padding: 16px;
}
.sample-type-card {
position: relative;
display: flex;
flex-direction: column;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 12px;
overflow: hidden;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 12px rgba(4, 120, 87, 0.1);
}
&.is-selected {
border-color: var(--mint-primary, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
}
}
.sample-type-card__select {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
}
.sample-type-card__media {
width: 100%;
height: 160px;
flex-shrink: 0;
background: #f1f5f9;
overflow: hidden;
}
.sample-type-card__logo {
display: block;
width: 100%;
height: 160px;
:deep(.el-image__inner),
:deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.sample-type-card__logo-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 160px;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
background: #f1f5f9;
.el-icon {
font-size: 28px;
}
}
.sample-type-card__body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
padding: 10px 16px 8px;
text-align: center;
}
.sample-type-card__title {
font-size: 15px;
font-weight: 600;
color: var(--mint-text, #0f172a);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sample-type-card__code {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.sample-type-card__meta {
margin-top: 2px;
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
}
.sample-type-card__footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 10px 16px 14px;
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.sample-type-view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
}
.sample-type-view-kv {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
font-size: 13px;
align-items: start;
dt {
margin: 0;
color: #64748b;
font-weight: 500;
}
dd {
margin: 0;
color: #1e293b;
word-break: break-word;
}
}
.sample-type-view-logo {
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px solid #e2e8f0;
display: block;
}
</style>
@@ -7,7 +7,7 @@
<h2>细胞采集基础信息维护</h2>
</div>
<el-tabs v-model="activeTab" class="panel-tabs">
<el-tabs v-model="activeTab" class="panel-pill-tabs">
<el-tab-pane label="采集地区及医院" name="region">
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
@@ -4,7 +4,7 @@
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2>保险公司列表</h2>
<h2>保险公司及险种</h2>
</div>
<div class="filter-block">
@@ -45,99 +45,118 @@
</div>
<div class="list-body list-wrap">
<el-table
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(i) => (pagination.page - 1) * pagination.pageSize + i + 1"
/>
<el-table-column
prop="companyName"
label="保险公司名称"
min-width="160"
align="center"
header-align="center"
/>
<el-table-column
prop="companyAddress"
label="地址"
min-width="180"
align="center"
header-align="center"
/>
<el-table-column
prop="contactName"
label="联系人"
min-width="100"
align="center"
header-align="center"
/>
<el-table-column
prop="contactPhone"
label="联系电话"
min-width="120"
align="center"
header-align="center"
/>
<el-table-column
prop="tag"
label="状态"
min-width="90"
align="center"
header-align="center"
<div v-loading="loading" class="insurance-company-card-grid">
<div
v-for="row in tableData"
:key="row.id"
class="insurance-company-card"
:class="{ 'is-selected': isRowSelected(row.id) }"
@dblclick="handleView(row)"
>
<template #default="{ row }">
<el-tag :type="row.status == '1' ? 'success' : 'info'" size="small">{{
row.status == '1' ? '正常' : '停用'
}}</el-tag>
</template>
</el-table-column>
<el-table-column
label="操作"
min-width="160"
fixed="right"
align="center"
header-align="center"
>
<template #default="{ row }">
<div style="display: flex; justify-content: center; gap: 8px">
<el-button class="btn-op-item primary" @click="openItemDialog(row)">
<el-icon><Setting /></el-icon>险种价格配置
</el-button>
<el-dropdown trigger="hover" @command="(cmd: string) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item">
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<div class="insurance-company-card__select" @click.stop>
<el-checkbox
:model-value="isRowSelected(row.id)"
@change="(val) => toggleRowSelection(row, !!val)"
/>
</div>
<div class="insurance-company-card__header">
<div class="insurance-company-card__media">
<el-image
v-if="row.companyLogo"
:src="companyLogoUrl(row.companyLogo)"
fit="cover"
class="insurance-company-card__logo"
/>
<div v-else class="insurance-company-card__logo-placeholder">
<el-icon><OfficeBuilding /></el-icon>
<span>暂无Logo</span>
</div>
</div>
</template>
</el-table-column>
</el-table>
<div class="insurance-company-card__summary">
<div class="insurance-company-card__title-row">
<div class="insurance-company-card__title" :title="row.companyName">
{{ row.companyName }}
</div>
<el-tag :type="row.status == '1' ? 'success' : 'info'" size="small">
{{ row.status == '1' ? '正常' : '停用' }}
</el-tag>
</div>
<div class="insurance-company-card__address" :title="row.companyAddress || ''">
{{ row.companyAddress || ' ' }}
</div>
<div class="insurance-company-card__contact">
<template v-if="row.contactName || row.contactPhone">
<span v-if="row.contactName">{{ row.contactName }}</span>
<span v-if="row.contactName && row.contactPhone"> · </span>
<span v-if="row.contactPhone">{{ row.contactPhone }}</span>
</template>
<span v-else>&nbsp;</span>
</div>
</div>
</div>
<div class="insurance-company-card__projects">
<div class="insurance-company-card__projects-head">
<span class="insurance-company-card__projects-title">险种</span>
<span class="insurance-company-card__projects-count">
{{ (row.projectPrices || []).length }} 项
</span>
</div>
<div class="insurance-company-card__project-body">
<div
v-for="item in (row.projectPrices || []).slice(0, 1)"
:key="item.id"
class="insurance-company-card__project-item"
>
<span class="insurance-company-card__project-name" :title="item.projectName">
{{ item.projectName }}
</span>
<span class="insurance-company-card__project-fee">
{{
item.projectFee != null ? '¥' + formatMoneyDisplay(item.projectFee) : '—'
}}
</span>
<el-tag :type="item.status == '1' ? 'success' : 'info'" size="small">
{{ item.status == '1' ? '正常' : '停用' }}
</el-tag>
</div>
<div
v-if="!(row.projectPrices || []).length"
class="insurance-company-card__project-empty"
>
暂无险种,请点击下方配置
</div>
</div>
</div>
<div class="insurance-company-card__footer">
<el-button class="btn-op-item primary" @click.stop="openItemDialog(row)">
<el-icon><Setting /></el-icon>险种价格配置
</el-button>
<el-dropdown trigger="hover" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button class="btn-op-item" @click.stop>
<el-icon><More /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view">
<el-icon><View /></el-icon>查看
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>编辑
</el-dropdown-item>
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<el-empty
v-if="!loading && !tableData.length"
description="暂无保险公司数据"
:image-size="80"
/>
<div class="pagination list-pager-bar">
<el-pagination
:current-page="pagination.page"
@@ -172,6 +191,37 @@
<el-form-item label="保险公司名称" prop="companyName">
<el-input v-model="companyForm.companyName" placeholder="请输入保险公司名称" />
</el-form-item>
<el-form-item label="公司Logo" class="form-col-span-2">
<div class="company-logo-row">
<el-image
v-if="companyForm.companyLogo"
:src="companyLogoUrl(companyForm.companyLogo)"
fit="cover"
class="company-logo-preview"
/>
<div v-else class="company-logo-placeholder">暂无</div>
<el-upload
ref="companyLogoUploadRef"
accept="image/*"
:show-file-list="false"
:auto-upload="false"
:limit="1"
:on-change="handleCompanyLogoChange"
:on-exceed="handleCompanyLogoExceed"
>
<el-button type="primary" plain>
<el-icon><Upload /></el-icon>上传Logo
</el-button>
</el-upload>
<el-button
v-if="companyForm.companyLogo"
class="btn-ghost"
@click="companyForm.companyLogo = ''"
>
<el-icon><Delete /></el-icon>移除
</el-button>
</div>
</el-form-item>
<el-form-item label="公司地址">
<el-input v-model="companyForm.companyAddress" placeholder="请输入公司地址" />
</el-form-item>
@@ -402,7 +452,23 @@
:icon="OfficeBuilding"
:meta="viewCompanyName"
:sections="viewSections"
/>
>
<template v-if="viewCompanyLogo" #company-logo>
<dl class="company-view-kv-grid">
<div class="company-view-kv">
<dt>Logo</dt>
<dd>
<el-image
:src="companyLogoUrl(viewCompanyLogo)"
fit="cover"
class="company-view-logo"
:preview-src-list="[companyLogoUrl(viewCompanyLogo)]"
/>
</dd>
</div>
</dl>
</template>
</SystemViewDialog>
</section>
</div>
</template>
@@ -419,8 +485,10 @@
Edit,
Delete,
More,
OfficeBuilding
OfficeBuilding,
Upload
} from '@element-plus/icons-vue'
import type { UploadFile } from 'element-plus'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import type { ViewSection } from '@/components/business/system-view-dialog/index.vue'
import {
@@ -437,12 +505,62 @@
fetchUpdateInsuranceProjectPrice,
fetchDeleteInsuranceProjectPrice
} from '@/api/admin'
import { fetchUploadAttachment } from '@/api/business'
import { withFullscreenLoading, fullscreenLoadingText } from '@/utils/fullscreen-loading'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
defineOptions({ name: 'BasicInfoInsuranceCompany' })
const companyLogoUploadRef = ref()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function companyLogoUrl(url: string) {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url
return `${minioBaseUrl}/${url}`
}
async function uploadCompanyLogoFile(file: File) {
try {
const fd = new FormData()
fd.append('folder', 'insurance-company')
fd.append('file', file)
const res = await fetchUploadAttachment(fd)
companyForm.companyLogo = res.fileUrl || ''
ElMessage.success('Logo上传成功')
} catch (e) {
console.error('保险公司Logo上传失败:', e)
ElMessage.error('Logo上传失败')
} finally {
companyLogoUploadRef.value?.clearFiles()
}
}
const handleCompanyLogoChange = async (uploadFile: UploadFile) => {
const file: any = (uploadFile as any).raw
if (!file) return
const status = (uploadFile as any).status
if (status && status !== 'ready') return
await uploadCompanyLogoFile(file)
}
const handleCompanyLogoExceed = (files: File[]) => {
const file = files[0]
if (file) uploadCompanyLogoFile(file)
}
const isRowSelected = (id: string) => selectedRows.value.some((r) => r.id === id)
const toggleRowSelection = (row: any, checked: boolean) => {
if (checked) {
if (!isRowSelected(row.id)) selectedRows.value = [...selectedRows.value, row]
return
}
selectedRows.value = selectedRows.value.filter((r) => r.id !== row.id)
}
// ---------------------------------------------------------------------------
// 搜索 & 主表
// ---------------------------------------------------------------------------
@@ -463,6 +581,7 @@
const editingId = ref<string | null>(null)
const companyForm = reactive({
companyName: '',
companyLogo: '',
companyAddress: '',
contactName: '',
contactPhone: '',
@@ -512,6 +631,7 @@
// ---------------------------------------------------------------------------
const viewVisible = ref(false)
const viewCompanyName = ref('')
const viewCompanyLogo = ref('')
const viewSections = ref<ViewSection[]>([])
// ---------------------------------------------------------------------------
@@ -528,6 +648,8 @@
})
tableData.value = (res as any)?.list || []
pagination.total = (res as any)?.total ?? 0
const validIds = new Set(tableData.value.map((r) => r.id))
selectedRows.value = selectedRows.value.filter((r) => validIds.has(r.id))
} finally {
loading.value = false
}
@@ -545,15 +667,12 @@
loadData()
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
// ---------------------------------------------------------------------------
// 保险公司弹窗操作
// ---------------------------------------------------------------------------
function resetCompanyForm() {
companyForm.companyName = ''
companyForm.companyLogo = ''
companyForm.companyAddress = ''
companyForm.contactName = ''
companyForm.contactPhone = ''
@@ -578,6 +697,7 @@
companyDialogTitle.value = '编辑保险公司'
editingId.value = row.id
companyForm.companyName = row.companyName || ''
companyForm.companyLogo = row.companyLogo || ''
companyForm.companyAddress = row.companyAddress || ''
companyForm.contactName = row.contactName || ''
companyForm.contactPhone = row.contactPhone || ''
@@ -615,6 +735,7 @@
try {
const payload = {
companyName: companyForm.companyName.trim(),
companyLogo: companyForm.companyLogo?.trim() ?? '',
companyAddress: companyForm.companyAddress?.trim() || undefined,
contactName: companyForm.contactName?.trim() || undefined,
contactPhone: companyForm.contactPhone?.trim() || undefined,
@@ -693,6 +814,7 @@
// ---------------------------------------------------------------------------
async function handleView(row: any) {
viewCompanyName.value = row.companyName || ''
viewCompanyLogo.value = row.companyLogo || ''
const sections: ViewSection[] = [
{
title: '基本信息',
@@ -710,6 +832,27 @@
]
}
]
if (row.companyLogo) {
sections.push({
title: '公司Logo',
fields: [],
slot: 'company-logo'
})
}
const projectPrices = row.projectPrices || []
if (projectPrices.length) {
sections.push({
title: '险种价格',
fields: projectPrices.map((item: any) => ({
label: item.projectName,
value:
(item.projectFee != null ? '¥' + formatMoneyDisplay(item.projectFee) : '—') +
' · ' +
(item.status == '1' ? '正常' : '停用'),
tagType: item.status == '1' ? 'success' : 'info'
}))
})
}
// 加载开票信息
try {
const invRes = await fetchInsuranceCompanyInvoices(row.id)
@@ -763,6 +906,7 @@
currentCompanyId.value = ''
currentCompanyName.value = ''
itemTable.value = []
loadData()
}
function onItemSelectionChange(rows: any[]) {
@@ -852,6 +996,275 @@
}
}
.company-logo-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.company-logo-preview {
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px solid var(--mint-border, #e2e8f0);
}
.company-logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 8px;
border: 1px dashed var(--mint-border, #e2e8f0);
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
background: #f8fafc;
}
.insurance-company-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 16px;
min-height: 120px;
padding: 16px;
align-items: stretch;
}
.insurance-company-card {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 268px;
background: #fff;
border: 1px solid var(--mint-border, #e2e8f0);
border-radius: 12px;
overflow: hidden;
transition:
border-color 0.2s,
box-shadow 0.2s;
&:hover {
border-color: #a8d4c4;
box-shadow: 0 2px 12px rgba(4, 120, 87, 0.1);
}
&.is-selected {
border-color: var(--mint-primary, #10b981);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
}
}
.insurance-company-card__select {
position: absolute;
top: 10px;
left: 10px;
z-index: 2;
}
.insurance-company-card__header {
display: flex;
gap: 14px;
min-height: 108px;
box-sizing: border-box;
padding: 16px 16px 12px 40px;
background: #f8fafc;
border-bottom: 1px solid var(--mint-border, #e2e8f0);
}
.insurance-company-card__media {
flex-shrink: 0;
}
.insurance-company-card__logo {
display: block;
width: 72px;
height: 72px;
border-radius: 10px;
border: 1px solid var(--mint-border, #e2e8f0);
background: #fff;
:deep(.el-image__inner),
:deep(img) {
width: 72px;
height: 72px;
object-fit: cover;
}
}
.insurance-company-card__logo-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 72px;
height: 72px;
border-radius: 10px;
border: 1px dashed var(--mint-border, #e2e8f0);
color: var(--mint-text-muted, #94a3b8);
font-size: 11px;
background: #fff;
.el-icon {
font-size: 24px;
}
}
.insurance-company-card__summary {
flex: 1;
min-width: 0;
}
.insurance-company-card__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.insurance-company-card__title {
min-height: 42px;
font-size: 15px;
font-weight: 600;
color: var(--mint-text, #0f172a);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.insurance-company-card__address,
.insurance-company-card__contact {
min-height: 18px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
.insurance-company-card__address {
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insurance-company-card__projects {
display: flex;
flex: 1;
flex-direction: column;
min-height: 92px;
padding: 12px 16px;
}
.insurance-company-card__projects-head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
height: 20px;
margin-bottom: 8px;
}
.insurance-company-card__projects-title {
font-size: 13px;
font-weight: 600;
color: var(--mint-text, #0f172a);
}
.insurance-company-card__projects-count {
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
}
.insurance-company-card__project-body {
display: flex;
flex: 1;
align-items: center;
min-height: 40px;
}
.insurance-company-card__project-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
width: 100%;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: #f8fafc;
}
.insurance-company-card__project-name {
font-size: 13px;
color: var(--mint-text, #0f172a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insurance-company-card__project-fee {
font-size: 12px;
font-weight: 600;
color: #047857;
white-space: nowrap;
}
.insurance-company-card__project-empty {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 40px;
padding: 0;
text-align: center;
font-size: 12px;
color: var(--mint-text-muted, #94a3b8);
}
.insurance-company-card__footer {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 8px;
height: 52px;
box-sizing: border-box;
padding: 10px 16px 14px;
border-top: 1px solid var(--mint-border, #e2e8f0);
}
.company-view-kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 24px;
}
.company-view-kv {
dt {
margin-bottom: 6px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
dd {
margin: 0;
}
}
.company-view-logo {
width: 96px;
height: 96px;
border-radius: 10px;
border: 1px solid var(--mint-border, #e2e8f0);
}
.item-dialog-head {
margin-bottom: 12px;
}