feat: add zyt medicine bootstrap
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MedicineMappingQuery {
|
||||
page_no: number
|
||||
page_size: number
|
||||
local_name?: string
|
||||
remote_keyword?: string
|
||||
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
|
||||
}
|
||||
|
||||
export interface MedicineMappingRow {
|
||||
local_medicine_id: number
|
||||
local_name: string
|
||||
local_unit: string
|
||||
local_status: number
|
||||
mapping_id: number | null
|
||||
mapping_status: number
|
||||
medicine_code: string | null
|
||||
remote_name: string | null
|
||||
remote_brand: string | null
|
||||
remote_unit: string | null
|
||||
settlement_price: string | number | null
|
||||
retail_price: string | number | null
|
||||
catalog_version: number | null
|
||||
remote_status: number | null
|
||||
remote_deleted: number | null
|
||||
operator_name: string | null
|
||||
mapping_update_time: number | null
|
||||
}
|
||||
|
||||
export interface CatalogOption {
|
||||
medicine_code: string
|
||||
name: string
|
||||
brand: string
|
||||
unit: string
|
||||
settlement_price: string | number
|
||||
retail_price: string | number
|
||||
catalog_version: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncStatus {
|
||||
sync_enabled: boolean
|
||||
cursor: number
|
||||
last_success_time: number
|
||||
last_failure_time: number
|
||||
last_error_summary: string
|
||||
is_syncing: boolean
|
||||
catalog_total: number
|
||||
catalog_active: number
|
||||
unmapped_local: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncResult {
|
||||
pages: number
|
||||
pulled: number
|
||||
received: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
deactivated: number
|
||||
cursor: number
|
||||
}
|
||||
|
||||
export function medicineMappingLists(params: MedicineMappingQuery) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
|
||||
}
|
||||
|
||||
export function medicineMappingStatus() {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/status' })
|
||||
}
|
||||
|
||||
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
|
||||
}
|
||||
|
||||
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/save', params })
|
||||
}
|
||||
|
||||
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
|
||||
}
|
||||
|
||||
export function medicineCatalogSync() {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/sync' })
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
<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>
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface LatestRequestTicket<T> {
|
||||
readonly generation: number
|
||||
readonly snapshot: T
|
||||
}
|
||||
|
||||
export interface LatestRequestGuard<T> {
|
||||
next(snapshot: T): LatestRequestTicket<T>
|
||||
invalidate(): number
|
||||
isLatest(ticket: LatestRequestTicket<T>): boolean
|
||||
}
|
||||
|
||||
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
|
||||
@@ -0,0 +1,22 @@
|
||||
export function createLatestRequestGuard() {
|
||||
let generation = 0
|
||||
|
||||
return {
|
||||
next(snapshot) {
|
||||
generation += 1
|
||||
const stableSnapshot = Array.isArray(snapshot)
|
||||
? [...snapshot]
|
||||
: snapshot && typeof snapshot === 'object'
|
||||
? { ...snapshot }
|
||||
: snapshot
|
||||
return Object.freeze({ generation, snapshot: stableSnapshot })
|
||||
},
|
||||
invalidate() {
|
||||
generation += 1
|
||||
return generation
|
||||
},
|
||||
isLatest(ticket) {
|
||||
return ticket?.generation === generation
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user