Files
xk-doctor-wx/subPackages/sub_business_shared/reconciliation/index.vue
2026-06-05 12:28:36 +08:00

802 lines
22 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>
<business-page-layout title="对账单" :show-back="true">
<view class="reconciliation-container">
<!-- 顶部吸顶区Tabs 时间快筛 -->
<page-sticky-header>
<view class="header-section">
<view class="tabs-wrap">
<u-tabs
:list="tabList"
:is-scroll="false"
:current="tabIndex"
active-color="#6ACDBB"
inactive-color="#86909C"
bg-color="transparent"
:bold="true"
@change="onTabChange"
/>
</view>
<view class="quick-filter">
<view class="date-range-picker">
<picker mode="date" :value="dateStart" @change="onStart">
<text class="date-text" :class="{'placeholder': !dateStart}">{{ dateStart || '开始日期' }}</text>
</picker>
<text class="sep"></text>
<picker mode="date" :value="dateEnd" @change="onEnd">
<text class="date-text" :class="{'placeholder': !dateEnd}">{{ dateEnd || '结束日期' }}</text>
</picker>
</view>
<view class="filter-actions">
<text class="btn-clear" @click="resetFilter">清空</text>
<text class="btn-search" @click="reload">查询</text>
</view>
</view>
</view>
<!-- 药品折叠筛选面板 (保持逻辑不变美化表单) -->
<collapsible-filter-panel v-if="tab === 'drug'" title="药品高级筛选" @clear="resetFilter">
<view class="filter-row switch-row">
<view class="filter-label-group">
<text class="filter-label">按订单维度拆分</text>
<text class="filter-hint">未勾选时按药品+供货价合并</text>
</view>
<switch :checked="byOrder" color="#6acdbb" style="transform:scale(0.85)" @change="onByOrderChange" />
</view>
<view class="filter-field">
<text class="filter-label">药品</text>
<view class="search-input-group">
<input
class="filter-input"
v-model="drugSearchQ"
placeholder="输入名称或拼音搜索"
placeholder-style="color:#BDBDBD"
confirm-type="search"
@confirm="searchDrugOptions"
/>
<text class="filter-link" @click="searchDrugOptions">搜索</text>
</view>
</view>
<view class="picker-wrap" v-if="drugOptionLabels.length">
<picker :range="drugOptionLabels" @change="onDrugPick">
<view class="filter-picker">{{ selectedDrugLabel || '点击选择匹配的药品' }}</view>
</picker>
<text v-if="drugId" class="filter-clear" @click="clearDrug">清除选择</text>
</view>
<view class="filter-field">
<text class="filter-label">订单</text>
<view class="search-input-group">
<input
class="filter-input"
v-model="orderSearchQ"
placeholder="输入订单号搜索"
placeholder-style="color:#BDBDBD"
confirm-type="search"
@confirm="searchOrderOptions"
/>
<text class="filter-link" @click="searchOrderOptions">搜索</text>
</view>
</view>
<view class="picker-wrap" v-if="orderOptionLabels.length">
<picker :range="orderOptionLabels" @change="onOrderPick">
<view class="filter-picker">{{ selectedOrderLabel || '点击选择匹配的订单' }}</view>
</picker>
<text v-if="orderId" class="filter-clear" @click="clearOrder">清除选择</text>
</view>
<view class="filter-query-btn" @click="reload">确认筛选</view>
</collapsible-filter-panel>
</page-sticky-header>
<!-- 统计卡片区取消横向滑动改为直观的网格平铺 -->
<view v-if="statItems.length" class="stats-grid-wrap">
<view class="stats-grid">
<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>
</view>
<!-- 数据列表区 -->
<view class="list-wrap">
<view v-for="item in list" :key="item._wxKey" class="premium-card">
<!-- ================= 诊所对账模式 ================= -->
<template v-if="tab === 'store'">
<view class="card-head">
<text class="card-title">{{ rowStore(item).name }}</text>
<text v-if="rowStore(item).registrationPrice !== '¥0.00'" class="mini-tag">挂号费 {{ rowStore(item).registrationPrice }}</text>
</view>
<!-- 核心摘要 -->
<view class="hero-data">
<view class="data-box">
<text class="data-label">总销售额</text>
<text class="data-val text-primary">{{ rowStore(item).totalSales }}</text>
</view>
<view class="data-divider"></view>
<view class="data-box">
<text class="data-label">总供货额</text>
<text class="data-val">{{ rowStore(item).totalSupply }}</text>
</view>
</view>
<!-- 2x2 仪表盘网格完美重构四大类费用 -->
<view class="dashboard-grid">
<!-- 草药 -->
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">🌿 草药</text>
<text class="island-sales">销量 {{ rowStore(item).herbal.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ rowStore(item).herbal.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ rowStore(item).herbal.supplyPrice }}</text></view>
</view>
<!-- 西药 -->
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">💊 西药</text>
<text class="island-sales">销量 {{ rowStore(item).medicine.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ rowStore(item).medicine.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ rowStore(item).medicine.supplyPrice }}</text></view>
</view>
<!-- 服务包 -->
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">📦 服务包</text>
<text class="island-sales">销量 {{ rowStore(item).servicePackage.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ rowStore(item).servicePackage.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ rowStore(item).servicePackage.supplyPrice }}</text></view>
</view>
<!-- 其他费用 -->
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">🔖 其他费用</text>
</view>
<view class="island-line"><text>诊疗费</text><text>{{ rowStore(item).otherFees.treatment }}</text></view>
<view class="island-line"><text>加工费</text><text>{{ rowStore(item).otherFees.process }}</text></view>
<view class="island-line"><text>快递费</text><text>{{ rowStore(item).otherFees.express }}</text></view>
</view>
</view>
</template>
<!-- ================= 药品对账模式 ================= -->
<template v-else>
<view class="card-head">
<text class="card-title">{{ rowDrug(item).drugName }}</text>
<view class="drug-meta" v-if="rowDrug(item).drugNumber">
<text class="mini-tag">编号 {{ rowDrug(item).drugNumber }}</text>
<text class="mini-tag gray">{{ rowDrug(item).typeText }}</text>
</view>
</view>
<view class="info-island">
<view class="island-row">
<text class="island-label">销量</text>
<text class="island-value text-primary font-bold">{{ rowDrug(item).number }}</text>
</view>
<view class="island-row">
<text class="island-label">供货单价</text>
<text class="island-value">{{ rowDrug(item).marketPrice }}</text>
</view>
<view class="island-row">
<text class="island-label">总销售额</text>
<text class="island-value">{{ rowDrug(item).totalSales }}</text>
</view>
<view class="island-row">
<text class="island-label">总供货额</text>
<text class="island-value">{{ rowDrug(item).totalSupply }}</text>
</view>
<view class="island-row" v-if="rowDrug(item).orderNo">
<text class="island-label">订单号</text>
<text class="island-value font-mono">{{ rowDrug(item).orderNo }}</text>
</view>
</view>
</template>
</view>
</view>
<view v-if="!loading && !list.length" class="empty-state">
<text>暂无对账数据</text>
</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import PageStickyHeader from '@/subPackages/sub_business_shared/components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '@/subPackages/sub_business_shared/components/CollapsibleFilterPanel.vue'
import { withWxKey } from '@/utils/wxListKey.js'
import {
mapReconciliationStoreRow,
mapReconciliationDrugRow,
mapReconciliationStoreCount,
mapReconciliationDrugCount,
} from '@/subPackages/sub_business_shared/common/display.js'
import {
buildStoreListParams,
buildDrugListParams,
buildSearchTimeQueryParams,
normalizeReconciliationResponse,
} from '@/subPackages/sub_business_shared/common/reconciliationParams.js'
import {
loadReconciliationFilter,
saveReconciliationFilter,
clearReconciliationFilter,
defaultReconciliationFilter,
} from '@/subPackages/sub_business_shared/common/filterCache.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, PageStickyHeader, CollapsibleFilterPanel },
data() {
return {
tab: 'store',
tabIndex: 0,
tabList: [{ name: '诊所对账' }, { name: '药品对账' }],
dateStart: '',
dateEnd: '',
list: [],
loading: false,
statItems: [],
byOrder: false,
drugSearchQ: '',
orderSearchQ: '',
drugOptions: [],
orderOptions: [],
drugId: null,
orderId: null,
selectedDrugLabel: '',
selectedOrderLabel: '',
}
},
computed: {
drugOptionLabels() {
return this.drugOptions.map(o => o.label)
},
orderOptionLabels() {
return this.orderOptions.map(o => o.label)
},
},
onLoad() {
this.applyReconciliationFilter(loadReconciliationFilter())
this.reload()
},
methods: {
applyReconciliationFilter(f) {
this.tabIndex = f.tabIndex
this.tab = f.tabIndex === 1 ? 'drug' : 'store'
this.dateStart = f.dateStart
this.dateEnd = f.dateEnd
this.byOrder = f.byOrder
this.drugSearchQ = f.drugSearchQ
this.orderSearchQ = f.orderSearchQ
this.drugId = f.drugId
this.orderId = f.orderId
this.selectedDrugLabel = f.selectedDrugLabel
this.selectedOrderLabel = f.selectedOrderLabel
this.drugOptions = []
this.orderOptions = []
},
persistReconciliationFilter() {
saveReconciliationFilter({
tabIndex: this.tabIndex,
dateStart: this.dateStart,
dateEnd: this.dateEnd,
byOrder: this.byOrder,
drugSearchQ: this.drugSearchQ,
orderSearchQ: this.orderSearchQ,
drugId: this.drugId,
orderId: this.orderId,
selectedDrugLabel: this.selectedDrugLabel,
selectedOrderLabel: this.selectedOrderLabel,
})
},
resetFilter() {
this.applyReconciliationFilter(defaultReconciliationFilter())
clearReconciliationFilter()
this.reload()
},
rowStore(item) {
return mapReconciliationStoreRow(item)
},
rowDrug(item) {
return mapReconciliationDrugRow(item)
},
onTabChange(index) {
this.tabIndex = index
this.tab = index === 0 ? 'store' : 'drug'
this.reload()
},
reload() {
this.persistReconciliationFilter()
this.load()
},
load() {
this.loading = true
const isDrug = this.tab === 'drug'
const apiFn = isDrug
? this.bizApi.getReconciliationDrugList
: this.bizApi.getReconciliationList
const params = isDrug
? buildDrugListParams({
dateStart: this.dateStart,
dateEnd: this.dateEnd,
drugId: this.drugId,
orderId: this.orderId,
byOrder: this.byOrder,
})
: buildStoreListParams({
dateStart: this.dateStart,
dateEnd: this.dateEnd,
excludeZeroSales: 0,
})
apiFn.call(this.bizApi, params).then(w => {
const { count, items } = normalizeReconciliationResponse(w.data)
this.statItems = isDrug
? mapReconciliationDrugCount(count)
: mapReconciliationStoreCount(count)
const keyField = isDrug ? 'drug_id' : 'id'
this.list = withWxKey(items, keyField, 'rec')
}).catch(() => {
uni.showToast({ title: '查询失败', icon: 'none' })
this.list = []
this.statItems = []
}).finally(() => { this.loading = false })
},
onStart(e) { this.dateStart = e.detail.value },
onEnd(e) { this.dateEnd = e.detail.value },
onByOrderChange(e) {
this.byOrder = !!e.detail.value
},
timeParams() {
return buildSearchTimeQueryParams(this.dateStart, this.dateEnd)
},
searchDrugOptions() {
if (!this.dateStart || !this.dateEnd) {
uni.showToast({ title: '请选择时间范围', icon: 'none' })
return
}
this.bizApi.getReconciliationDrugOptions({
q: this.drugSearchQ,
limit: 20,
...this.timeParams(),
}).then(w => {
const items = (w.data && w.data.items) || []
this.drugOptions = items
if (!items.length) uni.showToast({ title: '无匹配药品', icon: 'none' })
})
},
searchOrderOptions() {
if (!this.dateStart || !this.dateEnd) {
uni.showToast({ title: '请选择时间范围', icon: 'none' })
return
}
this.bizApi.getReconciliationOrderOptions({
q: this.orderSearchQ,
limit: 20,
...this.timeParams(),
}).then(w => {
const items = (w.data && w.data.items) || []
this.orderOptions = items
if (!items.length) uni.showToast({ title: '无匹配订单', icon: 'none' })
})
},
onDrugPick(e) {
const idx = Number(e.detail.value)
const opt = this.drugOptions[idx]
if (opt) {
this.drugId = opt.value
this.selectedDrugLabel = opt.label
}
},
onOrderPick(e) {
const idx = Number(e.detail.value)
const opt = this.orderOptions[idx]
if (opt) {
this.orderId = opt.value
this.selectedOrderLabel = opt.label
}
},
clearDrug() {
this.drugId = null
this.selectedDrugLabel = ''
this.drugOptions = []
},
clearOrder() {
this.orderId = null
this.selectedOrderLabel = ''
this.orderOptions = []
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
$color-border: #E5E6EB;
.reconciliation-container {
background-color: $color-bg-base;
min-height: 100vh;
padding-bottom: 60rpx;
}
/* 顶部吸顶区 */
.header-section {
background: #ffffff;
padding-bottom: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.02);
}
.tabs-wrap {
border-bottom: 1rpx solid $color-border;
}
.quick-filter {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 0;
}
.date-range-picker {
display: flex;
align-items: center;
background: #F7F8FA;
border-radius: 12rpx;
padding: 12rpx 24rpx;
flex: 1;
margin-right: 24rpx;
}
.date-text {
font-size: 26rpx;
color: $color-text-main;
font-weight: 500;
&.placeholder {
color: #BDBDBD;
font-weight: 400;
}
}
.sep {
color: $color-text-muted;
font-size: 24rpx;
margin: 0 16rpx;
}
.filter-actions {
display: flex;
align-items: center;
gap: 20rpx;
flex-shrink: 0;
}
.btn-clear {
font-size: 26rpx;
color: $color-text-muted;
}
.btn-search {
font-size: 26rpx;
color: #fff;
background: $theme-primary;
padding: 10rpx 24rpx;
border-radius: 30rpx;
font-weight: 500;
}
/* 药品筛选展开面板优化 */
.switch-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.filter-label-group {
display: flex;
flex-direction: column;
}
.filter-label {
font-size: 28rpx;
color: $color-text-main;
font-weight: 500;
margin-bottom: 4rpx;
}
.filter-hint {
font-size: 22rpx;
color: $color-text-muted;
}
.filter-field {
margin-bottom: 16rpx;
}
.search-input-group {
display: flex;
align-items: center;
background: #F7F8FA;
border-radius: 12rpx;
padding: 8rpx 8rpx 8rpx 24rpx;
margin-top: 12rpx;
}
.filter-input {
flex: 1;
font-size: 26rpx;
color: $color-text-main;
height: 60rpx;
}
.filter-link {
font-size: 26rpx;
color: $theme-primary;
padding: 12rpx 24rpx;
font-weight: 500;
}
.picker-wrap {
margin-bottom: 24rpx;
}
.filter-picker {
font-size: 26rpx;
color: $color-text-main;
padding: 20rpx 24rpx;
background: #F7F8FA;
border-radius: 12rpx;
margin-bottom: 12rpx;
}
.filter-clear {
font-size: 24rpx;
color: #F53F3F;
display: block;
text-align: right;
}
.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-grid-wrap {
padding: 24rpx 24rpx 8rpx; /* 控制和卡片之间的呼吸感 */
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 取消了scroll-x滚动改成直接平铺两列 */
gap: 20rpx;
}
.stat-card {
background: #fff;
padding: 32rpx 24rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-label {
font-size: 24rpx;
color: $color-text-muted;
}
.stat-value {
font-size: 36rpx;
color: $color-text-main;
font-weight: 700;
margin-top: 12rpx;
font-family: DIN Alternate, monospace, sans-serif;
/* 添加截断处理防止极端数字导致错位 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ================= 核心高级卡片 ================= */
.list-wrap {
padding: 0 24rpx;
}
.premium-card {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
border: 1rpx solid #E5E6EB;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.015);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24rpx;
}
.card-title {
font-size: 32rpx;
color: $color-text-main;
font-weight: 600;
flex: 1;
line-height: 1.4;
margin-right: 16rpx;
}
.mini-tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
background: rgba(106, 205, 187, 0.1);
color: $theme-primary;
border-radius: 8rpx;
font-weight: 500;
white-space: nowrap;
&.gray {
background: #F2F3F5;
color: $color-text-body;
margin-left: 12rpx;
}
}
/* 诊所对账:核心摘要 */
.hero-data {
display: flex;
align-items: center;
background: #F8FBFB;
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 24rpx;
}
.data-box {
flex: 1;
display: flex;
flex-direction: column;
}
.data-divider {
width: 2rpx;
height: 60rpx;
background: #EAEFEF;
margin: 0 32rpx;
}
.data-label {
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 8rpx;
}
.data-val {
font-size: 32rpx;
font-weight: 700;
color: $color-text-main;
font-family: DIN Alternate, monospace, sans-serif;
&.text-primary {
color: $theme-primary;
}
}
/* 诊所对账2x2 仪表盘网格 */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.dashboard-island {
background: #F7F8FA;
border-radius: 16rpx;
padding: 20rpx;
}
.island-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 12rpx;
border-bottom: 1rpx dashed #E5E6EB;
}
.island-title {
font-size: 26rpx;
font-weight: 600;
color: $color-text-main;
}
.island-sales {
font-size: 22rpx;
color: $theme-primary;
font-weight: 500;
}
.island-line {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
color: $color-text-body;
margin-bottom: 8rpx;
&:last-child {
margin-bottom: 0;
}
text:last-child {
font-family: monospace, sans-serif;
color: $color-text-main;
}
}
/* 药品对账:通用信息岛 */
.drug-meta {
display: flex;
align-items: center;
}
.info-island {
background: #F7F8FA;
border-radius: 16rpx;
padding: 24rpx;
}
.island-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 26rpx;
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
.island-label {
color: $color-text-muted;
}
.island-value {
color: $color-text-main;
&.text-primary { color: $theme-primary; }
&.font-bold { font-weight: 600; font-size: 28rpx; }
&.font-mono { font-family: SF Pro Text, monospace, sans-serif; font-size: 24rpx; color: $color-text-muted; }
}
}
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 120rpx 0;
font-size: 28rpx;
}
</style>