Files
xk-doctor-wx/subPackages/sub_clinic_admin/order/index.vue

818 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view>
<clinic-page-layout title="商品订单" :show-back="true">
<!-- 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">
<view class="filter-picker date-picker">{{ dateStart || '开始日期' }}</view>
</picker>
<text class="sep"></text>
<picker mode="date" :value="dateEnd" @change="onEnd">
<view class="filter-picker date-picker">{{ dateEnd || '结束日期' }}</view>
</picker>
<view class="btn-query" @click="reload">查询</view>
</view>
</view>
<collapsible-filter-panel title="订单筛选" @clear="resetFilter">
<view class="filter-row">
<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="deliveryLabels" @change="onDelivery">
<view class="filter-picker">{{ deliveryLabels[deliveryIndex] }}</view>
</picker>
</view>
<view class="filter-row">
<text class="filter-label">订单类型</text>
<picker :range="prescriptionTypeLabels" @change="onPrescriptionType">
<view class="filter-picker">{{ prescriptionTypeLabels[prescriptionTypeIndex] }}</view>
</picker>
</view>
</collapsible-filter-panel>
</view>
</view>
<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>
<text class="stat-value">{{ s.value }}</text>
</view>
</view>
</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"
>
<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 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
v-if="patientDetailVisible"
:visible="patientDetailVisible"
:up-id="patientDetailUpId"
:patient-name="patientDetailName"
@close="patientDetailVisible = false"
/>
</view>
</template>
<script>
import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.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,
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,
saveOrderFilter,
clearOrderFilter,
defaultOrderFilter,
} from '@/subPackages/sub_clinic_admin/common/filterCache.js'
import { defaultTodayDate } from '@/subPackages/sub_clinic_admin/common/reconciliationParams.js'
const DELIVERY_OPTIONS = [
{ label: '全部', value: '' },
{ label: '快递到家', value: 0 },
{ label: '诊所自提', value: 1 },
]
const PRESCRIPTION_TYPE_OPTIONS = [
{ label: '全部', value: '' },
{ label: '中药', value: 1 },
{ label: '西药', value: 2 },
{ label: '中成药', value: 3 },
{ label: '产品服务包', value: 5 },
]
export default {
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: '', count: 0 }],
statusIndex: 0,
deliveryIndex: 0,
prescriptionTypeIndex: 0,
dateStart: '',
dateEnd: '',
statItems: [],
navTopOffset: 0,
patientDetailVisible: false,
patientDetailUpId: 0,
patientDetailName: '',
}
},
computed: {
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)
},
prescriptionTypeLabels() {
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: '', 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()
},
onHide() {
this.closePrescriptionPopup()
},
onUnload() {
this.closePrescriptionPopup()
},
methods: {
closePrescriptionPopup() {
const pop = this.$refs.prescriptionPopup
if (pop && typeof pop.onClose === 'function') {
pop.onClose()
}
},
applyOrderFilter(f) {
this.orderNo = f.orderNo
this.statusIndex = f.statusIndex
this.deliveryIndex = f.deliveryIndex
this.prescriptionTypeIndex = f.prescriptionTypeIndex
this.dateStart = f.dateStart
this.dateEnd = f.dateEnd
},
persistOrderFilter() {
saveOrderFilter({
orderNo: this.orderNo,
statusIndex: this.statusIndex,
deliveryIndex: this.deliveryIndex,
prescriptionTypeIndex: this.prescriptionTypeIndex,
dateStart: this.dateStart,
dateEnd: this.dateEnd,
})
},
resetFilter() {
this.applyOrderFilter(defaultOrderFilter())
clearOrderFilter()
this.reload()
},
checkDateEndAndReload() {
const today = defaultTodayDate()
if (this.dateEnd && this.dateEnd !== today) {
uni.showModal({
title: '提示',
content: '筛选结束日期不是今天,是否更新到今天?',
success: (r) => {
if (r.confirm) {
this.dateEnd = today
this.persistOrderFilter()
}
this.reload()
},
})
return
}
this.reload()
},
filterValues() {
const st = this.statusOptions[this.statusIndex]
const del = DELIVERY_OPTIONS[this.deliveryIndex]
const pt = PRESCRIPTION_TYPE_OPTIONS[this.prescriptionTypeIndex]
return {
orderNo: this.orderNo.trim(),
status: st && st.value !== '' && st.value != null ? st.value : undefined,
deliveryMethod: del.value !== '' ? del.value : undefined,
prescriptionType: pt.value !== '' ? pt.value : undefined,
dateStart: this.dateStart,
dateEnd: this.dateEnd,
}
},
reload() {
this.persistOrderFilter()
this.page = 1
this.hasMore = true
this.load(1, false)
this.loadSaleAmount()
this.loadStatusCount()
},
loadSaleAmount() {
const params = buildOrderSaleAmountParams(this.filterValues())
getOrderSaleAmount(params).then(w => {
if (!w.ok || !w.data) {
this.statItems = []
return
}
const d = w.data
this.statItems = [
{ key: 'total', label: '销售金额', value: formatMoney(d.total) },
{ key: 'income', label: '我的收益', value: formatMoney(d.income) },
]
})
},
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,
...this.filterValues(),
})
getOrderList(params).then(w => {
// 只写入卡片瘦字段,避免把处方 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
this.hasMore = rawItems.length >= pageSize
}).finally(() => {
this.loading = false
if (this.isRefreshing) this.isRefreshing = false
})
},
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)
},
onPrescriptionType(e) {
this.prescriptionTypeIndex = Number(e.detail.value)
},
onStart(e) { this.dateStart = e.detail.value },
onEnd(e) { this.dateEnd = e.detail.value },
goDetail(item) {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/order/detail?id=' + item.id })
},
openPrescription(pId) {
if (!pId) return
this.$refs.prescriptionPopup.open(pId)
},
openPatient(item) {
const upId = Number(item.upId || 0)
if (!upId) {
uni.showToast({ title: '缺少就诊人信息', icon: 'none' })
return
}
this.patientDetailUpId = upId
this.patientDetailName = item.patientName !== '-' ? item.patientName : ''
this.patientDetailVisible = true
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F2F4F7; /* Apple 风格的冷调高级灰底色 */
$color-text-main: #1D2129;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
.order-list-container {
background-color: $color-bg-base;
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: 0 0 16rpx 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.filter-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 24rpx;
}
.filter-label {
font-size: 28rpx;
color: $color-text-body;
min-width: 140rpx;
}
.filter-input, .filter-picker {
flex: 1;
font-size: 28rpx;
padding: 16rpx 20rpx;
background: #F7F8FA;
border-radius: 12rpx;
color: $color-text-main;
}
.date-row { flex-wrap: nowrap; }
.date-picker { text-align: center; }
.sep { color: $color-text-muted; margin: 0 8rpx; font-size: 24rpx; }
.filter-query-btn {
text-align: center;
padding: 24rpx;
background: $theme-primary;
color: #fff;
border-radius: 16rpx;
font-size: 30rpx;
font-weight: 600;
margin-top: 32rpx;
box-shadow: 0 8rpx 24rpx rgba(106, 205, 187, 0.2);
}
/* 统计卡片 */
.stats-scroll {
margin-bottom: 20rpx;
white-space: nowrap;
}
.stats {
display: inline-flex;
gap: 20rpx;
padding: 16rpx 32rpx;
}
.stat-card {
display: inline-block;
width: 320rpx;
background: #fff;
padding: 32rpx;
border-radius: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
vertical-align: top;
white-space: normal;
}
.stat-label {
display: block;
font-size: 26rpx;
color: $color-text-muted;
}
.stat-value {
display: block;
font-size: 42rpx;
color: $color-text-main;
font-weight: 700;
margin-top: 12rpx;
}
/* ================= 核心高级卡片样式 ================= */
.card-list-wrap {
//padding: 0 24rpx;
}
.premium-card {
background: #ffffff;
border-radius: 32rpx; /* 超大圆角带来前沿视觉享受 */
margin-bottom: 24rpx;
padding: 32rpx;
border: 1rpx solid #E5E6EB;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.015); /* 极其克制的阴影 */
transition: all 0.2s ease;
}
.card-hover {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.01);
background-color: #FAFAFA;
}
/* 卡片头部 */
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28rpx;
}
.order-id {
display: flex;
align-items: center;
gap: 12rpx;
}
.id-icon {
font-size: 20rpx;
color: #fff;
background-color: #C9CDD4;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-weight: 600;
}
.id-text {
font-size: 26rpx;
color: $color-text-muted;
font-family: SF Pro Text, monospace, sans-serif;
letter-spacing: 0.5rpx;
}
/* 状态标签配色优化 */
.status-badge {
font-size: 26rpx;
font-weight: 600;
color: $theme-primary;
}
/* 可根据实际的状态值(status)配置置灰 */
.status-done { color: $color-text-muted; font-weight: 400; }
.status-cancel { color: #F53F3F; }
/* 核心内容区 */
.card-body {
margin-bottom: 24rpx;
}
.main-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24rpx;
gap: 20rpx;
}
.title-wrap {
flex: 1;
}
.goods-title {
font-size: 32rpx;
font-weight: 600;
color: $color-text-main;
line-height: 1.4;
margin-bottom: 16rpx;
/* 多行截断 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.goods-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.modern-tag {
font-size: 22rpx;
padding: 4rpx 14rpx;
border-radius: 8rpx;
font-weight: 500;
}
.tag-type { background: #FFF3E8; color: #FF7D00; } /* 柔和爱马仕橙 */
.tag-delivery { background: #E8F3FF; color: #165DFF; } /* 科技蓝 */
.price-wrap {
display: flex;
align-items: baseline;
color: $color-text-main;
}
.currency {
font-size: 28rpx;
font-weight: 600;
margin-right: 4rpx;
}
.amount {
font-size: 44rpx;
font-weight: 700;
font-family: DIN Alternate, SF Pro Display, sans-serif;
}
/* 高级信息岛 (Inset Info Box) */
.info-island {
background-color: #F7F8FA;
border-radius: 16rpx;
padding: 24rpx;
}
.island-row {
display: flex;
font-size: 24rpx;
line-height: 1.6;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
.island-label {
color: $color-text-muted;
width: 110rpx;
flex-shrink: 0;
}
.island-value {
color: $color-text-body;
flex: 1;
word-break: break-all;
&.link {
color: $theme-primary;
}
}
.muted {
color: $color-text-muted;
}
}
/* 卡片底部 */
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1rpx solid #F2F3F5;
padding-top: 24rpx;
}
.time {
font-size: 24rpx;
color: $color-text-muted;
}
.btn-ghost {
font-size: 26rpx;
color: $theme-primary;
background: rgba(106, 205, 187, 0.08);
padding: 10rpx 32rpx;
border-radius: 30rpx;
font-weight: 500;
transition: background-color 0.2s;
&:active {
background: rgba(106, 205, 187, 0.15);
}
}
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 120rpx 0;
font-size: 28rpx;
}
/* ================= 顶部筛选区flex 布局下不再用 sticky由 page-flex 固定) ================= */
.native-sticky-header {
position: relative;
z-index: 100;
background-color: $color-bg-base;
}
.header-solid-bg {
padding: 16rpx 24rpx 16rpx;
}
/* 重新设计的紧凑型查询按钮 */
.btn-query {
flex-shrink: 0;
background: $theme-primary;
color: #fff;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx; /* 胶囊圆角 */
font-size: 26rpx;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(106, 205, 187, 0.3);
transition: opacity 0.2s;
&:active { opacity: 0.8; }
}
</style>