687 lines
22 KiB
Vue
687 lines
22 KiB
Vue
<template>
|
|
<div class="mapping-page">
|
|
<header class="page-header">
|
|
<h1>洛阳药房药材映射</h1>
|
|
<el-button
|
|
v-if="status.sync_enabled"
|
|
v-perms="['pharmacy.medicineMapping/sync']"
|
|
type="primary"
|
|
:icon="Refresh"
|
|
:loading="syncing"
|
|
@click="handleSync"
|
|
>
|
|
增量同步
|
|
</el-button>
|
|
</header>
|
|
|
|
<section class="status-strip" v-loading="statusLoading">
|
|
<div class="status-item">
|
|
<span>目录</span>
|
|
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
|
|
<small>启用 / 总数</small>
|
|
</div>
|
|
<div class="status-item warning">
|
|
<span>未映射本地药材</span>
|
|
<strong>{{ status.unmapped_local }}</strong>
|
|
<small>仅统计启用药材</small>
|
|
</div>
|
|
<div class="status-item">
|
|
<span>同步游标</span>
|
|
<strong>{{ status.cursor }}</strong>
|
|
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
|
|
</div>
|
|
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
|
|
<span>最近同步</span>
|
|
<strong>{{
|
|
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
|
|
}}</strong>
|
|
<small :title="status.last_error_summary">
|
|
{{
|
|
status.last_error_summary ||
|
|
formatTime(status.last_failure_time) ||
|
|
'无失败记录'
|
|
}}
|
|
</small>
|
|
</div>
|
|
</section>
|
|
|
|
<el-form class="filter-bar" inline @submit.prevent>
|
|
<el-form-item label="本地药材">
|
|
<el-input
|
|
v-model="query.local_name"
|
|
clearable
|
|
placeholder="名称"
|
|
:prefix-icon="Search"
|
|
@keyup.enter="search"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="远端目录">
|
|
<el-input
|
|
v-model="query.remote_keyword"
|
|
clearable
|
|
placeholder="名称或编码"
|
|
:prefix-icon="Search"
|
|
@keyup.enter="search"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="映射状态">
|
|
<el-select
|
|
v-model="query.mapping_status"
|
|
clearable
|
|
placeholder="全部"
|
|
style="width: 150px"
|
|
>
|
|
<el-option label="已映射" value="mapped" />
|
|
<el-option label="未映射" value="unmapped" />
|
|
<el-option label="远端失效" value="invalid" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
|
<el-button @click="resetFilters">重置</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
|
|
<div class="table-wrap">
|
|
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
|
|
<el-table-column label="本地药材" min-width="190" fixed="left">
|
|
<template #default="{ row }">
|
|
<div class="medicine-name">{{ row.local_name }}</div>
|
|
<div class="subline">
|
|
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="本地状态" width="90" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
|
|
{{ row.local_status === 1 ? '启用' : '停用' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="映射状态" width="105" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="mappingTag(row.mapping_status)" size="small">
|
|
{{ mappingText(row.mapping_status) }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="洛阳药房目录" min-width="250">
|
|
<template #default="{ row }">
|
|
<template v-if="row.medicine_code">
|
|
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
|
|
<div class="subline code">{{ row.medicine_code }}</div>
|
|
</template>
|
|
<template v-else>
|
|
<div class="medicine-name">-</div>
|
|
<div class="subline">未映射</div>
|
|
</template>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="单位" width="80" align="center">
|
|
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="价格" width="155" align="right">
|
|
<template #default="{ row }">
|
|
<template v-if="row.mapping_status !== 0">
|
|
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
|
|
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
|
|
</template>
|
|
<template v-else>
|
|
<div>-</div>
|
|
<div class="subline">未映射</div>
|
|
</template>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="版本 / 状态" width="130" align="center">
|
|
<template #default="{ row }">
|
|
<template v-if="row.medicine_code">
|
|
<div>v{{ row.catalog_version || 0 }}</div>
|
|
<div class="subline">
|
|
{{
|
|
row.remote_status === 1 && row.remote_deleted !== 1
|
|
? '远端启用'
|
|
: '远端停用'
|
|
}}
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<div>-</div>
|
|
<div class="subline">未映射</div>
|
|
</template>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="170" fixed="right" align="center">
|
|
<template #default="{ row }">
|
|
<el-button
|
|
v-perms="['pharmacy.medicineMapping/save']"
|
|
type="primary"
|
|
link
|
|
:icon="Link"
|
|
:disabled="row.local_status !== 1"
|
|
@click="openMapping(row)"
|
|
>
|
|
{{ row.mapping_status === 1 ? '更换' : '映射' }}
|
|
</el-button>
|
|
<el-button
|
|
v-if="row.mapping_status === 1 || row.mapping_status === 2"
|
|
v-perms="['pharmacy.medicineMapping/unlink']"
|
|
type="danger"
|
|
link
|
|
:icon="CloseBold"
|
|
@click="handleUnlink(row)"
|
|
>
|
|
解除
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
|
|
<div class="pagination-wrap">
|
|
<el-pagination
|
|
v-model:current-page="query.page_no"
|
|
v-model:page-size="query.page_size"
|
|
:total="total"
|
|
:page-sizes="[20, 50, 100]"
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="loadRows"
|
|
@current-change="loadRows"
|
|
/>
|
|
</div>
|
|
|
|
<el-dialog
|
|
v-model="dialogVisible"
|
|
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
|
|
width="min(560px, calc(100vw - 32px))"
|
|
destroy-on-close
|
|
>
|
|
<div class="local-summary">
|
|
<span>本地药材</span>
|
|
<strong>{{ editingRow?.local_name }}</strong>
|
|
<small
|
|
>ID {{ editingRow?.local_medicine_id }} ·
|
|
{{ editingRow?.local_unit || '-' }}</small
|
|
>
|
|
</div>
|
|
<el-form label-position="top" class="mapping-form">
|
|
<el-form-item label="洛阳药房药材">
|
|
<el-select
|
|
v-model="selectedCode"
|
|
filterable
|
|
remote
|
|
reserve-keyword
|
|
clearable
|
|
:remote-method="searchCatalog"
|
|
:loading="catalogLoading"
|
|
placeholder="输入药材名称或编码检索"
|
|
style="width: 100%"
|
|
>
|
|
<el-option
|
|
v-for="option in catalogOptions"
|
|
:key="option.medicine_code"
|
|
:label="`${option.name} · ${option.medicine_code}`"
|
|
:value="option.medicine_code"
|
|
>
|
|
<div class="option-row">
|
|
<span>{{ option.name }}</span>
|
|
<small
|
|
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
|
|
{{ option.unit }}</small
|
|
>
|
|
</div>
|
|
</el-option>
|
|
</el-select>
|
|
</el-form-item>
|
|
</el-form>
|
|
<template #footer>
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
<el-button
|
|
type="primary"
|
|
:loading="saving"
|
|
:disabled="!selectedCode"
|
|
@click="saveMapping"
|
|
>
|
|
保存映射
|
|
</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts" name="pharmacyMedicineMapping">
|
|
import { onMounted, reactive, ref } from 'vue'
|
|
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
|
|
import { ElMessageBox } from 'element-plus'
|
|
import feedback from '@/utils/feedback'
|
|
import { createLatestRequestGuard } from './latest-request.mjs'
|
|
import {
|
|
medicineCatalogOptions,
|
|
medicineCatalogSync,
|
|
medicineMappingLists,
|
|
medicineMappingSave,
|
|
medicineMappingStatus,
|
|
medicineMappingUnlink,
|
|
type CatalogOption,
|
|
type MedicineMappingQuery,
|
|
type MedicineMappingRow,
|
|
type PharmacySyncResult,
|
|
type PharmacySyncStatus
|
|
} from '@/api/pharmacy'
|
|
|
|
const emptyStatus = (): PharmacySyncStatus => ({
|
|
sync_enabled: false,
|
|
cursor: 0,
|
|
last_success_time: 0,
|
|
last_failure_time: 0,
|
|
last_error_summary: '',
|
|
is_syncing: false,
|
|
catalog_total: 0,
|
|
catalog_active: 0,
|
|
unmapped_local: 0
|
|
})
|
|
|
|
const loading = ref(false)
|
|
const statusLoading = ref(false)
|
|
const syncing = ref(false)
|
|
const saving = ref(false)
|
|
const catalogLoading = ref(false)
|
|
const rows = ref<MedicineMappingRow[]>([])
|
|
const total = ref(0)
|
|
const status = ref<PharmacySyncStatus>(emptyStatus())
|
|
const query = reactive<MedicineMappingQuery>({
|
|
page_no: 1,
|
|
page_size: 20,
|
|
local_name: '',
|
|
remote_keyword: '',
|
|
mapping_status: ''
|
|
})
|
|
|
|
const dialogVisible = ref(false)
|
|
const editingRow = ref<MedicineMappingRow | null>(null)
|
|
const selectedCode = ref('')
|
|
const catalogOptions = ref<CatalogOption[]>([])
|
|
let catalogTimer: number | undefined
|
|
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
|
|
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
|
|
const statusRequests = createLatestRequestGuard()
|
|
|
|
const formatPrice = (value: string | number | null | undefined) => {
|
|
const number = Number(value || 0)
|
|
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
|
|
}
|
|
|
|
const formatTime = (timestamp: number) => {
|
|
if (!timestamp) return ''
|
|
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
|
|
const mappingText = (value: number) => {
|
|
if (value === 1) return '已映射'
|
|
if (value === 2) return '远端失效'
|
|
return '未映射'
|
|
}
|
|
|
|
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
|
|
if (value === 1) return 'success'
|
|
if (value === 2) return 'warning'
|
|
return 'info'
|
|
}
|
|
|
|
const loadRows = async () => {
|
|
const ticket = listRequests.next({ ...query })
|
|
loading.value = true
|
|
try {
|
|
const response = await medicineMappingLists(ticket.snapshot)
|
|
if (listRequests.isLatest(ticket)) {
|
|
rows.value = response.lists || []
|
|
total.value = response.count || 0
|
|
}
|
|
} finally {
|
|
if (listRequests.isLatest(ticket)) {
|
|
loading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const loadStatus = async () => {
|
|
const ticket = statusRequests.next(undefined)
|
|
statusLoading.value = true
|
|
try {
|
|
const nextStatus = await medicineMappingStatus()
|
|
if (statusRequests.isLatest(ticket)) {
|
|
status.value = nextStatus
|
|
}
|
|
} finally {
|
|
if (statusRequests.isLatest(ticket)) {
|
|
statusLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
const search = () => {
|
|
query.page_no = 1
|
|
void loadRows()
|
|
}
|
|
|
|
const resetFilters = () => {
|
|
query.local_name = ''
|
|
query.remote_keyword = ''
|
|
query.mapping_status = ''
|
|
search()
|
|
}
|
|
|
|
const handleSync = async () => {
|
|
syncing.value = true
|
|
try {
|
|
const result = (await medicineCatalogSync()) as PharmacySyncResult
|
|
feedback.msgSuccess(
|
|
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
|
|
)
|
|
await Promise.all([loadRows(), loadStatus()])
|
|
} finally {
|
|
syncing.value = false
|
|
}
|
|
}
|
|
|
|
const searchCatalogNow = async (
|
|
keyword: string,
|
|
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
|
|
) => {
|
|
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
|
|
catalogLoading.value = true
|
|
try {
|
|
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
|
|
if (
|
|
catalogRequests.isLatest(ticket) &&
|
|
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
|
|
) {
|
|
catalogOptions.value = options
|
|
}
|
|
} finally {
|
|
if (catalogRequests.isLatest(ticket)) {
|
|
catalogLoading.value = false
|
|
}
|
|
}
|
|
return ticket
|
|
}
|
|
|
|
const searchCatalog = (keyword: string) => {
|
|
if (catalogTimer) window.clearTimeout(catalogTimer)
|
|
catalogRequests.invalidate()
|
|
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
|
|
}
|
|
|
|
const openMapping = async (row: MedicineMappingRow) => {
|
|
if (catalogTimer) window.clearTimeout(catalogTimer)
|
|
catalogRequests.invalidate()
|
|
const rowSnapshot = { ...row }
|
|
const localMedicineId = Number(rowSnapshot.local_medicine_id)
|
|
editingRow.value = rowSnapshot
|
|
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
|
|
catalogOptions.value = []
|
|
dialogVisible.value = true
|
|
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
|
|
if (
|
|
!catalogRequests.isLatest(initialTicket) ||
|
|
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
|
|
)
|
|
return
|
|
if (
|
|
selectedCode.value &&
|
|
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
|
|
) {
|
|
await searchCatalogNow(selectedCode.value, localMedicineId)
|
|
}
|
|
}
|
|
|
|
const saveMapping = async () => {
|
|
if (!editingRow.value || !selectedCode.value) return
|
|
saving.value = true
|
|
try {
|
|
await medicineMappingSave({
|
|
local_medicine_id: editingRow.value.local_medicine_id,
|
|
medicine_code: selectedCode.value
|
|
})
|
|
dialogVisible.value = false
|
|
await Promise.all([loadRows(), loadStatus()])
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
const handleUnlink = async (row: MedicineMappingRow) => {
|
|
await ElMessageBox.confirm(
|
|
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
|
|
'解除映射',
|
|
{
|
|
type: 'warning',
|
|
confirmButtonText: '解除',
|
|
cancelButtonText: '取消'
|
|
}
|
|
)
|
|
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
|
|
await Promise.all([loadRows(), loadStatus()])
|
|
}
|
|
|
|
onMounted(() => {
|
|
void Promise.all([loadRows(), loadStatus()])
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mapping-page {
|
|
min-width: 0;
|
|
padding: 16px;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.page-header h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
line-height: 28px;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.status-strip {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
min-height: 88px;
|
|
margin-bottom: 16px;
|
|
border: 1px solid var(--el-border-color-light);
|
|
background: var(--el-bg-color);
|
|
}
|
|
|
|
.status-item {
|
|
min-width: 0;
|
|
padding: 14px 16px;
|
|
border-right: 1px solid var(--el-border-color-lighter);
|
|
}
|
|
|
|
.status-item:last-child {
|
|
border-right: 0;
|
|
}
|
|
|
|
.status-item span,
|
|
.status-item small {
|
|
display: block;
|
|
overflow: hidden;
|
|
color: var(--el-text-color-secondary);
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.status-item strong {
|
|
display: block;
|
|
margin: 5px 0 2px;
|
|
font-size: 18px;
|
|
line-height: 24px;
|
|
}
|
|
|
|
.status-item.warning strong {
|
|
color: var(--el-color-warning-dark-2);
|
|
}
|
|
|
|
.status-item.danger strong,
|
|
.status-item.danger small {
|
|
color: var(--el-color-danger);
|
|
}
|
|
|
|
.filter-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0 8px;
|
|
padding: 14px 16px 0;
|
|
border: 1px solid var(--el-border-color-light);
|
|
border-bottom: 0;
|
|
background: var(--el-fill-color-extra-light);
|
|
}
|
|
|
|
.filter-bar :deep(.el-input) {
|
|
width: 190px;
|
|
}
|
|
|
|
.table-wrap {
|
|
min-width: 0;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.table-wrap :deep(.el-table) {
|
|
min-width: 1220px;
|
|
}
|
|
|
|
.medicine-name {
|
|
overflow: hidden;
|
|
font-weight: 600;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.subline,
|
|
.muted {
|
|
margin-top: 3px;
|
|
color: var(--el-text-color-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.code {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
}
|
|
|
|
.pagination-wrap {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
overflow-x: auto;
|
|
padding-top: 16px;
|
|
}
|
|
|
|
.local-summary {
|
|
display: grid;
|
|
grid-template-columns: auto 1fr;
|
|
gap: 3px 12px;
|
|
padding: 12px 14px;
|
|
border-left: 3px solid var(--el-color-primary);
|
|
background: var(--el-fill-color-light);
|
|
}
|
|
|
|
.local-summary span,
|
|
.local-summary small {
|
|
color: var(--el-text-color-secondary);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.local-summary small {
|
|
grid-column: 2;
|
|
}
|
|
|
|
.mapping-form {
|
|
margin-top: 18px;
|
|
}
|
|
|
|
.option-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
width: 100%;
|
|
}
|
|
|
|
.option-row small {
|
|
overflow: hidden;
|
|
color: var(--el-text-color-secondary);
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.status-strip {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
|
|
.status-item:nth-child(2) {
|
|
border-right: 0;
|
|
}
|
|
|
|
.status-item:nth-child(-n + 2) {
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.mapping-page {
|
|
padding: 12px;
|
|
}
|
|
|
|
.page-header {
|
|
align-items: stretch;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.page-header .el-button {
|
|
width: 100%;
|
|
}
|
|
|
|
.status-strip {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.status-item,
|
|
.status-item:nth-child(2) {
|
|
border-right: 0;
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|
}
|
|
|
|
.status-item:last-child {
|
|
border-bottom: 0;
|
|
}
|
|
|
|
.filter-bar {
|
|
display: block;
|
|
}
|
|
|
|
.filter-bar :deep(.el-form-item),
|
|
.filter-bar :deep(.el-input),
|
|
.filter-bar :deep(.el-select) {
|
|
width: 100% !important;
|
|
}
|
|
|
|
.pagination-wrap {
|
|
justify-content: flex-start;
|
|
}
|
|
}
|
|
</style>
|