feat: 会员管理、在线复诊优化、新增配送仓库

This commit is contained in:
李琦
2026-07-23 13:31:32 +08:00
parent dabfa45ac0
commit cf3fbf5a4c
14 changed files with 1030 additions and 376 deletions

View File

@@ -226,6 +226,46 @@ export function mapOrderListRow(item) {
}
}
/**
* 列表卡片瘦字段:只保留 UI 需要的标量,丢弃处方 content / 明细整包,
* 避免微信小程序 setData 体积过大(曾出现 ~1.5MB 告警)
* @param {Object} raw 接口订单行
* @returns {Object}
*/
export function toOrderCardRow(raw) {
if (!raw) return { _wxKey: '0', id: 0 }
const d = mapOrderListRow(raw)
const id = raw.id
const status = raw.status
// 样式用:已完成/取消给语义 class其余用状态码
let statusKey = status != null ? String(status) : 'default'
if (status === 6 || status === 7) statusKey = 'done'
else if (status === 9) statusKey = 'cancel'
const payText = (d.totalPayPrice || '').replace('¥', '').trim()
return {
_wxKey: id != null && id !== '' ? String(id) : 'ord0',
id,
status,
statusKey,
orderNo: d.orderNo,
storeName: d.storeName,
statusText: d.statusText,
productSummary: d.productSummary || '药品订单',
prescriptionTypeText: d.prescriptionTypeText,
deliveryText: d.deliveryText,
patientName: d.patientName,
userNickname: d.userNickname,
receiverName: d.receiverName,
receiverMobile: d.receiverMobile,
createdAt: d.createdAt,
totalPayPrice: d.totalPayPrice,
payAmountText: payText,
pId: d.pId,
canViewPrescription: d.canViewPrescription,
upId: d.upId,
}
}
/** 物流详情(对齐 PC express-detail/detail-by-order */
export function mapExpressDetail(data) {
if (!data || typeof data !== 'object') {

View File

@@ -30,3 +30,15 @@ export function buildOrderSaleAmountParams(opts) {
const { page, pageSize, ...rest } = opts || {}
return buildOrderListParams(rest)
}
/**
* 状态数量接口参数:与列表筛选一致,但不传 status
* (徽标要展示每个状态的数量,不能被当前 Tab 的 status 过滤)
*/
export function buildOrderStatusCountParams(opts) {
const params = buildOrderSaleAmountParams(opts)
if (params && Object.prototype.hasOwnProperty.call(params, 'status')) {
delete params.status
}
return params
}

View File

@@ -0,0 +1,156 @@
<template>
<!--
列表骨架屏 type 模拟订单卡等放在业务分包以控制主包体积
order 类型对齐商品订单卡片结构顶栏/正文/底栏
-->
<view class="list-skeleton" :class="'list-skeleton--' + type">
<view
v-for="i in rowCount"
:key="i"
class="sk-row"
:class="'sk-row--' + type"
>
<template v-if="type === 'order'">
<view class="sk-order-head">
<view class="sk-line sk-line--md sk-shimmer"></view>
<view class="sk-line sk-line--xs sk-shimmer"></view>
</view>
<view class="sk-order-body">
<view class="sk-line sk-line--lg sk-shimmer"></view>
<view class="sk-line sk-line--sm sk-shimmer"></view>
<view class="sk-line sk-line--md sk-shimmer"></view>
</view>
<view class="sk-order-foot">
<view class="sk-line sk-line--xs sk-shimmer"></view>
<view class="sk-line sk-line--price sk-shimmer"></view>
</view>
</template>
<template v-else>
<view class="sk-line sk-line--lg sk-shimmer"></view>
<view class="sk-line sk-line--md sk-shimmer"></view>
<view class="sk-line sk-line--sm sk-shimmer"></view>
</template>
</view>
</view>
</template>
<script>
export default {
name: 'ListSkeleton',
props: {
/** 骨架样式order=订单卡default=通用三行 */
type: {
type: String,
default: 'order',
validator(v) {
return ['order', 'default'].indexOf(v) !== -1
},
},
/** 行数 */
rows: {
type: [Number, String],
default: 4,
},
},
computed: {
rowCount() {
const n = Number(this.rows)
return n > 0 ? n : 4
},
},
}
</script>
<style lang="scss" scoped>
.list-skeleton {
width: 100%;
padding: 0 24rpx 24rpx;
box-sizing: border-box;
}
.sk-shimmer {
background: linear-gradient(90deg, #ebebeb 25%, #f5f5f5 50%, #ebebeb 75%);
background-size: 400% 100%;
animation: sk-shimmer 1.4s ease infinite;
}
@keyframes sk-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: 0 0;
}
}
.sk-row--order {
background: #fff;
border-radius: 20rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
}
.sk-order-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.sk-order-body {
margin-bottom: 24rpx;
}
.sk-order-foot {
display: flex;
justify-content: space-between;
align-items: center;
}
.sk-row--default {
background: #fff;
border-radius: 16rpx;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
}
.sk-line {
height: 24rpx;
border-radius: 6rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
.sk-line--lg {
width: 70%;
}
.sk-line--md {
width: 50%;
}
.sk-line--sm {
width: 90%;
}
.sk-line--xs {
width: 120rpx;
height: 22rpx;
margin-bottom: 0;
}
.sk-line--price {
width: 140rpx;
height: 32rpx;
margin-bottom: 0;
}
.sk-order-head .sk-line--md {
width: 45%;
margin-bottom: 0;
}
</style>

View File

@@ -1,12 +1,26 @@
<template>
<view>
<business-page-layout title="商品订单" :show-back="true">
<view class="order-list-container">
<!-- 终极修复弃用会导致冲突的 page-sticky-header -->
<!-- 改用原生 native-sticky-header绑定动态计算的导航栏高度精准吸附在 u-navbar 下方 -->
<view class="native-sticky-header" :style="{ top: navTopOffset + 'px' }">
<!-- page-flex 高度=视口减导航与 layout 底部 32rpxheader 不可再设 relative top会空白+遮挡列表 -->
<view
class="order-list-container page-flex"
:style="{ height: 'calc(100vh - ' + navTopOffset + 'px - 32rpx)' }"
>
<view class="native-sticky-header rx-flex-fixed">
<view class="header-solid-bg">
<!-- 状态 Tabcount>0 显示徽标0 u-badge 自动隐藏 -->
<view class="tabs-wrap">
<u-tabs
:list="statusTabsList"
:is-scroll="true"
:current="statusIndex"
active-color="#6ACDBB"
inactive-color="#86909C"
bg-color="#ffffff"
:bold="true"
@change="onStatusTabChange"
/>
</view>
<view class="quick-filter">
<view class="filter-row date-row">
<picker mode="date" :value="dateStart" @change="onStart">
@@ -25,12 +39,6 @@
<text class="filter-label">订单号</text>
<input class="filter-input" v-model="orderNo" placeholder="输入订单号" placeholder-style="color:#BDBDBD" />
</view>
<view class="filter-row">
<text class="filter-label">订单状态</text>
<picker :range="statusLabels" @change="onStatus">
<view class="filter-picker">{{ statusLabels[statusIndex] || '全部' }}</view>
</picker>
</view>
<view class="filter-row">
<text class="filter-label">发货方式</text>
<picker :range="deliveryLabels" @change="onDelivery">
@@ -47,8 +55,7 @@
</view>
</view>
<!-- 统计卡片区 -->
<scroll-view v-if="statItems.length" class="stats-scroll" scroll-x>
<scroll-view v-if="statItems.length" class="stats-scroll rx-flex-fixed" scroll-x>
<view class="stats">
<view v-for="s in statItems" :key="s.key" class="stat-card">
<text class="stat-label">{{ s.label }}</text>
@@ -57,90 +64,105 @@
</view>
</scroll-view>
<!-- 顶级悬浮卡片列表 -->
<view class="card-list-wrap">
<view
v-for="item in list"
:key="item._wxKey"
class="premium-card"
hover-class="card-hover"
:hover-stay-time="150"
@click="goDetail(item)"
<!-- 左右滑切换状态内层 scroll-view 负责下拉刷新与触底分页 -->
<swiper
class="order-swiper"
:current="statusIndex"
:disable-touch="statusOptions.length < 2"
@change="onStatusSwiperChange"
>
<swiper-item
v-for="(cat, idx) in statusOptions"
:key="idx"
>
<!-- 顶部业务归属与状态 -->
<view class="card-header">
<view class="header-left">
<!-- 诊所信息提权放在最醒目的左上角 -->
<view class="clinic-tag" v-if="rowDisplay(item).storeName !== '-'">
<text class="clinic-name">{{ rowDisplay(item).storeName }}</text>
<scroll-view
scroll-y
class="order-scroll"
refresher-enabled="true"
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresherRefresh"
@scrolltolower="onScrollToLower"
>
<block v-if="statusIndex === idx">
<!-- 仅首屏 loading 且无数据时展示骨架上拉加载不盖骨架 -->
<list-skeleton v-if="loading && !list.length" type="order" :rows="4" />
<view v-else class="card-list-wrap">
<view
v-for="item in list"
:key="item._wxKey"
class="premium-card"
hover-class="card-hover"
:hover-stay-time="150"
@click="goDetail(item)"
>
<view class="card-header">
<view class="header-left">
<view class="clinic-tag" v-if="item.storeName !== '-'">
<text class="clinic-name">{{ item.storeName }}</text>
</view>
<view class="order-no-wrap">
<text class="lbl">单号</text>
<text class="val">{{ item.orderNo }}</text>
</view>
</view>
<view class="status-text" :class="['status-' + (item.statusKey || 'default')]">
{{ item.statusText }}
</view>
</view>
<view class="card-body">
<view class="product-row">
<text class="product-name">{{ item.productSummary || '药品订单' }}</text>
</view>
<view class="tags-row">
<text class="modern-tag tag-type">{{ item.prescriptionTypeText }}</text>
<text class="modern-tag tag-delivery">{{ item.deliveryText }}</text>
</view>
<view class="info-grid">
<view
class="info-item"
v-if="item.patientName !== '-' || item.userNickname !== '-'"
@click.stop="openPatient(item)"
>
<text class="info-lbl">就诊人</text>
<text class="info-val link">{{ item.patientName !== '-' ? item.patientName : item.userNickname }}</text>
</view>
<view class="info-item">
<text class="info-lbl">收件人</text>
<text class="info-val">
{{ item.receiverName }}
<text class="info-sub" v-if="item.receiverMobile">{{ item.receiverMobile }}</text>
</text>
</view>
</view>
</view>
<view class="card-footer">
<text class="time">{{ item.createdAt }}</text>
<view class="footer-actions">
<view class="price-block">
<text class="price-lbl">实付</text>
<text class="currency">¥</text>
<text class="amount">{{ item.payAmountText }}</text>
</view>
<view
v-if="item.canViewPrescription"
class="btn-outline"
@click.stop="openPrescription(item.pId)"
>
查看处方
</view>
</view>
</view>
</view>
</view>
<view class="order-no-wrap">
<text class="lbl">单号</text>
<text class="val">{{ rowDisplay(item).orderNo }}</text>
<view v-if="!loading && !list.length" class="empty-state">
<text>暂无订单记录</text>
</view>
</view>
<view class="status-text" :class="['status-' + (rowDisplay(item).status || 'default')]">
{{ rowDisplay(item).statusText }}
</view>
</view>
<!-- 核心商品与收件信息无边框内联排版节省大量高度 -->
<view class="card-body">
<!-- 药品名称与标签同行排版 -->
<view class="product-row">
<text class="product-name">{{ rowDisplay(item).productSummary || '药品订单' }}</text>
</view>
<view class="tags-row">
<text class="modern-tag tag-type">{{ rowDisplay(item).prescriptionTypeText }}</text>
<text class="modern-tag tag-delivery">{{ rowDisplay(item).deliveryText }}</text>
</view>
<!-- 去掉臃肿的灰色岛采用精致的文本网格 -->
<view class="info-grid">
<view
class="info-item"
v-if="rowDisplay(item).patientName !== '-' || rowDisplay(item).userNickname !== '-'"
@click.stop="openPatient(item)"
>
<text class="info-lbl">就诊人</text>
<text class="info-val link">{{ rowDisplay(item).patientName !== '-' ? rowDisplay(item).patientName : rowDisplay(item).userNickname }}</text>
</view>
<view class="info-item">
<text class="info-lbl">收件人</text>
<text class="info-val">
{{ rowDisplay(item).receiverName }}
<text class="info-sub" v-if="rowDisplay(item).receiverMobile">{{ rowDisplay(item).receiverMobile }}</text>
</text>
</view>
</view>
</view>
<!-- 底部时间价格与操作水平对齐 -->
<view class="card-footer">
<text class="time">{{ rowDisplay(item).createdAt }}</text>
<view class="footer-actions">
<view class="price-block">
<text class="price-lbl">实付</text>
<text class="currency">¥</text>
<text class="amount">{{ rowDisplay(item).totalPayPrice.replace('¥', '').trim() }}</text>
</view>
<view
v-if="rowDisplay(item).canViewPrescription"
class="btn-outline"
@click.stop="openPrescription(rowDisplay(item).pId)"
>
查看处方
</view>
</view>
</view>
</view>
</view>
<view v-if="!loading && !list.length" class="empty-state">
<text>暂无订单记录</text>
</view>
</block>
<view v-else class="rx-swiper-placeholder"></view>
</scroll-view>
</swiper-item>
</swiper>
</view>
</business-page-layout>
<prescription-detail-popup ref="prescriptionPopup" />
<user-patient-detail
@@ -157,14 +179,15 @@
// 这里已经移除了引入产生冲突的 PageStickyHeader以保证代码的纯净性
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import CollapsibleFilterPanel from '@/subPackages/sub_business_shared/components/CollapsibleFilterPanel.vue'
import ListSkeleton from '@/subPackages/sub_business_shared/components/ListSkeleton.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import UserPatientDetail from '@/subPackages/sub_business_shared/components/UserPatientDetail.vue'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapOrderListRow } from '@/subPackages/sub_business_shared/common/display.js'
import { toOrderCardRow } from '@/subPackages/sub_business_shared/common/display.js'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import {
buildOrderListParams,
buildOrderSaleAmountParams,
buildOrderStatusCountParams,
} from '@/subPackages/sub_business_shared/common/orderParams.js'
import {
loadOrderFilter,
@@ -190,14 +213,24 @@ const PRESCRIPTION_TYPE_OPTIONS = [
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, CollapsibleFilterPanel, PrescriptionDetailPopup, UserPatientDetail },
components: {
BusinessPageLayout,
CollapsibleFilterPanel,
ListSkeleton,
PrescriptionDetailPopup,
UserPatientDetail,
},
data() {
return {
list: [],
page: 1,
pageSize: 15,
loading: false,
hasMore: true,
isRefreshing: false,
tabChanging: false,
orderNo: '',
statusOptions: [{ label: '全部', value: '' }],
statusOptions: [{ label: '全部', value: '', count: 0 }],
statusIndex: 0,
deliveryIndex: 0,
prescriptionTypeIndex: 0,
@@ -211,8 +244,15 @@ export default {
}
},
computed: {
statusLabels() {
return this.statusOptions.map(s => s.label || s.name || '全部')
/** u-tabsname + count0 不显示徽标) */
statusTabsList() {
return (this.statusOptions || []).map((s) => {
const count = Number(s.count) > 0 ? Number(s.count) : 0
return {
name: s.label || s.name || '全部',
count,
}
}) || []
},
deliveryLabels() {
return DELIVERY_OPTIONS.map(o => o.label)
@@ -222,8 +262,7 @@ export default {
},
},
created() {
// 动态计算顶部 u-navbar 的总高度 (状态栏 + navbar 固定 44px 高度)
// 这一步与 business-page-layout 内的计算逻辑保持完全一致,保证完美对接
// 仅用于 page-flex 高度 calclayout 已 paddingTop勿再绑到 header 的 relative top
const sys = uni.getSystemInfoSync()
const statusBarHeight = this.$statusBarHeight || sys.statusBarHeight || 0
const navbarHeight = this.$navbarHeight || 44
@@ -234,26 +273,24 @@ export default {
this.bizApi.getOrderStatusOption().then(w => {
const opts = (w.data && (w.data.items || w.data)) || []
if (Array.isArray(opts) && opts.length) {
this.statusOptions = [{ label: '全部', value: '' }].concat(opts.map(o => ({
this.statusOptions = [{ label: '全部', value: '', count: 0 }].concat(opts.map(o => ({
label: o.label || o.name,
value: o.value != null ? o.value : o.id,
count: 0,
})))
if (this.statusIndex >= this.statusOptions.length) this.statusIndex = 0
}
this.loadStatusCount()
})
this.checkDateEndAndReload()
},
/** 离开/隐藏时关掉处方抽屉,避免 page-container 残留白屏 */
onHide() {
this.closePrescriptionPopup()
},
onUnload() {
this.closePrescriptionPopup()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
/** 关闭处方抽屉 */
closePrescriptionPopup() {
const pop = this.$refs.prescriptionPopup
if (pop && typeof pop.onClose === 'function') {
@@ -314,14 +351,14 @@ export default {
dateEnd: this.dateEnd,
}
},
rowDisplay(item) {
return mapOrderListRow(item)
},
/** 重新加载列表、销售金额与状态徽标 */
reload() {
this.persistOrderFilter()
this.page = 1
this.hasMore = true
this.load(1, false)
this.loadSaleAmount()
this.loadStatusCount()
},
loadSaleAmount() {
const params = buildOrderSaleAmountParams(this.filterValues())
@@ -337,21 +374,83 @@ export default {
]
})
},
/**
* 拉各状态数量写回 statusOptions.count不含当前 status 筛选)
*/
loadStatusCount() {
const params = buildOrderStatusCountParams(this.filterValues())
this.bizApi.getOrderStatusCount(params).then((w) => {
if (!w.ok || !w.data) return
const total = Number(w.data.total || 0)
const map = {}
const items = w.data.items || []
items.forEach((it) => {
map[Number(it.id)] = Number(it.count || 0)
})
this.statusOptions = (this.statusOptions || []).map((s, i) => {
const isAll = i === 0 || s.value === '' || s.value == null
return {
...s,
count: isAll ? total : (map[Number(s.value)] || 0),
}
})
})
},
load(page, append) {
this.loading = true
const pageSize = this.pageSize || 15
const params = buildOrderListParams({
page,
pageSize: 15,
pageSize,
...this.filterValues(),
})
this.bizApi.getOrderList(params).then(w => {
const items = withWxKey((w.data && w.data.items) || [], 'id', 'ord')
// 只写入卡片瘦字段,避免把处方 content / 明细整包 setData
const rawItems = (w.data && w.data.items) || []
const items = rawItems.map((raw) => toOrderCardRow(raw))
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
this.hasMore = rawItems.length >= pageSize
}).finally(() => {
this.loading = false
if (this.isRefreshing) this.isRefreshing = false
})
},
onStatus(e) {
this.statusIndex = Number(e.detail.value)
/** scroll-view 下拉刷新 */
onRefresherRefresh() {
this.isRefreshing = true
this.reload()
},
/** scroll-view 触底分页 */
onScrollToLower() {
if (this.loading || !this.hasMore) return
this.load(this.page + 1, true)
},
/** 状态 Tab 点击:与 swiper 双向同步 */
onStatusTabChange(index) {
if (this.tabChanging) return
this.tabChanging = true
const idx = typeof index === 'object' ? Number(index.index != null ? index.index : index) : Number(index)
if (!Number.isNaN(idx) && idx !== this.statusIndex) {
this.statusIndex = idx
this.reload()
}
this.$nextTick(() => {
this.tabChanging = false
})
},
/** 左右滑切换状态 */
onStatusSwiperChange(e) {
if (this.tabChanging) return
this.tabChanging = true
const idx = Number((e.detail && e.detail.current) || 0)
if (idx !== this.statusIndex) {
this.statusIndex = idx
this.reload()
}
this.$nextTick(() => {
this.tabChanging = false
})
},
onDelivery(e) {
this.deliveryIndex = Number(e.detail.value)
@@ -368,16 +467,14 @@ export default {
if (!pId) return
this.$refs.prescriptionPopup.open(pId)
},
/** 打开患者视角详情抽屉 */
openPatient(item) {
const d = this.rowDisplay(item)
const upId = Number(d.upId || 0)
const upId = Number(item.upId || 0)
if (!upId) {
uni.showToast({ title: '缺少就诊人信息', icon: 'none' })
return
}
this.patientDetailUpId = upId
this.patientDetailName = d.patientName !== '-' ? d.patientName : ''
this.patientDetailName = item.patientName !== '-' ? item.patientName : ''
this.patientDetailVisible = true
},
},
@@ -394,30 +491,58 @@ $color-border: #E5E6EB;
.order-list-container {
background-color: $color-bg-base;
min-height: 100vh;
padding-bottom: 60rpx;
height: 100%;
box-sizing: border-box;
}
.page-flex {
/* height 由行内 calc(100vh - navTopOffset) 控制,避免再叠 layout 的 paddingTop */
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
box-sizing: border-box;
}
.rx-flex-fixed {
flex-shrink: 0;
}
.order-swiper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
}
.order-scroll {
height: 100%;
box-sizing: border-box;
}
.rx-swiper-placeholder {
min-height: 200rpx;
}
/* ================= 核心:完美吸顶方案 ================= */
/* 原生 sticky 结合 Vue 计算出的精确导航栏高度(top),严丝合缝拦截在导航栏正下方 */
.native-sticky-header {
position: -webkit-sticky;
position: sticky;
z-index: 100; /* 高层级,保证滑动时覆盖在下方的卡片上 */
background-color: $color-bg-base; /* 必须有底色用于防穿透遮挡 */
position: relative;
z-index: 100;
background-color: $color-bg-base;
}
.header-solid-bg {
/* 使用 padding 将内容向内挤,防止滑动过程的透明间隙穿透问题 */
padding: 16rpx 24rpx 16rpx;
}
.tabs-wrap {
background: #fff;
border-radius: 20rpx 20rpx 0 0;
margin-bottom: 0;
overflow: hidden;
}
/* 筛选项 UI 美化 */
.quick-filter {
background: #fff;
padding: 16rpx 20rpx;
border-radius: 20rpx;
border-radius: 0 0 20rpx 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 16rpx;
}
.filter-row {
display: flex;

View File

@@ -226,6 +226,45 @@ export function mapOrderListRow(item) {
}
}
/**
* 列表卡片瘦字段:只保留 UI 需要的标量,丢弃处方 content / 明细整包,
* 避免微信小程序 setData 体积过大
* @param {Object} raw 接口订单行
* @returns {Object}
*/
export function toOrderCardRow(raw) {
if (!raw) return { _wxKey: '0', id: 0 }
const d = mapOrderListRow(raw)
const id = raw.id
const status = raw.status
let statusKey = status != null ? String(status) : 'default'
if (status === 6 || status === 7) statusKey = 'done'
else if (status === 9) statusKey = 'cancel'
const payText = (d.totalPayPrice || '').replace('¥', '').trim()
return {
_wxKey: id != null && id !== '' ? String(id) : 'ord0',
id,
status,
statusKey,
orderNo: d.orderNo,
storeName: d.storeName,
statusText: d.statusText,
productSummary: d.productSummary || '药品订单',
prescriptionTypeText: d.prescriptionTypeText,
deliveryText: d.deliveryText,
patientName: d.patientName,
userNickname: d.userNickname,
receiverName: d.receiverName,
receiverMobile: d.receiverMobile,
createdAt: d.createdAt,
totalPayPrice: d.totalPayPrice,
payAmountText: payText,
pId: d.pId,
canViewPrescription: d.canViewPrescription,
upId: d.upId,
}
}
/** 物流详情(对齐 PC express-detail/detail-by-order */
export function mapExpressDetail(data) {
if (!data || typeof data !== 'object') {

View File

@@ -30,3 +30,14 @@ export function buildOrderSaleAmountParams(opts) {
const { page, pageSize, ...rest } = opts || {}
return buildOrderListParams(rest)
}
/**
* 状态数量接口参数:与列表筛选一致,但不传 status
*/
export function buildOrderStatusCountParams(opts) {
const params = buildOrderSaleAmountParams(opts)
if (params && Object.prototype.hasOwnProperty.call(params, 'status')) {
delete params.status
}
return params
}

View File

@@ -1,10 +1,25 @@
<template>
<view>
<clinic-page-layout title="商品订单" :show-back="true">
<view class="order-list-container">
<!-- 顶部筛选 -->
<view class="native-sticky-header" :style="{ top: navTopOffset + 'px' }">
<!-- page-flex 高度=视口减导航与 layout 底部 32rpxheader 不可再设 relative top -->
<view
class="order-list-container page-flex"
:style="{ height: 'calc(100vh - ' + navTopOffset + 'px - 32rpx)' }"
>
<view class="native-sticky-header rx-flex-fixed">
<view class="header-solid-bg">
<view class="tabs-wrap">
<u-tabs
:list="statusTabsList"
:is-scroll="true"
:current="statusIndex"
active-color="#6ACDBB"
inactive-color="#86909C"
bg-color="#ffffff"
:bold="true"
@change="onStatusTabChange"
/>
</view>
<view class="quick-filter">
<view class="filter-row date-row">
<picker mode="date" :value="dateStart" @change="onStart">
@@ -23,12 +38,6 @@
<text class="filter-label">订单号</text>
<input class="filter-input" v-model="orderNo" placeholder="输入订单号" placeholder-style="color:#BDBDBD" />
</view>
<view class="filter-row">
<text class="filter-label">订单状态</text>
<picker :range="statusLabels" @change="onStatus">
<view class="filter-picker">{{ statusLabels[statusIndex] || '全部' }}</view>
</picker>
</view>
<view class="filter-row">
<text class="filter-label">发货方式</text>
<picker :range="deliveryLabels" @change="onDelivery">
@@ -45,8 +54,7 @@
</view>
</view>
<!-- 统计卡片区 -->
<scroll-view v-if="statItems.length" class="stats-scroll" scroll-x>
<scroll-view v-if="statItems.length" class="stats-scroll rx-flex-fixed" scroll-x>
<view class="stats">
<view v-for="s in statItems" :key="s.key" class="stat-card">
<text class="stat-label">{{ s.label }}</text>
@@ -55,91 +63,106 @@
</view>
</scroll-view>
<!-- 顶级悬浮卡片列表 -->
<view class="card-list-wrap">
<view
v-for="item in list"
:key="item._wxKey"
class="premium-card"
hover-class="card-hover"
:hover-stay-time="150"
@click="goDetail(item)"
<swiper
class="order-swiper"
:current="statusIndex"
:disable-touch="statusOptions.length < 2"
@change="onStatusSwiperChange"
>
<swiper-item
v-for="(cat, idx) in statusOptions"
:key="idx"
>
<!-- 顶部单号与状态 -->
<view class="card-head">
<view class="order-id">
<text class="id-icon"></text>
<text class="id-text">{{ rowDisplay(item).orderNo }}</text>
</view>
<view
class="status-badge"
:class="['status-' + (rowDisplay(item).status || 'default')]"
>
{{ rowDisplay(item).statusText }}
</view>
</view>
<view class="card-body">
<!-- 核心视觉区商品与金额 -->
<view class="main-info">
<view class="title-wrap">
<text class="goods-title">{{ rowDisplay(item).productSummary || '药品订单' }}</text>
<view class="goods-tags">
<text class="modern-tag tag-type">{{ rowDisplay(item).prescriptionTypeText }}</text>
<text class="modern-tag tag-delivery">{{ rowDisplay(item).deliveryText }}</text>
<scroll-view
scroll-y
class="order-scroll"
refresher-enabled="true"
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresherRefresh"
@scrolltolower="onScrollToLower"
>
<block v-if="statusIndex === idx">
<list-skeleton v-if="loading && !list.length" type="order" :rows="4" />
<view v-else class="card-list-wrap">
<view
v-for="item in list"
:key="item._wxKey"
class="premium-card"
hover-class="card-hover"
:hover-stay-time="150"
@click="goDetail(item)"
>
<view class="card-head">
<view class="order-id">
<text class="id-icon"></text>
<text class="id-text">{{ item.orderNo }}</text>
</view>
<view
class="status-badge"
:class="['status-' + (item.statusKey || 'default')]"
>
{{ item.statusText }}
</view>
</view>
<view class="card-body">
<view class="main-info">
<view class="title-wrap">
<text class="goods-title">{{ item.productSummary || '药品订单' }}</text>
<view class="goods-tags">
<text class="modern-tag tag-type">{{ item.prescriptionTypeText }}</text>
<text class="modern-tag tag-delivery">{{ item.deliveryText }}</text>
</view>
</view>
<view class="price-wrap">
<text class="currency">¥</text>
<text class="amount">{{ item.payAmountText }}</text>
</view>
</view>
<view class="info-island">
<view
class="island-row"
v-if="item.patientName !== '-' || item.userNickname !== '-'"
@click.stop="openPatient(item)"
>
<text class="island-label">就诊人</text>
<text class="island-value link">{{ item.patientName !== '-' ? item.patientName : item.userNickname }}</text>
</view>
<view class="island-row">
<text class="island-label">收件人</text>
<text class="island-value">
{{ item.receiverName }}
<text class="muted" v-if="item.receiverMobile"> · {{ item.receiverMobile }}</text>
</text>
</view>
<view class="island-row" v-if="item.storeName !== '-'">
<text class="island-label">开方诊所</text>
<text class="island-value">{{ item.storeName }}</text>
</view>
</view>
</view>
<view class="card-foot">
<text class="time">{{ item.createdAt }}</text>
<view class="actions">
<view
v-if="item.canViewPrescription"
class="btn-ghost"
@click.stop="openPrescription(item.pId)"
>
查看处方
</view>
</view>
</view>
</view>
</view>
<view class="price-wrap">
<text class="currency">¥</text>
<text class="amount">{{ rowDisplay(item).totalPayPrice.replace('¥', '').trim() }}</text>
<view v-if="!loading && !list.length" class="empty-state">
<text>暂无订单记录</text>
</view>
</view>
<!-- 信息岛 (Info Island)聚合次要信息的高级灰底区块 -->
<view class="info-island">
<view
class="island-row"
v-if="rowDisplay(item).patientName !== '-' || rowDisplay(item).userNickname !== '-'"
@click.stop="openPatient(item)"
>
<text class="island-label">就诊人</text>
<text class="island-value link">{{ rowDisplay(item).patientName !== '-' ? rowDisplay(item).patientName : rowDisplay(item).userNickname }}</text>
</view>
<view class="island-row">
<text class="island-label">收件人</text>
<text class="island-value">
{{ rowDisplay(item).receiverName }}
<text class="muted" v-if="rowDisplay(item).receiverMobile"> · {{ rowDisplay(item).receiverMobile }}</text>
</text>
</view>
<view class="island-row" v-if="rowDisplay(item).storeName !== '-'">
<text class="island-label">开方诊所</text>
<text class="island-value">{{ rowDisplay(item).storeName }}</text>
</view>
</view>
</view>
<!-- 底部时间与操作按键 -->
<view class="card-foot">
<text class="time">{{ rowDisplay(item).createdAt }}</text>
<view class="actions">
<view
v-if="rowDisplay(item).canViewPrescription"
class="btn-ghost"
@click.stop="openPrescription(rowDisplay(item).pId)"
>
查看处方
</view>
</view>
</view>
</view>
</view>
<view v-if="!loading && !list.length" class="empty-state">
<text>暂无订单记录</text>
</view>
</block>
<view v-else class="rx-swiper-placeholder"></view>
</scroll-view>
</swiper-item>
</swiper>
</view>
</clinic-page-layout>
<prescription-detail-popup ref="prescriptionPopup" />
<user-patient-detail
@@ -154,17 +177,22 @@
<script>
import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import PageStickyHeader from '@/subPackages/sub_clinic_admin/components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '@/subPackages/sub_clinic_admin/components/CollapsibleFilterPanel.vue'
import ListSkeleton from '@/subPackages/sub_business_shared/components/ListSkeleton.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import UserPatientDetail from '@/subPackages/sub_business_shared/components/UserPatientDetail.vue'
import { getOrderList, getOrderStatusOption, getOrderSaleAmount } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapOrderListRow } from '@/subPackages/sub_clinic_admin/common/display.js'
import {
getOrderList,
getOrderStatusOption,
getOrderSaleAmount,
getOrderStatusCount,
} from '@/api/clinicAdmin.js'
import { toOrderCardRow } from '@/subPackages/sub_clinic_admin/common/display.js'
import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import {
buildOrderListParams,
buildOrderSaleAmountParams,
buildOrderStatusCountParams,
} from '@/subPackages/sub_clinic_admin/common/orderParams.js'
import {
loadOrderFilter,
@@ -188,28 +216,45 @@ const PRESCRIPTION_TYPE_OPTIONS = [
]
export default {
components: { ClinicPageLayout, PageStickyHeader, CollapsibleFilterPanel, PrescriptionDetailPopup, UserPatientDetail },
components: {
ClinicPageLayout,
CollapsibleFilterPanel,
ListSkeleton,
PrescriptionDetailPopup,
UserPatientDetail,
},
data() {
return {
list: [],
page: 1,
pageSize: 15,
loading: false,
hasMore: true,
isRefreshing: false,
tabChanging: false,
orderNo: '',
statusOptions: [{ label: '全部', value: '' }],
statusOptions: [{ label: '全部', value: '', count: 0 }],
statusIndex: 0,
deliveryIndex: 0,
prescriptionTypeIndex: 0,
dateStart: '',
dateEnd: '',
statItems: [],
navTopOffset: 0,
patientDetailVisible: false,
patientDetailUpId: 0,
patientDetailName: '',
}
},
computed: {
statusLabels() {
return this.statusOptions.map(s => s.label || s.name || '全部')
statusTabsList() {
return (this.statusOptions || []).map((s) => {
const count = Number(s.count) > 0 ? Number(s.count) : 0
return {
name: s.label || s.name || '全部',
count,
}
}) || []
},
deliveryLabels() {
return DELIVERY_OPTIONS.map(o => o.label)
@@ -218,31 +263,36 @@ export default {
return PRESCRIPTION_TYPE_OPTIONS.map(o => o.label)
},
},
created() {
// 仅用于 page-flex 高度 calclayout 已 paddingTop勿再绑到 header 的 relative top
const sys = uni.getSystemInfoSync()
const statusBarHeight = this.$statusBarHeight || sys.statusBarHeight || 0
const navbarHeight = this.$navbarHeight || 44
this.navTopOffset = statusBarHeight + navbarHeight
},
onLoad() {
this.applyOrderFilter(loadOrderFilter())
getOrderStatusOption().then(w => {
const opts = (w.data && (w.data.items || w.data)) || []
if (Array.isArray(opts) && opts.length) {
this.statusOptions = [{ label: '全部', value: '' }].concat(opts.map(o => ({
this.statusOptions = [{ label: '全部', value: '', count: 0 }].concat(opts.map(o => ({
label: o.label || o.name,
value: o.value != null ? o.value : o.id,
count: 0,
})))
if (this.statusIndex >= this.statusOptions.length) this.statusIndex = 0
}
this.loadStatusCount()
})
this.checkDateEndAndReload()
},
/** 离开/隐藏时关掉处方抽屉,避免 page-container 残留白屏 */
onHide() {
this.closePrescriptionPopup()
},
onUnload() {
this.closePrescriptionPopup()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
/** 关闭处方抽屉 */
closePrescriptionPopup() {
const pop = this.$refs.prescriptionPopup
if (pop && typeof pop.onClose === 'function') {
@@ -303,14 +353,13 @@ export default {
dateEnd: this.dateEnd,
}
},
rowDisplay(item) {
return mapOrderListRow(item)
},
reload() {
this.persistOrderFilter()
this.page = 1
this.hasMore = true
this.load(1, false)
this.loadSaleAmount()
this.loadStatusCount()
},
loadSaleAmount() {
const params = buildOrderSaleAmountParams(this.filterValues())
@@ -326,21 +375,76 @@ export default {
]
})
},
loadStatusCount() {
const params = buildOrderStatusCountParams(this.filterValues())
getOrderStatusCount(params).then((w) => {
if (!w.ok || !w.data) return
const total = Number(w.data.total || 0)
const map = {}
const items = w.data.items || []
items.forEach((it) => {
map[Number(it.id)] = Number(it.count || 0)
})
this.statusOptions = (this.statusOptions || []).map((s, i) => {
const isAll = i === 0 || s.value === '' || s.value == null
return {
...s,
count: isAll ? total : (map[Number(s.value)] || 0),
}
})
})
},
load(page, append) {
this.loading = true
const pageSize = this.pageSize || 15
const params = buildOrderListParams({
page,
pageSize: 15,
pageSize,
...this.filterValues(),
})
getOrderList(params).then(w => {
const items = withWxKey((w.data && w.data.items) || [], 'id', 'ord')
// 只写入卡片瘦字段,避免把处方 content / 明细整包 setData
const rawItems = (w.data && w.data.items) || []
const items = rawItems.map((raw) => toOrderCardRow(raw))
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
this.hasMore = rawItems.length >= pageSize
}).finally(() => {
this.loading = false
if (this.isRefreshing) this.isRefreshing = false
})
},
onStatus(e) {
this.statusIndex = Number(e.detail.value)
onRefresherRefresh() {
this.isRefreshing = true
this.reload()
},
onScrollToLower() {
if (this.loading || !this.hasMore) return
this.load(this.page + 1, true)
},
onStatusTabChange(index) {
if (this.tabChanging) return
this.tabChanging = true
const idx = typeof index === 'object' ? Number(index.index != null ? index.index : index) : Number(index)
if (!Number.isNaN(idx) && idx !== this.statusIndex) {
this.statusIndex = idx
this.reload()
}
this.$nextTick(() => {
this.tabChanging = false
})
},
onStatusSwiperChange(e) {
if (this.tabChanging) return
this.tabChanging = true
const idx = Number((e.detail && e.detail.current) || 0)
if (idx !== this.statusIndex) {
this.statusIndex = idx
this.reload()
}
this.$nextTick(() => {
this.tabChanging = false
})
},
onDelivery(e) {
this.deliveryIndex = Number(e.detail.value)
@@ -358,14 +462,13 @@ export default {
this.$refs.prescriptionPopup.open(pId)
},
openPatient(item) {
const d = this.rowDisplay(item)
const upId = Number(d.upId || 0)
const upId = Number(item.upId || 0)
if (!upId) {
uni.showToast({ title: '缺少就诊人信息', icon: 'none' })
return
}
this.patientDetailUpId = upId
this.patientDetailName = d.patientName !== '-' ? d.patientName : ''
this.patientDetailName = item.patientName !== '-' ? item.patientName : ''
this.patientDetailVisible = true
},
},
@@ -381,16 +484,45 @@ $color-text-muted: #86909C;
.order-list-container {
background-color: $color-bg-base;
min-height: 100vh;
padding-bottom: 60rpx;
height: 100%;
box-sizing: border-box;
}
.page-flex {
/* height 由行内 calc(100vh - navTopOffset) 控制,避免再叠 layout 的 paddingTop */
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
box-sizing: border-box;
}
.rx-flex-fixed {
flex-shrink: 0;
}
.order-swiper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
}
.order-scroll {
height: 100%;
box-sizing: border-box;
}
.rx-swiper-placeholder {
min-height: 200rpx;
}
/* 筛选区 */
.tabs-wrap {
background: #fff;
border-radius: 16rpx 16rpx 0 0;
overflow: hidden;
}
.quick-filter {
background: #fff;
padding: 24rpx;
margin-bottom: 16rpx;
border-radius: 16rpx;
border-radius: 0 0 16rpx 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.filter-row {
@@ -657,54 +789,17 @@ $color-text-muted: #86909C;
}
/* ================= 核心:完美吸顶方案 ================= */
/* 原生 sticky 结合 Vue 计算出的精确导航栏高度(top),严丝合缝拦截在导航栏正下方 */
/* ================= 顶部筛选区flex 布局下不再用 sticky由 page-flex 固定) ================= */
.native-sticky-header {
position: -webkit-sticky;
position: sticky;
z-index: 100; /* 高层级,保证滑动时覆盖在下方的卡片上 */
background-color: $color-bg-base; /* 必须有底色用于防穿透遮挡 */
position: relative;
z-index: 100;
background-color: $color-bg-base;
}
.header-solid-bg {
/* 使用 padding 将内容向内挤,防止滑动过程的透明间隙穿透问题 */
padding: 16rpx 24rpx 16rpx;
}
/* 筛选项 UI 美化 */
.quick-filter {
background: #fff;
padding: 16rpx 20rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.filter-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
&:last-child { margin-bottom: 0; }
}
.date-row {
margin-bottom: 0;
justify-content: space-between;
}
.filter-label {
font-size: 28rpx;
color: $color-text-body;
min-width: 130rpx;
}
.filter-input, .filter-picker {
flex: 1;
font-size: 26rpx;
padding: 14rpx 20rpx;
background: #F7F8FA;
border-radius: 12rpx;
color: $color-text-main;
}
.date-picker { text-align: center; }
.sep { color: $color-text-muted; font-size: 24rpx; }
/* 重新设计的紧凑型查询按钮 */
.btn-query {
flex-shrink: 0;

View File

@@ -11,7 +11,7 @@
>
<view class="chinese-medicine-modal page-bg">
<view class="modal-header white">
<d-text text="选择中药" className="fs-32 font-bold color-title"></d-text>
<d-text :text="modalTitleText" className="fs-32 font-bold color-title"></d-text>
</view>
<view class="modal-content">
@@ -286,6 +286,12 @@ export default {
wayPanelDrugIndex: -1
};
},
computed: {
/** 抽屉标题选择中药N种随 selectedDrugs 动态变更 */
modalTitleText() {
return '选择中药(' + (this.selectedDrugs || []).length + '种)';
},
},
watch: {
value(newVal) {
this.show = newVal;

View File

@@ -1,5 +1,6 @@
<template>
<view class="safe-area-inset-bottom page-bg">
<!-- page-flex整页纵向 flexswiper 吃满剩余高度避免死高度裁切 -->
<view class="safe-area-inset-bottom page-bg page-flex">
<!-- #ifdef MP-WEIXIN -->
<page-container
:if="pageBackGuardShow"
@@ -8,36 +9,29 @@
/>
<!-- #endif -->
<!-- 导航栏 -->
<u-navbar class="navbar" :is-back="true" :title="navbarTitle" :custom-back="boundCustomBack" title-color="#000" background="{ background: '#fff' }">
<u-navbar class="navbar rx-flex-fixed" :is-back="true" :title="navbarTitle" :custom-back="boundCustomBack" title-color="#000" background="{ background: '#fff' }">
</u-navbar>
<!-- 处方类型Tab切换 -->
<view v-if="!isSalespersonTransferMode" class="tabs flex p-32 flex-ali-center flex-jus-sp shadow-sm" style="overflow-x: auto; position: relative; z-index: 10;">
<u-button
:throttle-time="0"
:plain="true"
:type="activeCategory === category.value ? 'success' : 'default'"
v-for="(category, index) in prescriptionCategories"
:key="category.value"
:custom-style="tabsBtnStyle"
@click="handleSwitchCategory(category.value)"
:hairline="true"
:hoverStayTime="0"
:style="{
display: (index === 0 && identity === 2) || identity === 1 ? 'block' : 'none',
marginLeft: '10rpx',
}">
{{ category.label }}
</u-button>
<!-- 处方类型 Tab与用户端订单列表一致u-tabs + 下方 swiper 双向同步 -->
<view v-if="!isSalespersonTransferMode" class="tabs-wrap shadow-sm rx-flex-fixed">
<u-tabs
:list="prescriptionTabsList"
:is-scroll="true"
:current="swiperCurrent"
active-color="#6ACDBB"
inactive-color="#606266"
:bold="true"
@change="onRxTabChange"
></u-tabs>
</view>
<view v-if="!isSalespersonTransferMode && registerModeHint" class="register-mode-hint mx-32 m-t-12">
<view v-if="!isSalespersonTransferMode && registerModeHint" class="register-mode-hint mx-32 m-t-12 rx-flex-fixed">
<text class="register-mode-hint__text">{{ registerModeHint }}</text>
</view>
<view
v-if="!isSalespersonTransferMode && hasSpecialPrescriptionRecord"
class="special-rx-card mx-32 m-t-12"
class="special-rx-card mx-32 m-t-12 rx-flex-fixed"
>
<view class="special-rx-card__inner flex-row flex-ali-center">
<u-image
@@ -87,17 +81,17 @@
</view>
</view>
<view v-if="!isSalespersonTransferMode && allowInsuranceCategory" class="mx-32 m-t-12 flex flex-ali-center">
<view v-if="!isSalespersonTransferMode && allowInsuranceCategory" class="mx-32 m-t-12 flex flex-ali-center rx-flex-fixed">
<u-button size="mini" :type="feeCategory === 1 ? 'success' : 'default'" @click="feeCategory = 1">自费</u-button>
<u-button size="mini" class="m-l-16" :type="feeCategory === 2 ? 'success' : 'default'" @click="feeCategory = 2">医保</u-button>
</view>
<view v-if="!isSalespersonTransferMode && activeCategory === 1 && hasSalespersonTransfer" class="mx-32 m-t-12">
<view v-if="!isSalespersonTransferMode && activeCategory === 1 && hasSalespersonTransfer" class="mx-32 m-t-12 rx-flex-fixed">
<u-button size="mini" type="primary" plain @click="openSalespersonTransfer">查看传方</u-button>
</view>
<!-- 诊断输入区域传方模式不展示 -->
<view v-if="!isSalespersonTransferMode" class="top white p-32 flex-col m-t-16 radius-12 mx-32">
<view v-if="!isSalespersonTransferMode" class="top white p-32 flex-col m-t-16 radius-12 mx-32 rx-flex-fixed">
<view class="top_title flex-row flex-ali-center">
<image :src="require('@/static/image/js.png')" class="size-32"></image>
<d-text text="临床诊断" className="fs-32 m-l-16 font-bold" color="#333"></d-text>
@@ -124,8 +118,19 @@
</view>
</view>
<!-- 滚动内容区域 -->
<scroll-view :style="scrollStyle" scroll-y="true">
<!-- 滚动内容swiper flex:1 吃满剩余高度对齐订单列表 order-swiper不算死高度 -->
<swiper
class="rx-order-swiper"
:current="swiperCurrent"
:disable-touch="isSalespersonTransferMode || swiperCategories.length < 2"
@change="onRxSwiperChange"
>
<swiper-item
v-for="(cat, idx) in swiperCategories"
:key="cat.value"
>
<scroll-view scroll-y class="rx-order-scroll">
<block v-if="swiperCurrent === idx">
<!-- 在线复诊 + 中药证候/治法/中医疾病接口 diseases_idmethod_idsyndrome_id PC 在线问诊处方一致 -->
<view v-if="activeCategory === 1 && isOnlineRevisit" class="white p-32 radius-12 mx-32 m-t-24 flex-col">
@@ -249,7 +254,7 @@
<view class="flex-row flex-jus-sp flex-ali-center m-b-24">
<view class="flex-row flex-ali-center">
<view class="rp-icon">Rp</view>
<text class="fs-28 text-gray m-l-8">中药处方</text>
<text class="fs-28 text-gray m-l-8">中药处方已选 {{ currentDrugs.length }} </text>
</view>
<view class="flex-row" style="height: 50rpx;">
<u-button
@@ -280,7 +285,7 @@
<view class="white radius-12 p-32" v-if="currentDrugs.length > 0">
<view class="flex-row flex-jus-sp flex-ali-center m-b-24">
<view class="flex-col">
<text class="fs-28 font-bold color-title">药材清单 ({{currentDrugs.length}})</text>
<text class="fs-28 font-bold color-title">药材清单{{currentDrugs.length}}</text>
<text
v-if="!isSpecialPrescriptionCartLocked"
class="fs-22 text-gray m-t-4"
@@ -328,7 +333,8 @@
<view class="flex-row flex-jus-sp flex-ali-center m-b-24">
<view class="flex-row flex-ali-center">
<view class="rp-icon">Rp</view>
<text class="fs-28 text-gray m-l-8">产品/器械</text>
<!-- 与当前 Tab 名一致避免 3/5/6/7 共用模板时写死产品/器械 -->
<text class="fs-28 text-gray m-l-8">{{ activeCategoryLabel }}</text>
</view>
<view class="flex-row m-l-163" style="height: 50rpx;">
<view v-if="!isSpecialPrescriptionCartLocked" class="m-l-16">
@@ -482,7 +488,11 @@
<u-icon name="shield-check" color="#B0B6C2" size="28" class="m-r-8"></u-icon>
<text class="fs-24 color-sub">请确认患者已在实体医院就诊并有明确诊断</text>
</view>
</scroll-view>
</block>
<view v-else class="rx-swiper-placeholder"></view>
</scroll-view>
</swiper-item>
</swiper>
<!-- 底部操作栏 -->
<view class="bottom flex-jus-sp flex-ali-center safe-area-inset-bottom">
@@ -632,6 +642,7 @@ import {
saveWestCommonPrescriptionApi,
saveChineseCommonPrescriptionApi,
getCurrentStoreTypeApi,
getPrescriptionTypeOptionsApi,
getPriceAdjustConfigApi,
getSalespersonTransferByRegisterApi,
getPrescriptionInfoApi,
@@ -711,13 +722,19 @@ export default {
pendingReusePrescriptionId: '',
patientInfo: null,
prescriptionCategories: [
{ label: '中药', value: 1 },
{ label: '西(中成)药', value: 2 },
{ label: '保健食品', value: 3 },
{ label: '产品服务包', value: 5 },
{ label: '非药品', value: 6 },
{ label: '医疗器械', value: 7 }
{ label: '中药', value: 1, icon: '', icon_text: '' },
{ label: '西(中成)药', value: 2, icon: '', icon_text: '' },
{ label: '保健食品', value: 3, icon: '', icon_text: '' },
{ label: '产品服务包', value: 5, icon: '', icon_text: '' },
{ label: '非药品', value: 6, icon: '', icon_text: '' },
{ label: '医疗器械', value: 7, icon: '', icon_text: '' }
],
/** 后端下发的默认处方类型;无本地草稿/锁定时使用 */
prescriptionTypeDefault: 2,
/** swiper / u-tabs 当前下标;默认与 activeCategory=2西药列表第 2 项)对齐,避免首屏 Tab/内容错位 */
swiperCurrent: 1,
/** 防止 tabs 与 swiper 互相触发形成回路(同订单列表) */
tabChanging: false,
activeCategory: 2,
currentDrugs: [],
diagnoses: [],
@@ -848,6 +865,39 @@ export default {
};
},
computed: {
/** 可见处方类型identity=2 仅中药;其余展示后端下发的全部 */
visiblePrescriptionCategories() {
const list = Array.isArray(this.prescriptionCategories) ? this.prescriptionCategories : [];
if (Number(this.identity) === 2) {
return list.filter((c) => Number(c.value) === 1);
}
return list;
},
/** swiper 用分类:传方模式仅中药一项 */
swiperCategories() {
if (this.isSalespersonTransferMode) {
return [{ label: '中药', value: 1, icon: '', icon_text: '' }];
}
return this.visiblePrescriptionCategories;
},
/** u-tabs 需要 { name };有 icon_text 时拼到 name 后便于看见角标;始终返回数组避免 list 非数组警告 */
prescriptionTabsList() {
const list = this.visiblePrescriptionCategories || [];
return list.map((c) => {
let name = c.label || '';
if (c.icon_text) name = `${name} ${c.icon_text}`;
return { name, value: c.value, icon: c.icon || '' };
}) || [];
},
/**
* 当前处方类型显示名:简单产品列表等共用区块按 Tab label 展示,
* 避免写死「产品/器械」导致保健食品等类型文案不对
*/
activeCategoryLabel() {
const list = this.visiblePrescriptionCategories || this.prescriptionCategories || [];
const hit = list.find((c) => Number(c.value) === Number(this.activeCategory));
return (hit && hit.label) || '产品';
},
allowInsuranceCategory() {
return Number(this.registerStoreInfo?.allow_insurance_category ?? 0) === 1;
},
@@ -965,25 +1015,6 @@ export default {
shouldEnablePageBackGuard() {
return this.hasPrescriptionOverlay;
},
scrollStyle() {
try {
const sys = uni.getSystemInfoSync();
const rpxRatio = sys.windowWidth / 750;
const topRpx = this.isSalespersonTransferMode ? 500 : 600;
const bottomRpx = 200;
const heightPx = sys.windowHeight - topRpx * rpxRatio - bottomRpx * rpxRatio;
return {
height: `${Math.max(Math.floor(heightPx), 200)}px`,
marginBottom: '200rpx',
};
} catch (e) {
const topRpx = this.isSalespersonTransferMode ? 500 : 600;
return {
height: `calc(100vh - ${topRpx}rpx)`,
marginBottom: '200rpx',
};
}
},
onlineTcmDiseasesLabel() {
const data = this.traditionalTcmData;
const id = this.onlineTcmDiseasesId;
@@ -1038,6 +1069,8 @@ export default {
created() {
// u-navbar 在小程序内 bind($parent) 可能拿不到页面实例,用箭头函数固定 this
this.boundCustomBack = () => this.handleCustomBack();
// 按默认 activeCategory 立刻对齐 swiper 下标,避免首屏 Tab 在中药、内容按西药渲染
this.syncSwiperCurrentFromCategory();
},
onLoad(options) {
this.isSalespersonTransferMode = String(options.salesperson_transfer) === '1';
@@ -1114,11 +1147,13 @@ export default {
await this.loadPatientInfo();
await this.loadRegisterOrderType();
await this.loadBasicConfigData();
await this.loadPrescriptionTypeOptions();
await this.loadRegisterStoreInfo();
// 无 URL/上次切换/草稿时,按诊所类型兜底中药或西药 Tab
this.applyDefaultCategoryByClinicType();
// 无 URL/上次切换/草稿时,用后端 default 兜底
this.applyDefaultCategoryFromOptions();
}
this.loadCurrentCategoryDrugs();
this.syncSwiperCurrentFromCategory();
if (this.activeCategory === 1 && this.chineseConfig.ruleType === 2) {
await this.hydrateChineseProcessRuleListsFromConfig();
}
@@ -1177,6 +1212,101 @@ export default {
};
}
},
/**
* 拉取后端处方类型列表与默认选中;失败时保留本地兜底列表
*/
async loadPrescriptionTypeOptions() {
try {
const params = {};
if (this.registerId) params.register_id = this.registerId;
const res = await getPrescriptionTypeOptionsApi(params);
// req 已解包为业务 data{ default, list }
const data = res || {};
const list = Array.isArray(data.list) ? data.list : [];
if (list.length) {
this.prescriptionCategories = list.map((item) => ({
value: Number(item.value),
label: item.label || '',
icon: item.icon || '',
icon_text: item.icon_text || '',
}));
}
const def = Number(data.default);
if (def) this.prescriptionTypeDefault = def;
} catch (e) {
console.error('加载处方类型失败:', e);
}
},
/**
* 开方默认 Tab 兜底:仅当无 URL 锁定、无上次切换分类、无任何药品草稿时,
* 使用后端下发的 default。优先级URL > 上次切换 > 草稿 > 接口 default
*/
applyDefaultCategoryFromOptions() {
if (this.isSalespersonTransferMode || this._categoryLockedByQuery) return;
if (!this.registerId) return;
if (PrescriptionStorage.getActiveCategory(this.registerId)) return;
if (PrescriptionStorage.hasDraftWithDrugs(this.registerId)) return;
const next = Number(this.prescriptionTypeDefault) || 0;
if (!next || next === this.activeCategory) return;
this.activeCategory = next;
PrescriptionStorage.saveActiveCategory(next, this.registerId);
this.focusActiveTab();
},
/**
* 开方默认 Tab 兜底:仅当无 URL 锁定、无上次切换分类、无任何药品草稿时,
* 按诊所类型设中药(1)/西药(2)。优先级URL > 上次切换 > 草稿 > clinic_type
* @deprecated 已由 applyDefaultCategoryFromOptions 替代,保留兼容
*/
applyDefaultCategoryByClinicType() {
this.applyDefaultCategoryFromOptions();
},
/** 将当前 Tab 滚入可视区u-tabs is-scroll 已自动聚焦,此处同步 swiper 下标) */
focusActiveTab() {
this.syncSwiperCurrentFromCategory();
},
/** 根据 activeCategory 同步 swiperCurrent */
syncSwiperCurrentFromCategory() {
const list = this.swiperCategories || [];
const idx = list.findIndex((c) => Number(c.value) === Number(this.activeCategory));
const next = idx >= 0 ? idx : 0;
if (next === this.swiperCurrent) return;
// 程序化改 current 会触发 swiper @change用 tabChanging 挡住回路
this.tabChanging = true;
this.swiperCurrent = next;
this.$nextTick(() => {
this.tabChanging = false;
});
},
/** Tab 点击切换(与订单列表 onTabChange 一致) */
onRxTabChange(index) {
if (this.tabChanging) return;
this.tabChanging = true;
const idx = typeof index === 'object' ? Number(index.index ?? index) : Number(index);
const list = this.swiperCategories || [];
const cat = list[idx];
if (cat) {
this.swiperCurrent = idx;
this.handleSwitchCategory(cat.value, { skipFocus: true });
}
this.$nextTick(() => {
this.tabChanging = false;
});
},
/** swiper 滑动切换 */
onRxSwiperChange(e) {
if (this.tabChanging) return;
this.tabChanging = true;
const idx = Number((e.detail && e.detail.current) || 0);
const list = this.swiperCategories || [];
const cat = list[idx];
if (cat) {
this.swiperCurrent = idx;
this.handleSwitchCategory(cat.value, { skipFocus: true });
}
this.$nextTick(() => {
this.tabChanging = false;
});
},
restoreFromLocalStorage() {
if (this._presetActiveCategory !== null && this._presetActiveCategory !== undefined && !Number.isNaN(this._presetActiveCategory)) {
this.activeCategory = this._presetActiveCategory;
@@ -1198,28 +1328,6 @@ export default {
}
}
},
/**
* 开方默认 Tab 兜底:仅当无 URL 锁定、无上次切换分类、无任何药品草稿时,
* 按诊所类型设中药(1)/西药(2)。优先级URL > 上次切换 > 草稿 > clinic_type
*/
applyDefaultCategoryByClinicType() {
if (this.isSalespersonTransferMode || this._categoryLockedByQuery) return;
if (!this.registerId) return;
// 最后切换的分类优先,不再用诊所类型覆盖
if (PrescriptionStorage.getActiveCategory(this.registerId)) return;
// 任一类有草稿则沿用 restore 结果
if (PrescriptionStorage.hasDraftWithDrugs(this.registerId)) return;
const clinicType = Number(this.registerStoreInfo?.clinic_type ?? 0);
let next = 0;
if (clinicType === 2) {
next = 1; // 中医诊所 → 中药
} else if (clinicType === 1) {
next = 2; // 西医诊所 → 西(中成)药
}
if (!next || next === this.activeCategory) return;
this.activeCategory = next;
PrescriptionStorage.saveActiveCategory(next, this.registerId);
},
loadCurrentCategoryDrugs() {
const data = PrescriptionStorage.loadPrescriptionData(this.activeCategory, this.registerId);
if (data) {
@@ -1501,10 +1609,15 @@ export default {
this.$toast('加载传方失败');
}
},
async handleSwitchCategory(category) {
async handleSwitchCategory(category, options = {}) {
this.saveToLocalStorage();
this.activeCategory = category;
this.loadCurrentCategoryDrugs();
if (!options.skipFocus) {
this.focusActiveTab();
} else {
this.syncSwiperCurrentFromCategory();
}
if (category === 1) {
await this.checkSalespersonTransfer();
await this.checkAndShowTransferTip();
@@ -2736,7 +2849,17 @@ export default {
/* 基础与通用样式 */
.page-bg {
background-color: #F4F6F8;
min-height: 100vh;
}
/* 整页纵向 flex顶部 chrome 不收缩swiper 吃满剩余高度 */
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
}
.rx-flex-fixed {
flex-shrink: 0;
}
.safe-area-inset-bottom { padding-bottom: env(safe-area-inset-bottom); }
.mx-32 { margin-left: 32rpx; margin-right: 32rpx; }
@@ -2812,9 +2935,26 @@ export default {
border-radius: 6rpx 2rpx 6rpx 2rpx;
}
.tabs {
.tabs-wrap {
background: #fff;
border-bottom: 1px solid #F0F2F5;
position: relative;
z-index: 10;
}
.rx-order-swiper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
}
/* 内部滚动区铺满 swiper底部留白避开 fixed 操作栏 */
.rx-order-scroll {
height: 100%;
box-sizing: border-box;
padding-bottom: calc(200rpx + env(safe-area-inset-bottom));
}
.rx-swiper-placeholder {
min-height: 400rpx;
}
/* 现代卡片设计 (西药 / 产品) */

View File

@@ -37,7 +37,6 @@ export class PrescriptionStorage {
try {
const key = this.getStorageKey(category, registerId);
uni.setStorageSync(key, JSON.stringify(data));
console.log('保存处方数据成功:', key, data);
} catch (error) {
console.error('保存处方数据失败:', error);
}