1. 优化了一些交互,中药药品编辑交互

2. dev和生产环境统一鉴别
This commit is contained in:
李琦
2026-05-30 13:53:14 +08:00
parent 6d93314222
commit 27202c0740
31 changed files with 6610 additions and 0 deletions

152
api/clinicAdmin.js Normal file
View File

@@ -0,0 +1,152 @@
import { req } from '@/common/js/index.js';
const PREFIX = '/newApi/clinic-admin';
/** 解析 Laravel jok 或 Yii 响应 */
export function unwrapClinicRes(res) {
if (!res) return null;
if (res.code === 0 && res.result !== undefined) return res.result;
if (res.errcode != null && res.errcode !== -1) return res.data;
if (res.result !== undefined) return res.result;
return res.data != null ? res.data : res;
}
function clinicRequest(options) {
return req.request({
...options,
header: {
...(options.header || {}),
_clinicAdmin: '1',
},
}).then((res) => {
const data = unwrapClinicRes(res);
const ok = res && (res.code === 0 || (res.errcode != null && Number(res.errcode) !== -1));
return { res, data, ok };
});
}
export function sendClinicAdminCode(phone) {
return req.request({
url: '/newApi/clinic-admin-auth/send-verification-code',
method: 'POST',
data: { account: phone },
header: { isToken: false, _clinicAdmin: '1' },
});
}
export function clinicAdminLogin(phone, password, code) {
return req.request({
url: '/newApi/clinic-admin-auth/login',
method: 'POST',
data: { account: phone, password, code },
header: { isToken: false, _clinicAdmin: '1' },
});
}
export function getClinicAdminMyInfo() {
return clinicRequest({ url: `${PREFIX}-auth/my-info`, method: 'GET' });
}
export function getClinicAdminBalance() {
return clinicRequest({ url: `${PREFIX}-account/my-balance`, method: 'GET' });
}
export function getClinicAdminCard() {
return clinicRequest({ url: `${PREFIX}-account/my-card`, method: 'GET' });
}
export function saveClinicAdminCard(data) {
return clinicRequest({ url: `${PREFIX}-account/save-card`, method: 'POST', data });
}
export function getLedgerLogList(params) {
return clinicRequest({ url: `${PREFIX}-settlement/ledger-log-list`, method: 'GET', data: params });
}
export function getLedgerDetail(params) {
return clinicRequest({ url: `${PREFIX}-settlement/ledger-detail`, method: 'GET', data: params });
}
export function getWithdrawalList(params) {
return clinicRequest({ url: `${PREFIX}-withdrawal/list`, method: 'GET', data: params });
}
export function getWithdrawalDetail(id) {
return clinicRequest({ url: `${PREFIX}-withdrawal/detail`, method: 'GET', data: { id } });
}
export function submitWithdrawal(data) {
return clinicRequest({ url: `${PREFIX}-withdrawal/withdrawal`, method: 'POST', data });
}
export function getReconciliationList(params) {
return clinicRequest({ url: `${PREFIX}-reconciliation/list`, method: 'GET', data: params });
}
export function getReconciliationDrugList(params) {
return clinicRequest({ url: `${PREFIX}-reconciliation/drug-list`, method: 'GET', data: params });
}
export function getReconciliationDrugOptions(params) {
return clinicRequest({ url: `${PREFIX}-reconciliation/drug-options`, method: 'GET', data: params });
}
export function getReconciliationOrderOptions(params) {
return clinicRequest({ url: `${PREFIX}-reconciliation/order-options`, method: 'GET', data: params });
}
export function getOrderSaleAmount(params) {
return clinicRequest({ url: `${PREFIX}-order/sale-amount`, method: 'GET', data: params });
}
export function getPrescriptionDetail(id) {
return clinicRequest({ url: `${PREFIX}-prescription/detail`, method: 'GET', data: { id } });
}
export function getOrderList(params) {
return clinicRequest({ url: `${PREFIX}-order/list`, method: 'GET', data: params });
}
export function getOrderStatusOption() {
return clinicRequest({ url: `${PREFIX}-order/status-option`, method: 'GET' });
}
export function getOrderDetail(id) {
return clinicRequest({ url: `${PREFIX}-order/detail`, method: 'GET', data: { id } });
}
export function refundOrder(data) {
return clinicRequest({ url: `${PREFIX}-order/refund`, method: 'POST', data });
}
export function wareSendOrder(data) {
return clinicRequest({ url: `${PREFIX}-order/ware-send`, method: 'POST', data });
}
export function getExpressDetailByOrderId(orderId) {
return clinicRequest({
url: `${PREFIX}-express-detail/detail-by-order`,
method: 'POST',
data: { order_id: orderId },
});
}
export function getWarehouseList(params) {
return clinicRequest({ url: `${PREFIX}-warehouse/list`, method: 'GET', data: params });
}
export function getWarehouseDetail(id) {
return clinicRequest({ url: `${PREFIX}-warehouse/detail`, method: 'GET', data: { id } });
}
export function updateWarehouseStatus(id) {
return clinicRequest({ url: `${PREFIX}-warehouse/update-status`, method: 'GET', data: { id } });
}
export function updateWarehousePrice(data) {
return clinicRequest({ url: `${PREFIX}-warehouse/update`, method: 'POST', data });
}
export function getWarehouseProductTypeOptions() {
return clinicRequest({ url: `${PREFIX}-warehouse/product-type-options`, method: 'GET' });
}

View File

@@ -0,0 +1,23 @@
let popupInstance = null;
export function registerImageCompressPopup(instance) {
popupInstance = instance;
}
export function openImageCompressModal({ path, size }) {
if (!popupInstance || typeof popupInstance.open !== 'function') {
console.warn('image-compress-popup 未注册,将直接使用原图');
return Promise.resolve({
path,
size,
usedCompress: false,
});
}
return popupInstance.open({ path, size }).then((result) => {
if (!result) {
return null;
}
return result;
});
}

View File

@@ -0,0 +1,95 @@
import { openImageCompressModal } from '@/common/js/image-compress-modal.js';
export const IMAGE_COMPRESS_THRESHOLD = 1 * 1024 * 1024;
export function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / k ** i;
return `${Number.parseFloat(value.toFixed(2))} ${sizes[i]}`;
}
/** 压缩节省比例文案,如「(约节省 42%)」 */
export function formatSavingsPercent(originalBytes, compressedBytes) {
if (originalBytes <= 0 || compressedBytes >= originalBytes) {
return '';
}
const percent = Math.round(
((originalBytes - compressedBytes) / originalBytes) * 100,
);
return percent > 0 ? `(约节省 ${percent}%` : '';
}
function getFileInfo(filePath) {
return new Promise((resolve, reject) => {
uni.getFileInfo({
filePath,
success: resolve,
fail: reject,
});
});
}
export function compressImagePath(src, quality) {
return new Promise((resolve, reject) => {
uni.compressImage({
src,
quality,
success: async (res) => {
try {
const info = await getFileInfo(res.tempFilePath);
resolve({
path: res.tempFilePath,
size: info.size,
});
} catch (error) {
reject(error);
}
},
fail: reject,
});
});
}
export async function tryCompressImage(
src,
qualities = [80, 60, 40],
onProgress,
) {
let result = null;
for (const quality of qualities) {
onProgress && onProgress(quality);
result = await compressImagePath(src, quality);
if (result.size <= IMAGE_COMPRESS_THRESHOLD) {
break;
}
}
return result;
}
export function isGifPath(path) {
return /\.gif$/i.test(path || '');
}
export async function prepareImagePath({ path, size }) {
if (size <= IMAGE_COMPRESS_THRESHOLD) {
return {
path,
size,
usedCompress: false,
};
}
const result = await openImageCompressModal({ path, size });
if (!result) {
return null;
}
return {
path: result.path,
size: result.size,
usedCompress: !!result.usedCompress,
};
}

View File

@@ -0,0 +1,60 @@
import { prepareImagePath } from '@/common/js/image-compress.js';
export function chooseAvatarImage(options = {}) {
const {
uploadUrl = 'https://api.xiaokang88.com/open-api/upload/old-admin-img',
formData = { store_id: '11001' },
onSuccess,
onFail,
onCancel,
} = options;
uni.chooseImage({
count: 1,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uni.showLoading({ title: '请稍后...' });
try {
const tempFile = res.tempFiles[0];
const prepared = await prepareImagePath({
path: tempFile.path,
size: tempFile.size,
});
if (!prepared) {
uni.hideLoading();
onCancel && onCancel();
return;
}
uni.uploadFile({
url: uploadUrl,
filePath: prepared.path,
name: 'file',
header: {
authorization: `Bearer ${uni.getStorageSync('token')}`,
},
formData,
success: (uploadRes) => {
uni.hideLoading();
onSuccess && onSuccess(uploadRes);
},
fail: (error) => {
uni.hideLoading();
onFail && onFail(error);
},
});
} catch (error) {
uni.hideLoading();
onFail && onFail(error);
}
},
complete: (res) => {
if (res.errMsg === 'chooseImage:fail cancel') {
onCancel && onCancel();
}
},
});
}

View File

@@ -0,0 +1,676 @@
<template>
<u-popup v-model="visible" mode="center" width="85%" border-radius="16" :mask-close-able="false">
<view class="popup-wrap">
<view class="popup-title">图片较大</view>
<view v-if="sizeText" class="size-text">{{ sizeText }}</view>
<view v-if="status === 'compressing'" class="compressing-panel">
<image
v-if="originalPath"
:src="originalPath"
mode="aspectFit"
class="preview-image single"
@click="previewImage(originalPath)"
/>
<text v-if="originalPath" class="preview-hint">点击预览</text>
<view class="compressing-status">
<u-loading mode="circle" />
<text class="progress-text">{{ progressText }}</text>
</view>
</view>
<view v-else-if="status === 'ready'" class="compare-panel">
<view class="preview-column">
<text class="preview-label">原图</text>
<image
:src="originalPath"
mode="aspectFit"
class="preview-image"
@click="previewImage(originalPath)"
/>
<text class="preview-size">{{ formatFileSize(originalSize) }}</text>
<text class="preview-hint">点击预览</text>
</view>
<view class="preview-column">
<text class="preview-label">压缩后</text>
<image
:src="compressedPath"
mode="aspectFit"
class="preview-image"
@click="previewImage(compressedPath)"
/>
<text class="preview-size">{{ formatFileSize(compressedSize) }}</text>
<text class="preview-hint">点击预览</text>
</view>
</view>
<view v-else-if="status === 'gifSkipped'" class="gif-panel">
<image
v-if="originalPath"
:src="originalPath"
mode="aspectFit"
class="preview-image single"
@click="previewImage(originalPath)"
/>
<text v-if="originalPath" class="preview-hint">点击预览</text>
<text class="hint-text">GIF 不支持压缩将上传原图</text>
</view>
<view v-else class="error-panel">
<image
v-if="originalPath"
:src="originalPath"
mode="aspectFit"
class="preview-image single"
@click="previewImage(originalPath)"
/>
<text v-if="originalPath" class="preview-hint">点击预览</text>
<text class="error-text">{{ errorMessage }}</text>
</view>
<view class="footer-actions">
<view class="btn btn-default" @click="finish('cancel')">取消</view>
<view
v-if="status === 'ready' || status === 'error'"
class="btn btn-default"
@click="finish('original')"
>
{{ originalButtonLabel }}
</view>
<view
v-if="status === 'ready'"
class="btn btn-primary"
@click="finish('compressed')"
>
{{ compressedButtonLabel }}
</view>
<view
v-if="status === 'gifSkipped'"
class="btn btn-primary"
@click="finish('original')"
>
{{ originalButtonLabel }}
</view>
</view>
</view>
</u-popup>
</template>
<script>
import {
formatFileSize,
formatSavingsPercent,
isGifPath,
tryCompressImage,
} from '@/common/js/image-compress.js';
export default {
name: 'ImageCompressPopup',
data() {
return {
visible: false,
status: 'compressing',
progressText: '正在压缩,请稍候…',
errorMessage: '',
originalPath: '',
compressedPath: '',
originalSize: 0,
compressedSize: 0,
skipped: false,
resolver: null,
};
},
computed: {
sizeText() {
if (!this.originalSize) return '';
const originalText = formatFileSize(this.originalSize);
if (this.status === 'gifSkipped') {
return `原图 ${originalText}`;
}
if (this.compressedSize && this.status === 'ready') {
const savings = formatSavingsPercent(this.originalSize, this.compressedSize);
return `原图 ${originalText} → 压缩后 ${formatFileSize(this.compressedSize)}${savings}`;
}
if (this.compressedSize) {
return `原图 ${originalText} → 压缩后 ${formatFileSize(this.compressedSize)}`;
}
return `原图 ${originalText}`;
},
originalButtonLabel() {
if (!this.originalSize) return '使用原图';
return `使用原图 (${formatFileSize(this.originalSize)})`;
},
compressedButtonLabel() {
if (!this.compressedSize) return '使用压缩图';
return `使用压缩图 (${formatFileSize(this.compressedSize)})`;
},
},
methods: {
formatFileSize,
previewImage(current) {
if (!this.originalPath) return;
const urls =
this.status === 'ready' && this.compressedPath
? [this.originalPath, this.compressedPath]
: [this.originalPath];
uni.previewImage({
urls,
current,
});
},
open({ path, size }) {
return new Promise((resolve) => {
this.resolver = resolve;
this.originalPath = path;
this.originalSize = size;
this.compressedPath = '';
this.compressedSize = 0;
this.skipped = false;
this.errorMessage = '';
this.progressText = '正在压缩,请稍候…';
this.visible = true;
this.startCompress(path, size);
});
},
async startCompress(path, size) {
if (isGifPath(path)) {
this.skipped = true;
this.status = 'gifSkipped';
return;
}
this.status = 'compressing';
this.progressText = '正在压缩(质量 80%)…';
try {
const compressed = await tryCompressImage(path, [80, 60, 40], (quality) => {
this.progressText = `正在压缩(质量 ${quality}%)…`;
});
this.compressedPath = compressed.path;
this.compressedSize = compressed.size;
this.status = 'ready';
this.progressText = '';
} catch (error) {
this.status = 'error';
this.errorMessage = '图片压缩失败,请使用原图或取消';
this.progressText = '';
}
},
finish(choice) {
const result = { choice, path: null, size: 0, usedCompress: false };
if (choice === 'cancel') {
this.visible = false;
this.resolver && this.resolver(null);
this.resolver = null;
return;
}
if (choice === 'compressed' && this.status === 'ready') {
result.path = this.compressedPath;
result.size = this.compressedSize;
result.usedCompress = true;
} else {
result.path = this.originalPath;
result.size = this.originalSize;
result.usedCompress = false;
}
this.visible = false;
this.resolver && this.resolver(result);
this.resolver = null;
},
},
};
</script>
<style lang="scss" scoped>
.popup-wrap {
padding: 32rpx;
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
margin-bottom: 16rpx;
}
.size-text {
font-size: 26rpx;
color: #666;
text-align: center;
margin-bottom: 24rpx;
}
.preview-image {
width: 100%;
height: 280rpx;
background: #fafafa;
border-radius: 12rpx;
}
.preview-image.single {
width: 100%;
}
.compare-panel {
display: flex;
gap: 16rpx;
}
.preview-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.preview-label {
font-size: 26rpx;
font-weight: 500;
margin-bottom: 12rpx;
}
.preview-size {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
}
.preview-hint {
margin-top: 4rpx;
font-size: 22rpx;
color: #bbb;
}
.compressing-panel,
.gif-panel,
.error-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.compressing-status {
display: flex;
align-items: center;
gap: 16rpx;
}
.progress-text,
.hint-text {
font-size: 26rpx;
color: #666;
}
.error-text {
font-size: 26rpx;
color: #fa3534;
}
.footer-actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 32rpx;
}
.btn {
min-width: 160rpx;
padding: 16rpx 24rpx;
border-radius: 8rpx;
font-size: 26rpx;
text-align: center;
}
.btn-default {
background: #f5f5f5;
color: #333;
}
.btn-primary {
background: #2979ff;
color: #fff;
}
</style>

View File

@@ -0,0 +1,513 @@
import { formatMoney } from './format.js'
const DEFAULT_DRUG_IMAGE =
'https://xkyp-web2.obs.cn-south-1.myhuaweicloud.com/buy/public/drug_no_mage.png'
/** 兼容 int 时间戳 / datetime 字符串 */
export function formatDateTime(v) {
if (v === null || v === undefined || v === '') return '-'
if (typeof v === 'number') {
const ms = v < 1e12 ? v * 1000 : v
const d = new Date(ms)
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleString()
}
const s = String(v)
if (s === '2021-01-01 00:00:00') return '-'
return s
}
export function withdrawalCheckStatusText(s) {
const m = { 1: '待审核', 2: '审核成功', 3: '拒绝', 4: '提现失败' }
return m[s] || '-'
}
export function dakuanStatusText(s) {
if (s === -1) return '打款失败'
if (s === 0) return '处理中'
if (s === 1) return '打款成功'
return '-'
}
export function isWithdrawalRejected(item) {
return item && Number(item.check_status) === 3
}
/** 提现列表行展示yii_cash_apply */
export function mapWithdrawalRow(item) {
if (!item) return {}
const checkStatus = item.check_status
return {
id: item.id,
orderNo: item.order_no || '-',
applyCash: formatMoney(item.apply_cash),
trueCash: formatMoney(item.true_cash),
chargeCash: formatMoney(item.charge_cash),
applyTime: formatDateTime(item.apply_time),
checkStatusText: item.check_status_txt || withdrawalCheckStatusText(checkStatus),
checkResult: item.check_result || '',
isRejected: isWithdrawalRejected(item),
dakuanStatusText: item.dakuan_status_txt || dakuanStatusText(item.dakuan_status),
dakuanTime: formatDateTime(item.dakuan_time),
_raw: item,
}
}
/** 结算记录列表行yii_ledger_log */
export function mapSettlementRow(item) {
if (!item) return {}
return {
id: item.id,
orderNo: item.order_no || (item.order_id ? '订单#' + item.order_id : '-'),
feeTypeText: item.fee_type_txt || '-',
amountText: formatMoney(item.amount),
amountClass: ledgerTypeClass(item),
typeText: item.type_txt || '-',
content: item.content || '',
createdAt: formatDateTime(item.created_at),
_raw: item,
}
}
export function ledgerTypeClass(item) {
return item && item.type === 1 ? 'inc' : 'dec'
}
/** 商品订单状态(对齐 PC product-order */
export function orderStatusText(status) {
const m = {
0: '待支付',
1: '待发货',
2: '待收货',
3: '待评价',
4: '已退款',
5: '退款中',
6: '已收货',
7: '确认收货',
8: '拒绝退款',
9: '已取消',
}
return m[status] != null ? m[status] : '-'
}
export function deliveryMethodText(dm) {
if (dm === 0) return '快递到家'
if (dm === 1) return '药店自提'
return '-'
}
export function orderOnlineText(isOnline) {
if (isOnline === 1) return '在线问诊'
if (isOnline === 2) return '在线复诊'
return '线下就诊'
}
export function prescriptionTypeText(pt) {
const m = {
1: '中药',
2: '西药',
3: '保健食品',
5: '产品服务包',
6: '非药品',
7: '医疗器械',
}
return m[pt] != null ? m[pt] : '其他'
}
export function orderTypeText(ot) {
const m = {
1: '处方订单',
2: '预约购药订单',
3: '商城处方订单',
}
return m[ot] != null ? m[ot] : '其他'
}
export function erpSyncText(v) {
if (v === 1) return '已同步'
return '未同步'
}
export function freeShippingText(v) {
if (v === 1) return '包邮'
return '不包邮'
}
function pickReceiverMobile(item) {
const addr = item.address
if (addr && typeof addr === 'object' && addr.mobile) return addr.mobile
return item.patient_mobile || ''
}
function buildProductSummary(items, prescriptionType, max = 2) {
if (!Array.isArray(items) || !items.length) return ''
return items.slice(0, max).map(p => formatOrderProductLine(p, prescriptionType)).join('')
}
function pickAddrName(item) {
const addr = item.address
if (addr && typeof addr === 'object' && addr.name) return addr.name
if (typeof addr === 'string') {
try {
const o = JSON.parse(addr)
return o.name || ''
} catch (e) {
return ''
}
}
return item.patient || item.express_name || '-'
}
/** 订单列表卡片 */
export function mapOrderListRow(item) {
if (!item) return {}
const store = item.store || {}
const user = item.user || {}
const pt = item.prescription_type
return {
id: item.id,
orderNo: item.order_no || '-',
totalPayPrice: formatMoney(item.total_pay_price),
itemsPrice: formatMoney(item.items_price),
transExpenses: formatMoney(item.trans_expenses),
registerPrice: item.register_id != null && item.register_price != null
? formatMoney(item.register_price)
: '',
registerOrderNo: item.register_order_no || '',
statusText: orderStatusText(item.status),
statusClass: 'tag-status',
prescriptionTypeText: prescriptionTypeText(pt),
prescriptionTypeClass: 'tag-type',
orderTypeText: orderTypeText(item.order_type),
onlineText: orderOnlineText(item.is_online),
erpStatusText: erpSyncText(item.is_sync_erp),
freeShippingText: freeShippingText(item.is_free_shipping),
receiverName: pickAddrName(item),
receiverMobile: pickReceiverMobile(item),
deliveryText: deliveryMethodText(item.delivery_method),
deliveryClass: 'tag-delivery',
userNickname: user.nickname || '-',
payTime: formatDateTime(item.pay_time),
createdAt: formatDateTime(item.created_at),
storeName: store.name || '-',
productSummary: buildProductSummary(item.product_order_items, pt),
pId: item.p_id,
canViewPrescription: !!(item.p_id && item.order_type !== 2 && item.order_type !== 3),
_raw: item,
}
}
/** 物流详情(对齐 PC express-detail/detail-by-order */
export function mapExpressDetail(data) {
if (!data || typeof data !== 'object') {
return { hasData: false, companyName: '', expressNo: '', stateText: '', tracks: [] }
}
const rawDetail = data.detail
const tracks = Array.isArray(rawDetail)
? rawDetail.map(d => ({
status: d.status || '-',
time: d.detail_at || '',
detail: d.detail || '',
}))
: []
return {
hasData: !!(data.express_no || data.express_company_name || tracks.length),
companyName: data.express_company_name || '-',
expressNo: data.express_no || '-',
stateText: data.state_txt || '-',
tracks,
}
}
/** 订单详情商品行 */
export function mapOrderProductItem(p, prescriptionType) {
if (!p) return {}
const drug = p.drug || {}
const sourceInfo = drug.source_info || {}
const manufacturer = sourceInfo.name || drug.source || '暂无'
const dosage = p.dosage > 0 ? p.dosage : 1
const qty = prescriptionType === 1
? '*' + ((p.number != null ? p.number : 0) * dosage)
: '×' + (p.number != null ? p.number : 0)
const isTcm = prescriptionType === 1
return {
drugName: p.drug_name || drug.drug_name || '药品',
drugNumber: drug.drug_number || '',
imageUrl: resolveDrugImage(p.drug_image || drug.image),
specification: drug.specification || (isTcm ? 'g' : '暂无'),
manufacturer,
qty,
price: isTcm
? (p.price != null && p.price !== '' ? p.price + ' 元/g' : '—')
: formatMoney(p.price),
buyPrice: p.buy_price != null && p.buy_price !== ''
? p.buy_price + ' 元/g'
: '—',
isTcm,
}
}
/** 订单详情处方块 */
export function mapOrderPrescription(info) {
const pres = info && info.prescription
const content = pres && pres.content
if (!content || info.order_type === 2 || info.order_type === 3) return null
const doctor = content.doctor || {}
const depart = doctor.depart || {}
const title = doctor.title || {}
return {
prescriptionNo: content.prescription_no || '-',
diagnose: content.clinical_diagnose || '-',
doctorOrder: content.doctor_order || '-',
doctorName: doctor.name || '-',
departName: depart.name || '-',
titleName: title.name || '-',
}
}
export function mapOrderDetailDisplay(info) {
if (!info || !info.id) return {}
const freeShip = info.is_free_shipping === 1 || info.free_ship === 1
const canView = !!(info.p_id && info.order_type !== 2 && info.order_type !== 3)
return {
orderTypeText: orderTypeText(info.order_type),
marketPrice: formatMoney(info.market_price),
freeShippingText: freeShip ? '是' : '否',
cancelRemark: info.cancel_remark || '无',
showPrescription: mapOrderPrescription(info),
prescriptionId: info.p_id,
canViewPrescription: canView,
statusText: orderStatusText(info.status),
prescriptionTypeText: prescriptionTypeText(info.prescription_type),
deliveryText: deliveryMethodText(info.delivery_method),
}
}
function parseRecipeContent(raw) {
if (!raw) return null
if (typeof raw === 'object') return raw
try {
return JSON.parse(raw)
} catch (e) {
return null
}
}
/** 完整处方详情(对齐 PC PrescriptionDetail.vue */
export function mapPrescriptionDetailView(item) {
if (!item) return null
const content = item.content || {}
const patient = content.patient || {}
const doctor = content.doctor || {}
const depart = doctor.depart || {}
const title = doctor.title || {}
const pt = item.prescription_type
const recipes = []
const repice = content.repice || content.recipe || []
const list = Array.isArray(repice) ? repice : []
if (pt === 1) {
list.forEach((recipe) => {
const drugs = parseRecipeContent(recipe.content)
const drugLines = Array.isArray(drugs)
? drugs.map(d => {
const name = d.name || d.drug_name || '—'
const num = d.number != null ? d.number : ''
const way = (d.use_way && d.use_way.name) || d.useWay || '煎服'
return { name, qty: num ? num + ' /g' : '', usage: way }
})
: []
recipes.push({
type: 'tcm',
drugs: drugLines,
usage: recipe.consumption != null && recipe.dosage != null
? `每日${recipe.consumption}次,共${recipe.dosage}`
: '',
process: [recipe.process_rule_note, recipe.process_rule].filter(Boolean).join(''),
})
})
} else {
list.forEach((recipe) => {
const d = parseRecipeContent(recipe.content)
if (!d) return
const unitName = (d.unit && d.unit.name) || d.unit || ''
recipes.push({
type: 'west',
drugs: [{
name: d.name || d.drug_name || '—',
qty: (d.number != null ? d.number : '') + unitName,
usage: d.useWay || (d.use_way && d.use_way.name) || '',
}],
usage: '',
process: '',
})
})
}
const tcm = item.online_tcm_print || {}
return {
prescriptionNo: item.prescription_no || content.prescription_no || '-',
createdAt: item.created_at || '-',
patientName: patient.name || '-',
patientSex: patient.sex === 1 ? '男' : patient.sex === 2 ? '女' : '-',
patientAge: patient.age != null ? String(patient.age) : '-',
category: content.category || '-',
diagnose: content.clinical_diagnose || '-',
doctorOrder: content.doctor_order || '-',
doctorName: doctor.name || '-',
departName: depart.name || '-',
titleName: title.name || '-',
tcmSyndrome: tcm.show && tcm.tcm_syndrome ? tcm.tcm_syndrome : '',
tcmMethod: tcm.show && tcm.tcm_method ? tcm.tcm_method : '',
tcmDisease: tcm.show && tcm.tcm_disease ? tcm.tcm_disease : '',
recipes,
prescriptionType: pt,
}
}
/** 订单商品行展示 */
export function formatOrderProductLine(p, prescriptionType) {
if (!p) return '-'
const name = p.drug_name || (p.drug && p.drug.drug_name) || '药品'
const num = p.number != null ? p.number : ''
if (prescriptionType === 1) {
const dosage = p.dosage > 0 ? p.dosage : 1
return name + ' ×' + (num * dosage)
}
return name + ' ×' + num
}
export function resolveDrugImage(url) {
if (!url) return DEFAULT_DRUG_IMAGE
const s = String(url)
if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
return DEFAULT_DRUG_IMAGE
}
/** 仓库 1=下架 2=上架 */
export function warehouseStatusText(status) {
if (status === 2) return '上架'
if (status === 1) return '下架'
return '-'
}
export function mapWarehouseRow(item) {
if (!item) return {}
const drug = item.drug || {}
const storeDrug = drug.drug_store_drug || {}
const suggestPrice = storeDrug.price != null ? storeDrug.price : drug.price
return {
id: item.id,
drugName: drug.drug_name || '-',
imageUrl: resolveDrugImage(drug.image),
buyPrice: formatMoney(item.buy_price),
price: formatMoney(item.price),
suggestPrice: formatMoney(suggestPrice),
stock: item.stock != null ? item.stock : '-',
statusText: warehouseStatusText(item.status),
isOnShelf: item.status === 2,
pinyin: drug.pinyin || '-',
_raw: item,
}
}
function mapReconciliationCategory(rec, prefix) {
const sales = rec[prefix + '_sales']
const salesPrice = rec[prefix + '_sales_price']
const supplyPrice = rec[prefix + '_supply_price']
return {
sales: sales != null ? String(sales) : '-',
salesPrice: formatMoney(salesPrice),
supplyPrice: formatMoney(supplyPrice),
}
}
export function mapReconciliationStoreRow(item) {
if (!item) return {}
const rec = item.reconciliation || {}
return {
id: item.id,
name: item.name || '-',
totalSales: formatMoney(rec.total_sales_price),
totalSupply: formatMoney(rec.total_supply_price),
herbalSalesPrice: formatMoney(rec.herbal_sales_price),
medicineSalesPrice: formatMoney(rec.medicine_sales_price),
registrationPrice: formatMoney(rec.registration_price),
herbal: mapReconciliationCategory(rec, 'herbal'),
medicine: mapReconciliationCategory(rec, 'medicine'),
servicePackage: mapReconciliationCategory(rec, 'service_package'),
otherFees: {
express: formatMoney(rec.express_price),
process: formatMoney(rec.process_price),
treatment: formatMoney(rec.treatment_price),
},
_raw: item,
}
}
export function mapReconciliationDrugRow(item) {
if (!item) return {}
return {
drugId: item.drug_id,
drugName: item.drug_name || '-',
drugNumber: item.drug_number || '',
typeText: item.type_txt || '-',
number: item.number != null ? item.number : '-',
marketPrice: formatMoney(item.market_price),
totalSales: formatMoney(item.total_sales_price),
totalSupply: formatMoney(item.total_supply_price),
orderNo: item.order_no || '',
_raw: item,
}
}
export function formatPricePlain(v) {
if (v === null || v === undefined || v === '') return '-'
return '¥' + Number(v).toFixed(2)
}
/** 汇总项:隐藏全零噪音 */
export function filterReconciliationStatItems(items) {
return items.filter(s => {
if (s.key === 'total_sales_price' || s.key === 'total_supply_price') return true
if (s.isCount) {
const n = Number(s.raw)
return !Number.isNaN(n) && n !== 0
}
const v = s.raw
if (v === null || v === undefined || v === '') return false
const n = Number(v)
return !Number.isNaN(n) && n !== 0
})
}
/** 诊所对账汇总(对齐 PC setStoreStatistics */
export function mapReconciliationStoreCount(count) {
if (!count) return []
const items = [
{ key: 'total_sales_price', label: '总销售金额', value: formatPricePlain(count.total_sales_price), raw: count.total_sales_price },
{ key: 'total_supply_price', label: '总供货金额', value: formatPricePlain(count.total_supply_price), raw: count.total_supply_price },
{ key: 'herbal_sales', label: '草药销量', value: count.herbal_sales != null ? String(count.herbal_sales) : '-', raw: count.herbal_sales, isCount: true },
{ key: 'herbal_sales_price', label: '草药销售金额', value: formatPricePlain(count.herbal_sales_price), raw: count.herbal_sales_price },
{ key: 'herbal_supply_price', label: '草药供货金额', value: formatPricePlain(count.herbal_supply_price), raw: count.herbal_supply_price },
{ key: 'medicine_sales', label: '成药销量', value: count.medicine_sales != null ? String(count.medicine_sales) : '-', raw: count.medicine_sales, isCount: true },
{ key: 'medicine_sales_price', label: '成药销售金额', value: formatPricePlain(count.medicine_sales_price), raw: count.medicine_sales_price },
{ key: 'medicine_supply_price', label: '成药供货金额', value: formatPricePlain(count.medicine_supply_price), raw: count.medicine_supply_price },
{ key: 'express_price', label: '快递费', value: formatPricePlain(count.express_price), raw: count.express_price },
{ key: 'process_price', label: '加工费', value: formatPricePlain(count.process_price), raw: count.process_price },
{ key: 'treatment_price', label: '诊疗费', value: formatPricePlain(count.treatment_price), raw: count.treatment_price },
{ key: 'outpatient_price', label: '门诊收入', value: formatPricePlain(count.outpatient_price), raw: count.outpatient_price },
{ key: 'registration_price', label: '挂号费', value: formatPricePlain(count.registration_price), raw: count.registration_price },
]
return filterReconciliationStatItems(items)
}
/** 药品对账汇总(对齐 PC setDrugStatistics */
export function mapReconciliationDrugCount(count) {
if (!count) return []
return [
{ key: 'total_sales_price', label: '总销售金额', value: formatPricePlain(count.total_sales_price) },
{ key: 'total_supply_price', label: '总供货金额', value: formatPricePlain(count.total_supply_price) },
{ key: 'number', label: '销量', value: count.number != null ? String(count.number) : '-' },
]
}

View File

@@ -0,0 +1,121 @@
/**
* 诊所端列表筛选条件本地缓存(对齐 PC reconciliation formCache
*/
import {
defaultMonthStartDate,
defaultTodayDate,
} from './reconciliationParams.js'
const KEY_ORDER = 'clinic_admin.order.filter'
const KEY_RECONCILIATION = 'clinic_admin.reconciliation.filter'
function readJson(key) {
try {
const raw = uni.getStorageSync(key)
if (!raw) return null
return typeof raw === 'string' ? JSON.parse(raw) : raw
} catch (e) {
return null
}
}
function writeJson(key, data) {
uni.setStorageSync(key, JSON.stringify(data))
}
export function defaultOrderFilter() {
return {
orderNo: '',
statusIndex: 0,
deliveryIndex: 0,
prescriptionTypeIndex: 0,
dateStart: defaultMonthStartDate(),
dateEnd: defaultTodayDate(),
}
}
export function loadOrderFilter() {
const cached = readJson(KEY_ORDER)
if (!cached || typeof cached !== 'object') return defaultOrderFilter()
const d = defaultOrderFilter()
return {
orderNo: cached.orderNo != null ? String(cached.orderNo) : d.orderNo,
statusIndex: Number(cached.statusIndex) || 0,
deliveryIndex: Number(cached.deliveryIndex) || 0,
prescriptionTypeIndex: Number(cached.prescriptionTypeIndex) || 0,
dateStart: cached.dateStart || d.dateStart,
dateEnd: cached.dateEnd || d.dateEnd,
}
}
export function saveOrderFilter(data) {
writeJson(KEY_ORDER, {
orderNo: data.orderNo || '',
statusIndex: data.statusIndex,
deliveryIndex: data.deliveryIndex,
prescriptionTypeIndex: data.prescriptionTypeIndex,
dateStart: data.dateStart,
dateEnd: data.dateEnd,
})
}
export function clearOrderFilter() {
try {
uni.removeStorageSync(KEY_ORDER)
} catch (e) { /* ignore */ }
}
export function defaultReconciliationFilter() {
return {
tabIndex: 0,
dateStart: defaultMonthStartDate(),
dateEnd: defaultTodayDate(),
byOrder: false,
drugSearchQ: '',
orderSearchQ: '',
drugId: null,
orderId: null,
selectedDrugLabel: '',
selectedOrderLabel: '',
}
}
export function loadReconciliationFilter() {
const cached = readJson(KEY_RECONCILIATION)
if (!cached || typeof cached !== 'object') return defaultReconciliationFilter()
const d = defaultReconciliationFilter()
return {
tabIndex: cached.tabIndex === 1 ? 1 : 0,
dateStart: cached.dateStart || d.dateStart,
dateEnd: cached.dateEnd || d.dateEnd,
byOrder: !!cached.byOrder,
drugSearchQ: cached.drugSearchQ != null ? String(cached.drugSearchQ) : '',
orderSearchQ: cached.orderSearchQ != null ? String(cached.orderSearchQ) : '',
drugId: cached.drugId != null && cached.drugId !== '' ? Number(cached.drugId) : null,
orderId: cached.orderId != null && cached.orderId !== '' ? Number(cached.orderId) : null,
selectedDrugLabel: cached.selectedDrugLabel || '',
selectedOrderLabel: cached.selectedOrderLabel || '',
}
}
export function saveReconciliationFilter(data) {
writeJson(KEY_RECONCILIATION, {
tabIndex: data.tabIndex,
dateStart: data.dateStart,
dateEnd: data.dateEnd,
byOrder: data.byOrder,
drugSearchQ: data.drugSearchQ || '',
orderSearchQ: data.orderSearchQ || '',
drugId: data.drugId,
orderId: data.orderId,
selectedDrugLabel: data.selectedDrugLabel || '',
selectedOrderLabel: data.selectedOrderLabel || '',
})
}
export function clearReconciliationFilter() {
try {
uni.removeStorageSync(KEY_RECONCILIATION)
} catch (e) { /* ignore */ }
}

View File

@@ -0,0 +1,7 @@
/** 金额展示WXML 模板用 methods 调用) */
export function formatMoney(v) {
if (v === null || v === undefined || v === '' || v === '-') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return '¥' + n.toFixed(2)
}

View File

@@ -0,0 +1,32 @@
/**
* 商品订单请求参数(对齐 PC product-order/config/search.ts
*/
import { buildSearchTimeQueryParams } from './reconciliationParams.js'
export function buildOrderListParams({
page = 1,
pageSize = 15,
orderNo = '',
status,
deliveryMethod,
prescriptionType,
dateStart,
dateEnd,
}) {
const params = {
page,
pageSize,
...buildSearchTimeQueryParams(dateStart, dateEnd),
}
if (orderNo) params.order_no = orderNo
if (status !== '' && status != null) params.status = status
if (deliveryMethod !== '' && deliveryMethod != null) params.delivery_method = deliveryMethod
if (prescriptionType !== '' && prescriptionType != null) params.prescription_type = prescriptionType
return params
}
export function buildOrderSaleAmountParams(opts) {
const { page, pageSize, ...rest } = opts || {}
return buildOrderListParams(rest)
}

View File

@@ -0,0 +1,70 @@
/**
* 对账请求参数(对齐 PC finance/reconciliation/index.vue
*/
export const STORE_LIST_PAGE_SIZE = 9999
/** Laravel GET 数组search_time[0] / search_time[1]uni.request 勿传 JS 数组,否则会变成逗号字符串) */
export function buildSearchTimeQueryParams(dateStart, dateEnd) {
return {
'search_time[0]': dateStart,
'search_time[1]': dateEnd,
}
}
export function buildStoreListParams({
page = 1,
pageSize = STORE_LIST_PAGE_SIZE,
dateStart,
dateEnd,
storeName = '',
excludeZeroSales = 0,
}) {
const params = {
page,
pageSize,
exclude_zero_sales: excludeZeroSales,
...buildSearchTimeQueryParams(dateStart, dateEnd),
}
if (storeName) params.store_name = storeName
return params
}
export function buildDrugListParams({
dateStart,
dateEnd,
drugId,
orderId,
byOrder = false,
storeId,
}) {
const params = {
...buildSearchTimeQueryParams(dateStart, dateEnd),
}
if (drugId) params.drug_id = drugId
if (orderId) params.order_id = orderId
if (byOrder) params.by_order = 1
if (storeId != null) params.store_id = storeId
return params
}
export function normalizeReconciliationResponse(data) {
const d = data || {}
const listWrap = d.list || {}
const raw = listWrap.items
return {
count: d.count || {},
items: Array.isArray(raw) ? raw : [],
pageMeta: listWrap,
}
}
/** 本月 1 日 YYYY-MM-DD */
export function defaultMonthStartDate() {
const d = new Date()
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`
}
export function defaultTodayDate() {
return new Date().toISOString().slice(0, 10)
}

View File

@@ -0,0 +1,28 @@
export const TAB_WORKBENCH = 0
export const TAB_MY = 1
export const CLINIC_TAB_ROUTES = [
'/subPackages/sub_clinic_admin/home/index',
'/subPackages/sub_clinic_admin/my/index',
]
export function getClinicTabList() {
return [
{
iconPath: require('@/static/image/w-sf.png'),
selectedIconPath: require('@/static/image/sf.png'),
text: '工作台',
},
{
iconPath: require('@/static/image/w-wd.png'),
selectedIconPath: require('@/static/image/wd.png'),
text: '我的',
},
]
}
export function switchClinicTab(index) {
const url = CLINIC_TAB_ROUTES[index]
if (!url) return
uni.reLaunch({ url })
}

View File

@@ -0,0 +1,80 @@
<template>
<view class="filter-panel filter-panel-sticky">
<view class="filter-head">
<view class="filter-head-left" @click="toggle">
<text class="filter-title">{{ title }}</text>
<text class="filter-toggle">{{ expanded ? '收起' : '展开' }}</text>
</view>
<text v-if="showClear" class="filter-clear-btn" @click.stop="onClear">清空</text>
</view>
<view v-show="expanded" class="filter-body">
<slot />
</view>
</view>
</template>
<script>
export default {
name: 'CollapsibleFilterPanel',
props: {
title: { type: String, default: '筛选条件' },
defaultExpanded: { type: Boolean, default: false },
showClear: { type: Boolean, default: true },
},
data() {
return {
expanded: this.defaultExpanded,
}
},
methods: {
toggle() {
this.expanded = !this.expanded
},
onClear() {
this.$emit('clear')
},
},
}
</script>
<style lang="scss" scoped>
.filter-panel {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.filter-panel-sticky {
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.filter-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
}
.filter-head-left {
display: flex;
align-items: center;
flex: 1;
gap: 16rpx;
}
.filter-title {
font-size: 28rpx;
color: #333;
font-weight: 500;
}
.filter-toggle {
font-size: 26rpx;
color: #6acdbb;
}
.filter-clear-btn {
font-size: 26rpx;
color: #999;
padding: 8rpx 0 8rpx 24rpx;
flex-shrink: 0;
}
.filter-body {
padding: 0 24rpx 24rpx;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<view class="page-sticky-header">
<slot />
</view>
</template>
<script>
export default {
name: 'PageStickyHeader',
}
</script>
<style lang="scss" scoped>
.page-sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: #f9fafb;
padding-bottom: 8rpx;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<view class="clinic-layout safe-area-inset-bottom">
<u-navbar
:title="title"
:is-back="showBack"
title-size="34"
title-color="#fff"
:background="{ background: 'linear-gradient(90deg, #00BFA6 0%, #6ACDBB 93%)' }"
/>
<view class="clinic-body" :style="bodyStyle">
<slot />
</view>
<u-tabbar
v-if="showTabbar"
:value="tabIndex"
:list="tabList"
active-color="#6ACDBB"
@change="onTabChange"
/>
</view>
</template>
<script>
import { getClinicTabList, switchClinicTab } from '../common/tabbar.js'
export default {
name: 'ClinicPageLayout',
props: {
title: { type: String, default: '' },
showBack: { type: Boolean, default: false },
showTabbar: { type: Boolean, default: false },
tabIndex: { type: Number, default: 0 },
},
data() {
return {
statusBarHeight: 0,
navbarHeight: 0,
tabList: [],
}
},
computed: {
bodyStyle() {
const top = this.statusBarHeight + this.navbarHeight
let bottom = '32rpx'
if (this.showTabbar) {
bottom = 'calc(100rpx + env(safe-area-inset-bottom))'
}
return {
paddingTop: top + 'px',
paddingBottom: bottom,
minHeight: '100vh',
boxSizing: 'border-box',
}
},
},
created() {
this.statusBarHeight = this.$statusBarHeight || 0
this.navbarHeight = this.$navbarHeight || 44
if (this.showTabbar) {
this.tabList = getClinicTabList()
}
},
methods: {
onTabChange(index) {
if (index === this.tabIndex) return
switchClinicTab(index)
},
},
}
</script>
<style lang="scss" scoped>
.clinic-layout {
min-height: 100vh;
background: #f9fafb;
}
.clinic-body {
padding-left: 24rpx;
padding-right: 24rpx;
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,212 @@
<template>
<clinic-page-layout title="我的" :show-tabbar="true" :tab-index="1">
<view class="mine-container">
<!-- 个人信息面板通栏 -->
<view class="profile-panel">
<!-- 超椭圆高质感头像 -->
<view class="avatar-squircle">
<text>{{ avatarText }}</text>
</view>
<view class="user-info">
<text class="user-name">{{ displayName }}</text>
<!-- 角色标签替代普通文本提升专业感 -->
<view class="role-badge">
<text class="role-text">诊所管理员</text>
</view>
</view>
</view>
<!-- 信息与设置面板通栏 -->
<view class="setting-panel">
<view class="setting-item">
<text class="item-label">所属诊所</text>
<text class="item-value">{{ storeName }}</text>
</view>
</view>
<!-- 操作面板通栏 -->
<view class="setting-panel">
<view
class="setting-item action-item"
hover-class="item-hover"
:hover-stay-time="100"
@click="logout"
>
<text class="danger-text">退出登录</text>
<!-- 保留原有的 u-icon -->
<u-icon name="arrow-right" color="#C9CDD4" size="28" />
</view>
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
export default {
components: { ClinicPageLayout },
data() {
return {
displayName: '',
storeName: '',
}
},
computed: {
avatarText() {
const n = this.displayName || '管'
return n.slice(0, 1)
},
},
onShow() {
const user = uni.getStorageSync('clinic_admin_user') || {}
this.displayName = user.nick_name || user.name || user.account || '管理员'
this.storeName = user.store_name || (user.store && user.store.name) || '-'
},
methods: {
logout() {
uni.showModal({
title: '提示',
content: '确定退出登录?',
confirmColor: '#f53f3f', // 将确认按钮改为警示红,提升用户体验
success: (res) => {
if (!res.confirm) return
uni.removeStorageSync('clinic_admin_token')
uni.removeStorageSync('clinic_admin_user')
uni.removeStorageSync('loginMode')
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
},
}
</script>
<style lang="scss" scoped>
/* 保持与工作台完全一致的色彩变量 */
$theme-primary: #6acdbb;
$theme-dark: #4db6a8;
$color-page-bg: #F5F7F8;
$color-text-title: #222B2A;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
$color-border: #F2F3F5;
$color-danger: #F53F3F; /* 现代系统标准红 */
.mine-container {
padding: 0 0 60rpx;
background-color: $color-page-bg;
min-height: 100vh;
box-sizing: border-box;
}
/* 个人信息面板:通栏纯白 */
.profile-panel {
display: flex;
align-items: center;
background-color: #ffffff;
padding: 60rpx 40rpx 50rpx; /* 顶部增加呼吸感 */
margin-bottom: 24rpx;
}
/* 超椭圆头像:抛弃死板的纯圆,注入品牌色和微弱发光质感 */
.avatar-squircle {
width: 128rpx;
height: 128rpx;
border-radius: 40rpx; /* 超椭圆倒角 */
background: linear-gradient(135deg, $theme-primary 0%, $theme-dark 100%);
box-shadow: 0 8rpx 24rpx rgba(106, 205, 187, 0.25);
color: #ffffff;
font-size: 52rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-info {
margin-left: 32rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.user-name {
font-size: 40rpx;
font-weight: 700;
color: $color-text-title;
margin-bottom: 12rpx;
letter-spacing: 1rpx;
}
/* 角色标签:用线框+背景色取代纯文本,更具系统身份感 */
.role-badge {
align-self: flex-start;
background-color: rgba(106, 205, 187, 0.1);
border: 1rpx solid rgba(106, 205, 187, 0.2);
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
.role-text {
font-size: 22rpx;
color: $theme-dark;
font-weight: 600;
}
/* 设置项面板(通栏组) */
.setting-panel {
background-color: #ffffff;
margin-bottom: 24rpx;
padding: 0 40rpx;
}
/* 单个设置项 */
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 36rpx 0;
border-bottom: 1rpx solid $color-border;
transition: background-color 0.2s;
}
/* 移除组内最后一个元素的底边框 */
.setting-panel .setting-item:last-child {
border-bottom: none;
}
.item-label {
font-size: 30rpx;
color: $color-text-title;
font-weight: 500;
}
.item-value {
font-size: 30rpx;
color: $color-text-muted;
font-weight: 400;
}
/* 交互列表项 */
.action-item {
/* 扩大点击热区补偿 padding */
margin: 0 -40rpx;
padding: 36rpx 40rpx;
}
.item-hover {
background-color: #F7F8FA;
}
/* 退出登录专用红色 */
.danger-text {
font-size: 30rpx;
color: $color-danger;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<view v-if="showBlock" class="express-panel">
<view class="section-title">物流信息</view>
<template v-if="display.hasData">
<view class="row"><text>物流公司</text><text>{{ display.companyName }}</text></view>
<view class="row"><text>运单号</text><text>{{ display.expressNo }}</text></view>
<view class="row"><text>最新状态</text><text class="highlight-text">{{ display.stateText }}</text></view>
<view v-if="display.tracks.length" class="timeline">
<view class="timeline-title">物流追踪</view>
<view v-for="(t, idx) in display.tracks" :key="idx" class="track-item">
<view class="track-dot" :class="{ 'active': idx === 0 }" />
<view class="track-body">
<text class="track-status" :class="{ 'active': idx === 0 }">{{ t.status }}</text>
<text class="track-detail">{{ t.detail }}</text>
<text class="track-time">{{ t.time }}</text>
</view>
</view>
</view>
</template>
<view v-else class="empty-express">暂无物流信息</view>
</view>
</template>
<script>
import { mapExpressDetail } from '../../common/display.js'
export default {
name: 'ExpressTimeline',
props: {
express: { type: Object, default: null },
show: { type: Boolean, default: true },
},
computed: {
showBlock() {
return this.show
},
display() {
return mapExpressDetail(this.express)
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-text-title: #222B2A;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
$color-border: #F2F3F5;
.express-panel {
background: #ffffff;
padding: 32rpx 40rpx;
margin-bottom: 24rpx;
/* 移除圆角和阴影,采用通栏设计 */
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: $color-text-title;
margin-bottom: 16rpx;
}
.row {
display: flex;
justify-content: space-between;
padding: 24rpx 0;
font-size: 28rpx;
border-bottom: 1rpx solid $color-border;
gap: 24rpx;
text:first-child {
color: $color-text-muted;
}
text:last-child {
color: $color-text-title;
text-align: right;
flex: 1;
word-break: break-all;
}
.highlight-text {
color: $theme-primary;
font-weight: 500;
}
}
.timeline-title {
font-size: 28rpx;
color: $color-text-title;
font-weight: 600;
margin: 32rpx 0 24rpx;
}
.track-item {
display: flex;
padding: 0;
margin-bottom: 16rpx;
}
.track-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: $color-border;
margin-top: 12rpx;
flex-shrink: 0;
border: 4rpx solid #ffffff;
box-shadow: 0 0 0 2rpx $color-border;
&.active {
background: $theme-primary;
box-shadow: 0 0 0 4rpx rgba(106, 205, 187, 0.2);
border-color: #ffffff;
}
}
.track-body {
flex: 1;
margin-left: 28rpx;
padding-bottom: 32rpx;
border-left: 2rpx solid $color-border;
padding-left: 28rpx;
margin-left: 8rpx;
}
/* 最后一个元素去掉线 */
.track-item:last-child .track-body {
border-left-color: transparent;
padding-bottom: 0;
}
.track-status {
display: block;
font-size: 28rpx;
color: $color-text-body;
font-weight: 500;
margin-bottom: 8rpx;
&.active {
color: $theme-primary;
}
}
.track-detail {
display: block;
font-size: 26rpx;
color: $color-text-body;
line-height: 1.5;
margin-bottom: 12rpx;
}
.track-time {
display: block;
font-size: 24rpx;
color: $color-text-muted;
}
.empty-express {
text-align: center;
color: $color-text-muted;
font-size: 26rpx;
padding: 40rpx 0;
}
</style>

View File

@@ -0,0 +1,591 @@
<template>
<u-popup v-model="show" mode="bottom" border-radius="20" height="85%" :closeable="true" @close="onClose">
<view class="popup-wrap">
<scroll-view v-if="view" scroll-y class="popup-scroll">
<view class="container safe-area-inset-bottom">
<!-- 处方头部 -->
<view class="head">
<view class="head_record_top">
<view class="record">
处方编号{{ view.prescriptionNo }}
</view>
<view class="record_state">
{{ view.typeText }}
</view>
</view>
<!-- 动态医院与公章 -->
<view class="head_record">
<image :src="view.sealImage || '/static/group/xkyard.png'" mode="aspectFit"></image>
<view class="record_yard">
<view class="yard">
{{ view.storeName }}
</view>
<view class="state">
处方笺
</view>
</view>
</view>
<!-- 时间 -->
<view class="head_record_time">
<text style="text-align: right;">开具日期{{ view.createdAt }}</text>
</view>
</view>
<!-- 患者信息 -->
<view class="my_info">
<view class="info">
<view class="info_item">
<view class="name">姓名<text>{{ view.patientName }}</text></view>
</view>
<view class="info_item">
<view class="name">性别<text>{{ view.patientSex }}</text></view>
</view>
<view class="info_item">
<view class="name">年龄<text>{{ view.patientAge }}</text></view>
</view>
<view class="info_item">
<view class="name">类别<text>{{ view.category }}</text></view>
</view>
</view>
<view class="info">
<view class="info_item">
<view class="name">科室<text>{{ view.departName }}</text></view>
</view>
<view class="info_item" v-if="view.patientMobile">
<view class="name">电话<text>{{ view.patientMobile }}</text></view>
</view>
</view>
<view class="info">
<view class="info_item">
<view class="names">诊断<text>{{ view.diagnose }}</text></view>
</view>
</view>
<!-- 中医专属字段 -->
<view v-if="view.tcmSyndrome || view.tcmMethod || view.tcmDisease" class="info">
<view v-if="view.tcmSyndrome" class="info_item">
<view class="names">中医证候<text>{{ view.tcmSyndrome }}</text></view>
</view>
<view v-if="view.tcmMethod" class="info_item">
<view class="names">中医治法<text>{{ view.tcmMethod }}</text></view>
</view>
<view v-if="view.tcmDisease" class="info_item">
<view class="names">中医疾病<text>{{ view.tcmDisease }}</text></view>
</view>
</view>
</view>
<view class="bg">
<!-- 处方明细区 (Rp) -->
<view class="my_medical">
<view class="title">Rp</view>
<view v-for="(recipe, rIdx) in view.recipes" :key="rIdx" class="item">
<view class="name">
<view class="_name" v-for="(drug, dIdx) in recipe.drugs" :key="dIdx">
<view class="text">
<text>{{ drug.name }}</text>
<text class="_abbr" v-if="drug.usage">[{{ drug.usage }}]</text>
</view>
<text class="name_num">{{ drug.qty }}</text>
</view>
</view>
<view class="details" v-if="recipe.usage || recipe.process">
<view class="text">
<text v-if="recipe.usage">用法{{ recipe.usage }}</text>
<text v-if="recipe.process" style="margin-left: 10rpx;">包装方式{{ recipe.process }}</text>
</view>
</view>
</view>
</view>
<!-- 医嘱 -->
<view class="doctor_order">
<view class="yz">
<text class="order_info_left">医嘱:</text>
<view class="order_info">
<view class="info">{{ view.doctorOrder !== '-' ? view.doctorOrder : '无' }}</view>
</view>
</view>
<view class="titles">处方开具已完毕</view>
</view>
</view>
<!-- 动态医生与药师签名区 -->
<view class="my_doctor">
<view class="doctor_info">
<view class="info">
<text>医师</text>
<image v-if="view.doctorSignImage" :src="checkImageUrl(view.doctorSignImage)" mode="aspectFit"></image>
<text v-else class="name">{{ view.doctorName }}</text>
</view>
<view class="info">
<text>审核药师</text>
<image v-if="view.pharmacistSignImage" :src="checkImageUrl(view.pharmacistSignImage)" mode="aspectFit"></image>
<text v-else class="name"></text>
</view>
<view class="info">
<text>发药人</text>
<image v-if="view.pharmacistSignImage" :src="checkImageUrl(view.pharmacistSignImage)" mode="aspectFit"></image>
<text v-else class="name"></text>
</view>
<view class="info">
<text>核对人</text>
<image v-if="view.pharmacistSignImage" :src="checkImageUrl(view.pharmacistSignImage)" mode="aspectFit"></image>
<text v-else class="name"></text>
</view>
<view class="info">
<text>调配人</text>
<image v-if="view.pharmacistSignImage" :src="checkImageUrl(view.pharmacistSignImage)" mode="aspectFit"></image>
<text v-else class="name"></text>
</view>
</view>
<!-- 价格 -->
<view class="price" v-if="view.totalPayPrice">
价格 {{ view.totalPayPrice }}
</view>
</view>
<!-- 动态有效期温馨提示 -->
<view class="my_prompt">
<view class="title">
温馨提示请遵医嘱服药处方{{ view.validHours }}小时有效
</view>
<image src="/static/group/img-yzf.png" mode="aspectFit" v-if="view.status == 2"></image>
</view>
</view>
</scroll-view>
<view v-else-if="loading" class="popup-loading">加载中...</view>
<view v-else class="popup-loading">暂无数据</view>
</view>
</u-popup>
</template>
<script>
import { getPrescriptionDetail } from '@/api/clinicAdmin.js'
import { mapPrescriptionDetailView } from '../../common/display.js'
export default {
name: 'PrescriptionDetailPopup',
data() {
return {
show: false,
loading: false,
view: null,
}
},
methods: {
// 解析签名图片,自动兼容 base64 和 http 链接
checkImageUrl(url) {
if (!url) return ''
return url.includes('http') ? url : 'data:image/jpeg;base64,' + url
},
open(prescriptionId) {
if (!prescriptionId) return
this.show = true
this.view = null
this.loading = true
getPrescriptionDetail(prescriptionId).then(w => {
if (w.ok && w.data) {
// 复用原有的处方解析逻辑
const mapped = mapPrescriptionDetailView(w.data)
// --- 动态注入你新版 UI 需要的独有字段 ---
mapped.storeName = w.data.store ? w.data.store.name : '未知诊所'
mapped.sealImage = w.data.store ? w.data.store.offical_seal : ''
mapped.typeText = w.data.type == 1 ? '普通' : '常用'
mapped.totalPayPrice = w.data.total_pay_price || '0.00'
mapped.doctorSignImage = w.data.doctor_sign_image || ''
mapped.pharmacistSignImage = w.data.Pharmacist_sign_image || ''
mapped.validHours = w.data.valid_hours || 72
mapped.status = w.data.status
mapped.patientMobile = w.data.patient ? w.data.patient.mobile : ''
this.view = mapped
}
}).finally(() => {
this.loading = false
})
},
onClose() {
this.view = null
},
},
}
</script>
<style lang="scss" scoped>
.popup-wrap {
height: 100%;
display: flex;
flex-direction: column;
background-color: #fff;
position: relative;
padding-top: 60rpx; /* 给默认的关闭按钮留出空间 */
}
.popup-scroll {
flex: 1;
height: 0;
}
.popup-loading {
text-align: center;
color: #999;
padding: 80rpx;
}
/* --- 原汁原味的 prescriptionDetail.vue 核心样式 (自适应修复版) --- */
.container {
width: 100%;
padding: 0 20rpx 40rpx;
box-sizing: border-box;
.bg {
min-height: 400rpx;
}
.head {
.head_record_top {
width: 100%;
margin: 14rpx auto 0;
display: flex;
align-items: center;
justify-content: space-between;
.record {
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
}
.record_state {
width: 80rpx;
height: 42rpx;
line-height: 42rpx;
text-align: center;
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #A7ABB0;
border: 1rpx solid #C4C7CC;
}
}
.head_record {
display: flex;
align-items: center;
justify-content: center;
position: relative;
image {
width: 160rpx;
height: 150rpx;
position: absolute;
left: 40%; /* 也可以根据需要调整居中印章 */
top: -8rpx;
opacity: 0.2; /* 添加微透明度防止完全遮盖文字 */
z-index: 0;
}
.record_yard {
text-align: center;
z-index: 1;
.yard {
font-size: 46rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
}
.state {
height: 44rpx;
font-size: 32rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
line-height: 38rpx;
}
}
}
.head_record_time {
text-align: right;
margin-top: 20rpx;
width: 100%;
height: 34rpx;
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
line-height: 28rpx;
}
}
// 经典信息区
.my_info {
width: 100%;
margin: 4rpx auto;
border-top: 1rpx solid #31353D;
border-bottom: 1rpx solid #31353D;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 4rpx 4rpx;
.info {
width: 100%;
display: flex;
align-items: center;
flex-wrap: wrap;
.info_item {
.names {
display: flex;
align-items: center;
justify-content: space-around;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
text {
flex: 1;
display: block;
height: 40rpx;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
line-height: 40rpx;
margin: 0 0rpx 0 8rpx;
}
}
.name {
display: flex;
align-items: center;
justify-content: space-around;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
text {
flex: 1;
display: block;
height: 40rpx;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
line-height: 40rpx;
margin: 0 26rpx 0 6rpx;
}
}
}
}
}
// 药品列表
.my_medical {
width: 100%;
margin: 4rpx auto;
padding: 1rpx 10rpx;
box-sizing: border-box;
.title {
font-size: 32rpx;
font-family: PingFang SC-Semibold, PingFang SC;
font-weight: 600;
color: #31353D;
margin-bottom: 2rpx;
}
.item {
width: 100%;
padding: 1rpx 0;
.name {
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
display: flex;
flex-wrap: wrap;
._name {
display: flex;
justify-content: space-between;
margin: 2rpx 80rpx 2rpx 0;
.text {
margin-right: 16rpx;
display: flex;
flex-direction: column;
._abbr {
color: #A7ABB0;
font-size: 20rpx;
margin-top: 2rpx;
}
}
}
}
.details {
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #6C7380;
display: flex;
flex-direction: column;
justify-content: space-around;
.text {
text {
word-spacing: 1rpx;
}
}
}
}
}
// 医嘱
.doctor_order {
width: 100%;
margin: 2rpx auto;
padding: 0 10rpx;
word-spacing: 2rpx;
box-sizing: border-box;
.yz {
display: flex;
width: 100%;
color: #6C7380;
.order_info_left {
margin-right: 3rpx;
}
.order_info {
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
line-height: 40rpx;
margin-bottom: 2rpx;
.info {
flex: 1;
}
}
}
.titles {
width: 100%;
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #A7ABB0;
margin: 8rpx 0 20rpx;
text-align: center;
}
}
// 电子签名及价格
.my_doctor {
width: 100%;
margin: 2rpx auto;
border-top: 1rpx solid #31353D;
border-bottom: 1rpx solid #31353D;
padding: 2rpx 4rpx;
box-sizing: border-box;
.doctor_info {
width: 100%;
display: flex;
align-items: center;
margin-bottom: 4rpx;
flex-wrap: wrap;
.info {
min-width: 222rpx;
max-width: 400rpx;
margin: 5rpx 0 8rpx;
text {
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
}
image {
width: 82rpx;
height: 62rpx;
vertical-align: middle;
margin-left: 10rpx;
}
.name {
display: inline-block;
width: 106rpx;
font-size: 32rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
line-height: 38rpx;
border-bottom: 2rpx solid #000000;
margin-left: 16rpx;
text-align: center;
min-height: 38rpx;
}
}
}
.price {
width: 100%;
font-size: 24rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #31353D;
margin: 8rpx 0;
}
}
// 温馨提示
.my_prompt {
width: 100%;
margin: 0 auto;
padding: 0 4rpx;
position: relative;
box-sizing: border-box;
.title {
width: 100%;
font-size: 28rpx;
font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400;
color: #A7ABB0;
line-height: 40rpx;
margin: 32rpx 0;
}
image {
width: 159rpx;
height: 100rpx;
position: absolute;
left: 60%;
top: 0%;
}
}
}
</style>

View File

@@ -0,0 +1,558 @@
<template>
<clinic-page-layout title="订单详情" :show-back="true">
<view class="detail-container">
<!-- 头部面板通栏展示金额与状态 -->
<view v-if="info.id" class="detail-panel header-panel">
<view class="order-head">
<text class="order-no-main">订单号{{ info.order_no }}</text>
<text class="pay-total">{{ money(info.total_pay_price) }}</text>
</view>
<view class="tags">
<text class="modern-tag tag-status">{{ detailExtra.statusText }}</text>
<text class="modern-tag tag-type">{{ detailExtra.prescriptionTypeText }}</text>
<text class="modern-tag tag-delivery">{{ detailExtra.deliveryText }}</text>
</view>
<view class="sub-block">
<view class="sub-block-title">基本信息</view>
<view class="row"><text>订单类型</text><text>{{ detailExtra.orderTypeText }}</text></view>
<view class="row"><text>患者</text><text>{{ info.patient || '-' }}</text></view>
<view class="row"><text>医生</text><text>{{ doctorName }}</text></view>
<view class="row"><text>处方来源</text><text>{{ storeName }}</text></view>
<view class="row"><text>订单来源</text><text>{{ onlineText(info.is_online) }}</text></view>
<view class="row"><text>ERP状态</text><text>{{ erpSyncText(info.is_sync_erp) }}</text></view>
<view class="row"><text>支付时间</text><text>{{ info.pay_time || '-' }}</text></view>
<view class="row"><text>下单时间</text><text>{{ info.created_at || '-' }}</text></view>
<view class="row" v-if="detailExtra.cancelRemark"><text>发货备注</text><text>{{ detailExtra.cancelRemark }}</text></view>
</view>
<view class="sub-block">
<view class="sub-block-title">费用明细</view>
<view class="row"><text>药品总价</text><text>{{ money(info.items_price) }}</text></view>
<view class="row"><text>供货价</text><text>{{ detailExtra.marketPrice }}</text></view>
<view class="row"><text>运费</text><text>{{ money(info.trans_expenses) }}</text></view>
<view class="row"><text>挂号费</text><text>{{ money(info.register_price) }}</text></view>
<view class="row"><text>是否包邮</text><text>{{ detailExtra.freeShippingText }}</text></view>
</view>
</view>
<!-- 配送信息面板 -->
<view v-if="info.id" class="detail-panel">
<view class="section-title">{{ receiverBlockTitle }}</view>
<view class="row"><text>{{ receiverLabel }}</text><text>{{ receiverName }}</text></view>
<view class="row"><text>联系电话</text><text>{{ receiverMobile }}</text></view>
<view class="row" v-if="info.delivery_method === 0">
<text>配送地址</text><text class="addr">{{ deliveryAddress }}</text>
</view>
<view class="row" v-else>
<text>自提地址</text><text class="addr">{{ pickupAddress }}</text>
</view>
</view>
<!-- 商品列表面板 -->
<view v-if="productItems.length" class="detail-panel">
<view class="section-title">订单商品</view>
<!-- 如果是中药高密度网格排列一行多个小且精致 -->
<view v-if="isTcmOrder" class="tcm-grid-wrap">
<view class="tcm-grid">
<view v-for="(p, idx) in productDisplayList" :key="idx" class="herb-box">
<text class="herb-name">{{ p.drugName }}</text>
<text class="herb-qty">{{ p.qty }}</text>
</view>
</view>
</view>
<!-- 非中药标准西药图文列表 -->
<view v-else>
<view
v-for="(p, idx) in productDisplayList"
:key="idx"
class="product-item"
:class="p.isTcm ? 'product-tcm' : 'product-west'"
>
<image
v-if="!p.isTcm"
class="product-img"
:src="p.imageUrl"
mode="aspectFill"
/>
<view class="product-body">
<view class="product-name">{{ p.drugName }}</view>
<view class="product-meta">
<text v-if="p.drugNumber">编号{{ p.drugNumber }}</text>
<text>数量{{ p.qty }}</text>
</view>
<view class="product-line" v-if="!p.isTcm">规格{{ p.specification }}</view>
<view class="product-line" v-if="!p.isTcm">厂家{{ p.manufacturer }}</view>
<view class="product-price-bar">
<text class="price-main">单价{{ p.price }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 处方信息内页入口 (保留作为备用) -->
<view
v-if="detailExtra.canViewPrescription"
class="detail-panel card-link"
hover-class="panel-hover"
@click="openPrescription"
>
<view class="section-head">
<text class="section-title">处方信息</text>
<text class="section-arrow">查看完整处方 </text>
</view>
<template v-if="detailExtra.showPrescription">
<view class="row"><text>处方编号</text><text>{{ detailExtra.showPrescription.prescriptionNo }}</text></view>
<view class="row"><text>诊断</text><text>{{ detailExtra.showPrescription.diagnose }}</text></view>
<view class="row"><text>医嘱</text><text>{{ detailExtra.showPrescription.doctorOrder }}</text></view>
<view class="row"><text>开药医生</text><text>{{ detailExtra.showPrescription.doctorName }}</text></view>
</template>
<view v-else class="prescription-hint">点击加载完整处方内容</view>
</view>
<!-- 物流时间轴 -->
<express-timeline
v-if="info.id && info.delivery_method === 0"
:express="expressInfo"
:show="true"
/>
<!-- 底部悬浮操作栏移除确认发货全宽查看处方 -->
<view class="actions-bar" v-if="info.id && (canRefund || detailExtra.canViewPrescription)">
<!-- 退款按钮为次要操作 -->
<button v-if="canRefund" class="btn-warn" @click="refund">申请退款</button>
<!-- 查看处方按钮为主操作利用 flex: 1 自动铺满剩余宽度 -->
<button v-if="detailExtra.canViewPrescription" class="btn-primary btn-full" @click="openPrescription">查看处方</button>
</view>
</view>
<!-- 处方弹窗 -->
<prescription-detail-popup ref="prescriptionPopup" />
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import ExpressTimeline from './components/ExpressTimeline.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import {
getOrderDetail,
refundOrder,
getExpressDetailByOrderId,
} from '@/api/clinicAdmin.js'
import {
orderStatusText,
deliveryMethodText,
orderOnlineText,
prescriptionTypeText,
erpSyncText,
mapOrderDetailDisplay,
mapOrderProductItem,
} from '../common/display.js'
import { formatMoney } from '../common/format.js'
export default {
components: { ClinicPageLayout, ExpressTimeline, PrescriptionDetailPopup },
data() {
return {
id: 0,
info: {},
expressInfo: null,
detailExtra: {},
}
},
computed: {
productItems() {
return this.info.product_order_items || []
},
productDisplayList() {
const pt = this.info.prescription_type
return this.productItems.map(p => mapOrderProductItem(p, pt))
},
// 判断是否为中药处方,决定是否使用网格布局
isTcmOrder() {
return this.info.prescription_type === 1 || (this.productDisplayList.length > 0 && this.productDisplayList[0].isTcm);
},
doctorName() {
const d = this.info.doctor
return (d && d.name) ? d.name : '-'
},
storeName() {
const s = this.info.store
return (s && s.name) ? s.name : '-'
},
receiverBlockTitle() {
return this.info.delivery_method === 0 ? '收货人信息' : '就诊人信息'
},
receiverLabel() {
return this.info.delivery_method === 0 ? '收货人' : '就诊人'
},
receiverName() {
if (this.info.delivery_method === 0) {
const a = this.info.address
return (a && a.name) ? a.name : '-'
}
return this.info.express_name || this.info.patient || '-'
},
receiverMobile() {
const a = this.info.address
if (a && a.mobile) return a.mobile
return this.info.patient_mobile || '-'
},
deliveryAddress() {
const a = this.info.address
if (!a || typeof a !== 'object') return '-'
return [a.province, a.region, a.detail_address].filter(Boolean).join(' ') || '-'
},
pickupAddress() {
const s = this.info.store
return (s && s.position) ? s.position : '-'
},
canRefund() {
return this.info.status === 2 || this.info.status === 3
}
},
onLoad(q) {
this.id = Number(q.id || 0)
this.load()
},
methods: {
load() {
getOrderDetail(this.id).then(w => {
if (!w.ok) return
this.info = w.data || {}
this.detailExtra = mapOrderDetailDisplay(this.info)
if (this.info.delivery_method === 0) {
this.loadExpress()
}
})
},
loadExpress() {
getExpressDetailByOrderId(this.id).then(w => {
if (w.ok) this.expressInfo = w.data
})
},
money(v) {
return formatMoney(v)
},
statusText(s) {
return orderStatusText(s)
},
deliveryText(dm) {
return deliveryMethodText(dm)
},
onlineText(v) {
return orderOnlineText(v)
},
prescriptionText(pt) {
return prescriptionTypeText(pt)
},
erpSyncText(v) {
return erpSyncText(v)
},
refund() {
uni.showModal({
title: '确认退款',
confirmColor: '#F53F3F',
success: (r) => {
if (!r.confirm) return
refundOrder({ id: this.id }).then(w => {
if (w.ok) {
uni.showToast({ title: '已提交' })
this.load()
}
})
},
})
},
openPrescription() {
const pid = this.detailExtra.prescriptionId
if (!pid) return
this.$refs.prescriptionPopup.open(pid)
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-page-bg: #F5F7F8;
$color-text-title: #222B2A;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
$color-border: #F2F3F5;
$color-danger: #F53F3F;
.detail-container {
background-color: $color-page-bg;
min-height: 100vh;
/* 适配底部操作栏,留出极大的安全边距 */
padding-bottom: 180rpx;
}
/* 通栏面板 */
.detail-panel {
background: #ffffff;
padding: 32rpx 40rpx;
margin-bottom: 24rpx;
}
.header-panel {
padding-top: 40rpx;
}
.order-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.order-no-main {
font-size: 28rpx;
color: $color-text-muted;
}
.pay-total {
font-size: 40rpx;
color: $color-text-title;
font-weight: 700;
font-family: monospace, sans-serif;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 32rpx;
}
.modern-tag {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 8rpx;
font-weight: 500;
}
.tag-status { background: rgba(106, 205, 187, 0.1); color: $theme-primary; }
.tag-type { background: rgba(230, 162, 60, 0.1); color: #e6a23c; }
.tag-delivery { background: rgba(91, 127, 214, 0.1); color: #5b7fd6; }
.sub-block {
margin-top: 32rpx;
}
.sub-block-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-title;
margin-bottom: 8rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid $color-border;
}
.section-title {
font-size: 32rpx;
font-weight: 700;
color: $color-text-title;
margin-bottom: 16rpx;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.section-arrow {
font-size: 26rpx;
color: $theme-primary;
}
.panel-hover {
background-color: #FAFAFA;
}
.prescription-hint {
font-size: 26rpx;
color: $color-text-muted;
padding: 16rpx 0;
}
.row {
display: flex;
justify-content: space-between;
padding: 20rpx 0;
font-size: 28rpx;
border-bottom: 1rpx dashed $color-border;
gap: 24rpx;
&:last-child {
border-bottom: none;
}
text:first-child {
color: $color-text-muted;
}
text:last-child {
color: $color-text-title;
text-align: right;
flex: 1;
word-break: break-all;
}
}
.addr { max-width: 460rpx; line-height: 1.4; }
/* ================= 专属中药网格区 ================= */
.tcm-grid-wrap {
margin-top: 24rpx;
background: #F8FBFB;
border-radius: 16rpx;
padding: 24rpx;
}
.tcm-grid {
display: grid;
grid-template-columns: repeat(4, 1fr); /* 一排严格4个精致小巧 */
gap: 16rpx;
}
.herb-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #ffffff;
border-radius: 12rpx;
padding: 16rpx 8rpx;
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.015);
border: 1rpx solid #EAEFEF;
}
.herb-name {
font-size: 26rpx;
color: $color-text-title;
font-weight: 600;
margin-bottom: 6rpx;
text-align: center;
}
.herb-qty {
font-size: 24rpx;
color: $theme-primary;
font-weight: 500;
}
/* 商品信息 (西药模式) */
.product-item {
display: flex;
padding: 32rpx 0;
border-bottom: 1rpx solid $color-border;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.product-img {
width: 140rpx;
height: 140rpx;
border-radius: 20rpx;
flex-shrink: 0;
background: $color-page-bg;
border: 1rpx solid $color-border;
}
.product-body {
flex: 1;
margin-left: 24rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.product-name {
font-size: 30rpx;
color: $color-text-title;
font-weight: 600;
margin-bottom: 8rpx;
}
.product-meta {
display: flex;
gap: 24rpx;
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 8rpx;
}
.product-line {
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 6rpx;
}
.product-price-bar {
margin-top: auto;
display: flex;
justify-content: flex-end;
align-items: center;
}
.price-main { font-size: 28rpx; color: $color-text-title; font-weight: 600; }
/* ================= 底部动作栏 ================= */
.actions-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 24rpx 40rpx;
/* 自动撑起 iOS 底部安全距离 */
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 24rpx rgba(0,0,0,0.04);
display: flex;
gap: 24rpx;
z-index: 99;
}
.actions-bar button {
margin: 0;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
border-radius: 44rpx;
font-weight: 600;
}
.actions-bar button::after { display: none; }
/* 主按钮使用 flex: 1 铺满 */
.btn-full {
flex: 1;
}
.btn-primary {
background: $theme-primary;
color: #fff;
}
/* 次要操作(退款)给固定宽度 */
.btn-warn {
width: 220rpx;
background: #fff;
color: $color-danger;
border: 2rpx solid $color-danger;
}
</style>

View File

@@ -0,0 +1,582 @@
<template>
<clinic-page-layout title="商品订单" :show-back="true">
<view class="order-list-container">
<!-- 顶部筛选 -->
<page-sticky-header>
<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="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">
<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>
<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>
<view class="filter-query-btn" @click="reload">查询</view>
</collapsible-filter-panel>
</page-sticky-header>
<!-- 统计卡片区 -->
<scroll-view v-if="statItems.length" class="stats-scroll" 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>
<!-- 顶级悬浮卡片列表 -->
<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)"
>
<!-- 顶部单号与状态 -->
<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>
</view>
</view>
<view class="price-wrap">
<text class="currency">¥</text>
<text class="amount">{{ rowDisplay(item).totalPayPrice.replace('¥', '').trim() }}</text>
</view>
</view>
<!-- 信息岛 (Info Island)聚合次要信息的高级灰底区块 -->
<view class="info-island">
<view class="island-row" v-if="rowDisplay(item).userNickname !== '-'">
<text class="island-label">就诊人</text>
<text class="island-value">{{ 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>
</view>
<prescription-detail-popup ref="prescriptionPopup" />
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PageStickyHeader from '../components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '../components/CollapsibleFilterPanel.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import { getOrderList, getOrderStatusOption, getOrderSaleAmount } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapOrderListRow } from '../common/display.js'
import { formatMoney } from '../common/format.js'
import {
buildOrderListParams,
buildOrderSaleAmountParams,
} from '../common/orderParams.js'
import {
loadOrderFilter,
saveOrderFilter,
clearOrderFilter,
defaultOrderFilter,
} from '../common/filterCache.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, PageStickyHeader, CollapsibleFilterPanel, PrescriptionDetailPopup },
data() {
return {
list: [],
page: 1,
loading: false,
orderNo: '',
statusOptions: [{ label: '全部', value: '' }],
statusIndex: 0,
deliveryIndex: 0,
prescriptionTypeIndex: 0,
dateStart: '',
dateEnd: '',
statItems: [],
}
},
computed: {
statusLabels() {
return this.statusOptions.map(s => s.label || s.name || '全部')
},
deliveryLabels() {
return DELIVERY_OPTIONS.map(o => o.label)
},
prescriptionTypeLabels() {
return PRESCRIPTION_TYPE_OPTIONS.map(o => o.label)
},
},
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 => ({
label: o.label || o.name,
value: o.value != null ? o.value : o.id,
})))
}
})
this.reload()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
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()
},
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,
}
},
rowDisplay(item) {
return mapOrderListRow(item)
},
reload() {
this.persistOrderFilter()
this.page = 1
this.load(1, false)
this.loadSaleAmount()
},
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) },
]
})
},
load(page, append) {
this.loading = true
const params = buildOrderListParams({
page,
pageSize: 15,
...this.filterValues(),
})
getOrderList(params).then(w => {
const items = withWxKey((w.data && w.data.items) || [], 'id', 'ord')
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
},
onStatus(e) {
this.statusIndex = Number(e.detail.value)
},
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)
},
},
}
</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;
min-height: 100vh;
padding-bottom: 60rpx;
}
/* 筛选区 */
.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;
}
.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;
}
</style>

View File

@@ -0,0 +1,799 @@
<template>
<clinic-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>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PageStickyHeader from '../components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '../components/CollapsibleFilterPanel.vue'
import {
getReconciliationList,
getReconciliationDrugList,
getReconciliationDrugOptions,
getReconciliationOrderOptions,
} from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import {
mapReconciliationStoreRow,
mapReconciliationDrugRow,
mapReconciliationStoreCount,
mapReconciliationDrugCount,
} from '../common/display.js'
import {
buildStoreListParams,
buildDrugListParams,
buildSearchTimeQueryParams,
normalizeReconciliationResponse,
} from '../common/reconciliationParams.js'
import {
loadReconciliationFilter,
saveReconciliationFilter,
clearReconciliationFilter,
defaultReconciliationFilter,
} from '../common/filterCache.js'
export default {
components: { ClinicPageLayout, 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 api = isDrug ? getReconciliationDrugList : 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,
})
api(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')
}).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
}
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
}
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>

View File

@@ -0,0 +1,130 @@
<template>
<u-popup v-model="show" mode="center" border-radius="16" width="85%" :closeable="true" @close="onClose">
<view class="modal">
<view class="modal-title">修改售价</view>
<view v-if="drugName" class="modal-drug">{{ drugName }}</view>
<view v-if="suggestPrice" class="modal-hint">建议售价 {{ suggestPrice }}</view>
<input
class="modal-input"
type="digit"
v-model="priceInput"
placeholder="请输入新售价"
/>
<view class="modal-actions">
<button class="btn cancel" @click="onClose">取消</button>
<button class="btn confirm" :loading="submitting" @click="onConfirm">确定</button>
</view>
</view>
</u-popup>
</template>
<script>
export default {
name: 'PriceEditModal',
props: {
visible: { type: Boolean, default: false },
item: { type: Object, default: null },
},
data() {
return {
show: false,
priceInput: '',
submitting: false,
}
},
computed: {
drugName() {
const drug = (this.item && this.item.drug) || {}
return drug.drug_name || ''
},
suggestPrice() {
if (!this.item) return ''
const drug = this.item.drug || {}
const storeDrug = drug.drug_store_drug || {}
const p = storeDrug.price != null ? storeDrug.price : drug.price
if (p == null || p === '') return ''
return '¥' + Number(p).toFixed(2)
},
},
watch: {
visible: {
immediate: true,
handler(v) {
this.show = v
if (v && this.item) {
const raw = this.item.price
this.priceInput = raw != null && raw !== '' ? String(raw) : ''
}
},
},
show(v) {
if (!v) this.$emit('close')
},
},
methods: {
onClose() {
this.show = false
this.$emit('close')
},
onConfirm() {
const price = parseFloat(String(this.priceInput).trim())
if (!price || price <= 0 || Number.isNaN(price)) {
uni.showToast({ title: '请输入有效售价', icon: 'none' })
return
}
const rounded = Math.round(price * 100) / 100
this.$emit('confirm', rounded)
},
},
}
</script>
<style lang="scss" scoped>
.modal {
padding: 40rpx 32rpx 32rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
text-align: center;
}
.modal-drug {
font-size: 26rpx;
color: #666;
margin-top: 16rpx;
text-align: center;
}
.modal-hint {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
text-align: center;
}
.modal-input {
margin-top: 32rpx;
padding: 20rpx 24rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
}
.modal-actions {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
}
.btn {
flex: 1;
font-size: 28rpx;
border-radius: 44rpx;
margin: 0;
}
.btn.cancel {
background: #f5f5f5;
color: #666;
}
.btn.confirm {
background: #6acdbb;
color: #fff;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<clinic-page-layout title="药品详情" :show-back="true">
<view v-if="info.id" class="card">
<image class="hero" :src="display.imageUrl" mode="aspectFit" />
<view class="row"><text>药品名称</text><text>{{ display.drugName }}</text></view>
<view class="row"><text>拼音</text><text>{{ display.pinyin }}</text></view>
<view class="row"><text>供货价</text><text>{{ display.buyPrice }}</text></view>
<view class="row"><text>建议售价</text><text>{{ display.suggestPrice }}</text></view>
<view class="row"><text>售价</text><text>{{ display.price }}</text></view>
<view class="row"><text>库存</text><text>{{ display.stock }}</text></view>
<view class="row"><text>状态</text><text>{{ display.statusText }}</text></view>
</view>
<view v-if="info.id" class="btn-group">
<button class="btn secondary" @click="openPriceEdit">修改售价</button>
<button class="btn primary" @click="toggleStatus">
{{ display.isOnShelf ? '下架' : '上架' }}
</button>
</view>
<price-edit-modal
:visible="priceModalVisible"
:item="info.id ? info : null"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue'
import {
getWarehouseDetail,
updateWarehouseStatus,
updateWarehousePrice,
} from '@/api/clinicAdmin.js'
import { mapWarehouseRow } from '../common/display.js'
export default {
components: { ClinicPageLayout, PriceEditModal },
data() {
return {
id: 0,
info: {},
priceModalVisible: false,
priceSubmitting: false,
}
},
computed: {
display() {
return mapWarehouseRow(this.info)
},
},
onLoad(q) {
this.id = Number(q.id || 0)
this.load()
},
methods: {
load() {
getWarehouseDetail(this.id).then(w => {
if (w.ok) this.info = w.data || {}
})
},
openPriceEdit() {
this.priceModalVisible = true
},
closePriceEdit() {
this.priceModalVisible = false
},
onPriceConfirm(price) {
if (this.priceSubmitting) return
this.priceSubmitting = true
updateWarehousePrice({ id: this.id, price }).then(w => {
if (w.ok) {
uni.showToast({ title: '修改成功' })
this.closePriceEdit()
this.load()
}
}).finally(() => { this.priceSubmitting = false })
},
toggleStatus() {
updateWarehouseStatus(this.id).then(w => {
if (w.ok) {
uni.showToast({ title: '操作成功' })
this.load()
}
})
},
},
}
</script>
<style lang="scss" scoped>
.card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 32rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.hero {
width: 100%;
height: 360rpx;
margin-bottom: 24rpx;
border-radius: 12rpx;
background: #f8f8f8;
}
.row {
display: flex;
justify-content: space-between;
padding: 16rpx 0;
font-size: 28rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.btn-group {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.btn {
border-radius: 44rpx;
font-size: 30rpx;
margin: 0;
}
.btn.primary {
background: #6acdbb;
color: #fff;
}
.btn.secondary {
background: #fff;
color: #6acdbb;
border: 1rpx solid #6acdbb;
}
</style>

View File

@@ -0,0 +1,231 @@
<template>
<clinic-page-layout :title="pageTitle" :show-back="true">
<view class="search">
<input v-model="keyword" placeholder="搜索药品名称" @confirm="reload" />
<text @click="reload">搜索</text>
</view>
<view
v-for="item in list"
:key="item._wxKey"
class="item"
@click="goDetail(item)"
>
<image class="thumb" :src="rowDisplay(item).imageUrl" mode="aspectFill" />
<view class="body">
<view class="title">{{ rowDisplay(item).drugName }}</view>
<view class="sub">供货 {{ rowDisplay(item).buyPrice }} · 售价 {{ rowDisplay(item).price }}</view>
<view class="sub">库存 {{ rowDisplay(item).stock }}</view>
</view>
<view class="actions" @click.stop>
<view class="tag" :class="rowDisplay(item).isOnShelf ? 'on' : 'off'">{{ rowDisplay(item).statusText }}</view>
<view class="btn-row">
<text class="act-btn" @click="openPriceEdit(item)">改价</text>
<text class="act-btn shelf" @click="toggleStatus(item)">
{{ rowDisplay(item).isOnShelf ? '下架' : '上架' }}
</text>
</view>
</view>
</view>
<view v-if="!loading && !list.length" class="empty">暂无药品</view>
<price-edit-modal
:visible="priceModalVisible"
:item="priceEditItem"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue'
import {
getWarehouseList,
updateWarehouseStatus,
updateWarehousePrice,
} from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapWarehouseRow } from '../common/display.js'
export default {
components: { ClinicPageLayout, PriceEditModal },
data() {
return {
pageTitle: '仓库管理',
warehouseType: '',
keyword: '',
list: [],
page: 1,
loading: false,
priceModalVisible: false,
priceEditItem: null,
priceSubmitting: false,
}
},
onLoad(q) {
if (q.label) {
this.pageTitle = decodeURIComponent(q.label)
}
if (q.type != null && q.type !== '') {
this.warehouseType = q.type
}
this.reload()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
rowDisplay(item) {
return mapWarehouseRow(item)
},
reload() {
this.page = 1
this.load(1, false)
},
load(page, append) {
this.loading = true
const params = { page, pageSize: 20 }
if (this.keyword) params.name = this.keyword
if (this.warehouseType !== '' && this.warehouseType != null) {
params.type = this.warehouseType
}
getWarehouseList(params).then(w => {
const items = withWxKey((w.data && w.data.items) || [], 'id', 'wh')
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
},
goDetail(item) {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/warehouse/detail?id=' + item.id })
},
openPriceEdit(item) {
this.priceEditItem = item
this.priceModalVisible = true
},
closePriceEdit() {
this.priceModalVisible = false
this.priceEditItem = null
},
onPriceConfirm(price) {
if (!this.priceEditItem || this.priceSubmitting) return
this.priceSubmitting = true
updateWarehousePrice({ id: this.priceEditItem.id, price }).then(w => {
if (w.ok) {
uni.showToast({ title: '修改成功' })
this.closePriceEdit()
this.reload()
}
}).finally(() => { this.priceSubmitting = false })
},
toggleStatus(item) {
const action = item.status === 2 ? '下架' : '上架'
uni.showModal({
title: '提示',
content: '确定' + action + '「' + ((item.drug && item.drug.drug_name) || '') + '」?',
success: (res) => {
if (!res.confirm) return
updateWarehouseStatus(item.id).then(w => {
if (w.ok) {
uni.showToast({ title: '操作成功' })
item.status = item.status === 2 ? 1 : 2
}
})
},
})
},
},
}
</script>
<style lang="scss" scoped>
.search {
display: flex;
background: #fff;
padding: 16rpx 24rpx;
border-radius: 16rpx;
margin-bottom: 24rpx;
align-items: center;
}
.search input {
flex: 1;
font-size: 28rpx;
}
.search text {
color: #6acdbb;
font-size: 28rpx;
margin-left: 16rpx;
}
.item {
display: flex;
align-items: flex-start;
background: #fff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.03);
}
.thumb {
width: 100rpx;
height: 100rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: #f5f5f5;
}
.body {
flex: 1;
margin-left: 20rpx;
min-width: 0;
padding-right: 12rpx;
}
.title {
font-size: 28rpx;
color: #333;
}
.sub {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12rpx;
}
.tag {
font-size: 22rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.tag.on {
color: #6acdbb;
background: rgba(106, 205, 187, 0.12);
}
.tag.off {
color: #999;
background: #f5f5f5;
}
.btn-row {
display: flex;
flex-direction: column;
gap: 8rpx;
align-items: flex-end;
}
.act-btn {
font-size: 24rpx;
color: #6acdbb;
padding: 4rpx 0;
}
.act-btn.shelf {
color: #666;
}
.empty {
text-align: center;
color: #999;
padding: 60rpx;
}
</style>

View File

@@ -0,0 +1,103 @@
<template>
<clinic-page-layout title="申请提现" :show-back="true">
<view class="tip">可提现余额{{ balanceText }}</view>
<view class="field">
<text class="label">提现金额</text>
<input v-model="amount" type="digit" placeholder="请输入金额" />
</view>
<view class="quick">
<text v-for="q in quickAmounts" :key="q.key" class="q-btn" @click="amount = String(q.value)">{{ q.value }}</text>
</view>
<button class="submit" @click="submit" :loading="loading">提交申请</button>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import { formatMoney } from '../common/format.js'
import { getClinicAdminBalance, submitWithdrawal } from '@/api/clinicAdmin.js'
export default {
components: { ClinicPageLayout },
data() {
return {
amount: '',
balanceText: '-',
loading: false,
quickAmounts: [
{ key: '1000', value: 1000 },
{ key: '3000', value: 3000 },
{ key: '5000', value: 5000 },
{ key: '10000', value: 10000 },
],
}
},
onLoad() {
getClinicAdminBalance().then(w => {
if (w.ok && w.data) {
const b = w.data.balance
this.balanceText = b != null ? formatMoney(b) : '-'
}
})
},
methods: {
submit() {
const amt = Number(this.amount)
if (!amt || amt <= 0) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
this.loading = true
submitWithdrawal({ amount: amt }).then(w => {
if (w.ok) {
uni.showToast({ title: '提交成功' })
setTimeout(() => uni.navigateBack(), 800)
} else {
uni.showToast({ title: (w.res && w.res.message) || '提交失败', icon: 'none' })
}
}).finally(() => { this.loading = false })
},
},
}
</script>
<style lang="scss" scoped>
.tip {
font-size: 28rpx;
color: #666;
margin-bottom: 32rpx;
background: #fff;
padding: 24rpx;
border-radius: 16rpx;
}
.field {
background: #fff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.label {
display: block;
font-size: 26rpx;
color: #999;
margin-bottom: 12rpx;
}
.quick {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 48rpx;
}
.q-btn {
padding: 12rpx 24rpx;
background: #eef8f6;
color: #6acdbb;
border-radius: 8rpx;
font-size: 26rpx;
}
.submit {
background: #6acdbb;
color: #fff;
border-radius: 44rpx;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<clinic-page-layout title="编辑账户" :show-back="true">
<view class="form-container">
<view class="form-panel">
<view class="form-row" v-for="f in fields" :key="f.key">
<text class="row-label">{{ f.label }}</text>
<input
class="row-input"
v-model="form[f.key]"
:placeholder="'请输入' + f.label"
placeholder-style="color:#C9CDD4"
/>
</view>
</view>
<view class="action-footer">
<button class="btn-submit" @click="save" :loading="loading">保存修改</button>
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import { getClinicAdminCard, saveClinicAdminCard } from '@/api/clinicAdmin.js'
export default {
components: { ClinicPageLayout },
data() {
return {
loading: false,
form: {
bank_user_name: '',
bank_card: '',
bank_name: '',
bank_no: '',
bank_account_type: '1',
},
fields: [
{ key: 'bank_user_name', label: '户名' },
{ key: 'bank_card', label: '银行卡号' },
{ key: 'bank_name', label: '开户行' },
{ key: 'bank_no', label: '行号' },
],
}
},
onLoad() {
getClinicAdminCard().then(w => {
if (!w.ok || !w.data) return
const d = w.data
this.form.bank_user_name = d.bank_user_name || ''
this.form.bank_card = d.bank_card || ''
this.form.bank_name = d.bank_name || ''
this.form.bank_no = d.bank_no || ''
this.form.bank_account_type = String(d.bank_account_type || 1)
})
},
methods: {
save() {
this.loading = true
saveClinicAdminCard(this.form).then(w => {
if (w.ok) {
uni.showToast({ title: '保存成功' })
setTimeout(() => uni.navigateBack(), 800)
} else {
uni.showToast({ title: (w.res && w.res.message) || '保存失败', icon: 'none' })
}
}).finally(() => { this.loading = false })
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-muted: #4E5969;
.form-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx;
}
.form-panel {
background: #ffffff;
border-radius: 24rpx;
padding: 0 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
}
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 36rpx 0;
border-bottom: 1rpx solid #F2F3F5;
&:last-child {
border-bottom: none;
}
}
.row-label {
font-size: 28rpx;
color: $color-text-main;
font-weight: 500;
min-width: 140rpx;
}
.row-input {
flex: 1;
text-align: right; /* 右对齐显得极其整洁 */
font-size: 28rpx;
color: $color-text-main;
}
.action-footer {
margin-top: 60rpx;
}
.btn-submit {
background: $theme-primary;
color: #fff;
font-size: 32rpx;
font-weight: 600;
height: 96rpx;
line-height: 96rpx;
border-radius: 48rpx;
border: none;
&::after {
display: none;
}
&:active {
opacity: 0.9;
}
}
</style>

View File

@@ -0,0 +1,415 @@
<template>
<clinic-page-layout title="提现管理" :show-back="true">
<view class="withdrawal-container">
<!-- 高级虚拟银行卡片 -->
<view class="premium-bank-card">
<view class="card-top">
<text class="bank-name">{{ cardTitle || '银行卡' }}</text>
<text class="card-type">储蓄卡</text>
</view>
<!-- 使用完整的 maskCard 方法处理卡号显示 -->
<view class="card-number">
<text class="card-val">{{ maskCard(card.bank_card) || '**** **** **** ----' }}</text>
</view>
<view class="card-bottom">
<text class="bank-sub">开户行{{ card.bank_name || '-' }}</text>
</view>
<!-- 装饰几何图案 -->
<view class="card-decorator"></view>
</view>
<!-- 核心统计网格 -->
<view class="stats-grid">
<view v-for="b in balanceItems" :key="b.key" class="stat-island">
<text class="stat-label">{{ b.label }}</text>
<text class="stat-val">{{ b.display }}</text>
</view>
</view>
<!-- 金刚区操作栏 -->
<view class="action-bar">
<view v-for="m in actions" :key="m.key" class="action-item" hover-class="action-hover" @click="onAction(m.key)">
<text class="action-text">{{ m.label }}</text>
</view>
</view>
<!-- 提现记录列表 -->
<view class="list-section">
<view class="section-hd">
<text class="section-title">提现记录</text>
</view>
<view class="record-list">
<view
v-for="item in withdrawalList"
:key="item._wxKey"
class="record-item"
hover-class="item-hover"
@click="goWithdrawalDetail(item)"
>
<view class="record-main">
<text class="record-title">提现到银行卡</text>
<!-- 三元表达式处理颜色完美兼容微信小程序 -->
<text class="record-amount" :class="item.status === 1 ? 'amount-success' : ''">-{{ item.amount || '0.00' }}</text>
</view>
<view class="record-sub">
<text class="record-time">{{ item.time || '-' }}</text>
<text
class="record-status"
:class="item.status === 1 ? 'text-success' : (item.status === 2 || item.status === 3 ? 'text-danger' : 'text-warning')"
>
{{ item.statusText || '处理中' }}
</text>
</view>
</view>
<view v-if="withdrawalList.length === 0" class="empty-state">
暂无提现记录
</view>
</view>
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import { getClinicAdminCard, getClinicAdminBalance, getWithdrawalList } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import { formatMoney } from '../common/format.js'
import { withdrawalCheckStatusText, formatDateTime } from '../common/display.js'
export default {
components: { ClinicPageLayout },
data() {
return {
cardTitle: '我的账户',
card: {
bank_card: '',
bank_name: ''
},
balanceItems: [
{ key: 'balance', label: '可提现余额 (元)', display: '0.00' },
{ key: 'frozen', label: '冻结金额 (元)', display: '0.00' }
],
actions: [
{ key: 'apply', label: '申请提现' },
{ key: 'edit', label: '修改账户' },
{ key: 'settlement', label: '结算记录' }
],
withdrawalList: [],
page: 1,
loading: false
}
},
onShow() {
this.loadCard();
this.loadBalance();
this.reloadList();
},
onReachBottom() {
if (!this.loading) {
this.loadList(this.page + 1, true);
}
},
methods: {
// 银行卡号掩码处理,保留后四位
maskCard(cardStr) {
if(!cardStr) return '**** **** **** ----';
const last4 = cardStr.slice(-4);
return `**** **** **** ${last4}`;
},
// 快捷操作跳转
onAction(key) {
if (key === 'apply') {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/withdrawal/apply' });
} else if (key === 'edit') {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/withdrawal/card-edit' });
} else if (key === 'settlement') {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/withdrawal/settlement/index' });
}
},
// 提现详情跳转
goWithdrawalDetail(item) {
uni.navigateTo({ url: `/subPackages/sub_clinic_admin/withdrawal/record-detail?id=${item.id}` });
},
// 获取绑定的银行卡信息
loadCard() {
getClinicAdminCard().then(w => {
if (w.ok && w.data) {
this.card = w.data;
this.cardTitle = w.data.bank_user_name || '我的账户';
}
});
},
// 获取余额数据
loadBalance() {
getClinicAdminBalance().then(w => {
if (w.ok && w.data) {
// 格式化金额并去除 ¥ 符号以便大字号展示
this.balanceItems[0].display = formatMoney(w.data.balance || 0).replace('¥', '');
this.balanceItems[1].display = formatMoney(w.data.frozen_balance || 0).replace('¥', '');
}
});
},
// 刷新提现列表
reloadList() {
this.page = 1;
this.loadList(1, false);
},
// 加载提现列表数据
loadList(page, append) {
this.loading = true;
getWithdrawalList({ page, pageSize: 15 }).then(w => {
if (w.ok && w.data) {
const items = (w.data.items || []).map(item => ({
...item,
amount: formatMoney(item.apply_cash).replace('¥', ''),
time: formatDateTime(item.apply_time),
status: item.check_status,
statusText: withdrawalCheckStatusText(item.check_status)
}));
const listWithKey = withWxKey(items, 'id', 'wd');
this.withdrawalList = append ? this.withdrawalList.concat(listWithKey) : listWithKey;
this.page = page;
}
}).finally(() => {
this.loading = false;
});
}
}
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
.withdrawal-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx 24rpx 60rpx;
}
/* 高级虚拟银行卡 */
.premium-bank-card {
position: relative;
background: linear-gradient(135deg, #2C303A 0%, #1A1D24 100%);
border-radius: 32rpx;
padding: 40rpx;
color: #fff;
box-shadow: 0 16rpx 32rpx rgba(26, 29, 36, 0.15);
margin-bottom: 24rpx;
overflow: hidden;
}
.card-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40rpx;
position: relative;
z-index: 2;
}
.bank-name {
font-size: 32rpx;
font-weight: 600;
letter-spacing: 2rpx;
}
.card-type {
font-size: 24rpx;
color: rgba(255,255,255,0.6);
}
.card-number {
font-family: monospace, sans-serif;
font-size: 44rpx;
font-weight: 600;
margin-bottom: 40rpx;
position: relative;
z-index: 2;
letter-spacing: 2rpx;
}
.card-bottom {
font-size: 24rpx;
color: rgba(255,255,255,0.6);
position: relative;
z-index: 2;
}
.card-decorator {
position: absolute;
right: -40rpx;
bottom: -60rpx;
width: 240rpx;
height: 240rpx;
border: 40rpx solid rgba(255,255,255,0.03);
border-radius: 50%;
z-index: 1;
}
/* 统计网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-bottom: 24rpx;
}
.stat-island {
background: #fff;
border-radius: 24rpx;
padding: 32rpx 24rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.stat-label {
font-size: 24rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
}
.stat-val {
font-size: 36rpx;
font-weight: 700;
color: $color-text-main;
font-family: DIN Alternate, monospace, sans-serif;
}
/* 金刚区操作栏 */
.action-bar {
display: flex;
background: #fff;
border-radius: 24rpx;
margin-bottom: 32rpx;
padding: 12rpx 0;
}
.action-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0;
position: relative;
transition: background-color 0.2s;
&:not(:last-child)::after {
content: '';
position: absolute;
right: 0;
top: 30%;
height: 40%;
width: 2rpx;
background-color: #F2F3F5;
}
}
.action-hover {
background-color: #FAFAFA;
}
.action-text {
font-size: 26rpx;
font-weight: 500;
color: $color-text-main;
}
/* 提现记录列表 */
.list-section {
background: #fff;
border-radius: 24rpx;
padding: 32rpx 24rpx;
}
.section-hd {
margin-bottom: 24rpx;
padding-left: 8rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 700;
color: $color-text-main;
}
.record-item {
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #F2F3F5;
transition: background-color 0.2s;
&:last-child {
border-bottom: none;
}
}
.item-hover {
background-color: #F7F8FA;
border-radius: 12rpx;
}
.record-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.record-title {
font-size: 28rpx;
color: $color-text-main;
font-weight: 500;
}
.record-amount {
font-size: 32rpx;
font-weight: 700;
color: $color-text-main;
font-family: DIN Alternate, monospace, sans-serif;
&.amount-success {
color: $theme-primary; /* 成功的金额变绿 */
}
}
.record-sub {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
}
.record-time {
color: $color-text-muted;
}
.record-status {
font-weight: 500;
}
.text-success { color: $theme-primary; }
.text-danger { color: #F53F3F; }
.text-warning { color: #F5A623; }
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 60rpx 0;
font-size: 26rpx;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<clinic-page-layout title="提现详情" :show-back="true">
<view class="detail-container">
<view v-if="info.id" class="detail-card">
<!-- 顶部焦点区域 -->
<view class="hero-section">
<text class="hero-label">实际到账 ()</text>
<text class="hero-amount">{{ formatMoney(info.true_cash) }}</text>
<!-- 修复使用三元表达式代替 methods 函数调用 -->
<view
class="status-badge"
:class="info.check_status === 1 ? 'badge-success' : (info.check_status === 3 ? 'badge-danger' : 'badge-warning')"
>
{{ checkStatusText(info.check_status) }}
</view>
</view>
<!-- 明细信息 -->
<view class="info-list">
<view class="row"><text class="label">订单号</text><text class="value font-mono">{{ info.order_no || '-' }}</text></view>
<view class="row"><text class="label">申请金额</text><text class="value">{{ formatMoney(info.apply_cash) }}</text></view>
<view class="row"><text class="label">手续费</text><text class="value">{{ formatMoney(info.charge_cash) }}</text></view>
<view class="row"><text class="label">申请时间</text><text class="value">{{ formatDateTime(info.apply_time) }}</text></view>
<!-- 审核说明突出显示拒绝原因 -->
<view class="row" v-if="info.check_result">
<text class="label">审核说明</text>
<text class="value" :class="{'text-danger': info.check_status === 3}">{{ info.check_result }}</text>
</view>
<view class="divider"></view>
<view class="row"><text class="label">打款状态</text><text class="value">{{ dakuanStatusText(info.dakuan_status) }}</text></view>
<view class="row" v-if="info.dakuan_time"><text class="label">打款时间</text><text class="value">{{ formatDateTime(info.dakuan_time) }}</text></view>
</view>
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../components/clinic-page-layout.vue'
import { formatMoney } from '../common/format.js'
import {
formatDateTime,
withdrawalCheckStatusText,
dakuanStatusText as dakuanStatusLabel,
} from '../common/display.js'
import { getWithdrawalDetail } from '@/api/clinicAdmin.js'
export default {
components: { ClinicPageLayout },
data() {
return { id: 0, info: {} }
},
onLoad(q) {
this.id = Number(q.id || 0)
getWithdrawalDetail(this.id).then(w => {
if (w.ok) this.info = w.data || {}
})
},
methods: {
formatMoney,
formatDateTime,
checkStatusText(s) {
return withdrawalCheckStatusText(s)
},
dakuanStatusText(s) {
return dakuanStatusLabel(s)
}
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-muted: #86909C;
.detail-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx;
}
.detail-card {
background: #ffffff;
border-radius: 24rpx;
padding: 48rpx 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
}
.hero-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 60rpx;
}
.hero-label {
font-size: 26rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
}
.hero-amount {
font-size: 64rpx;
font-weight: 700;
color: $color-text-main;
font-family: DIN Alternate, monospace, sans-serif;
margin-bottom: 24rpx;
}
.status-badge {
font-size: 24rpx;
padding: 6rpx 24rpx;
border-radius: 30rpx;
font-weight: 500;
&.badge-success { background: rgba(106, 205, 187, 0.1); color: $theme-primary; }
&.badge-warning { background: #FFF3E8; color: #FF7D00; }
&.badge-danger { background: #FFECE8; color: #F53F3F; }
}
.info-list {
background: #F8FBFB;
border-radius: 16rpx;
padding: 24rpx 32rpx;
}
.row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16rpx 0;
font-size: 26rpx;
line-height: 1.5;
}
.label {
color: $color-text-muted;
min-width: 120rpx;
}
.value {
color: $color-text-main;
text-align: right;
flex: 1;
word-break: break-all;
}
.font-mono {
font-family: SF Pro Text, monospace, sans-serif;
}
.text-danger {
color: #F53F3F;
font-weight: 500;
}
.divider {
height: 1rpx;
background-color: #EAEFEF;
margin: 16rpx 0;
}
</style>

View File

@@ -0,0 +1,196 @@
<template>
<clinic-page-layout title="结算明细" :show-back="true">
<view class="detail-container">
<!-- 顶部焦点区域: 订单号和合计金额 -->
<view class="hero-card" v-if="detail.order_no">
<text class="hero-label">订单号{{ detail.order_no }}</text>
<text class="hero-amount">{{ formatMoney(detail.sum_money) }}</text>
</view>
<!-- 汇总信息岛 -->
<view class="info-island" v-if="ledgerLogSummary">
<view class="island-title">结算汇总</view>
<view class="row"><text class="label">费用类型</text><text class="value">{{ ledgerLogSummary.fee_type_txt || '-' }}</text></view>
<view class="row"><text class="label">结算类型</text><text class="value">{{ ledgerLogSummary.type_txt || '-' }}</text></view>
<view class="row"><text class="label">结算总额</text><text class="value text-primary font-bold">{{ formatMoney(ledgerLogSummary.amount) }}</text></view>
<view class="row" v-if="ledgerLogSummary.content"><text class="label">说明</text><text class="value">{{ ledgerLogSummary.content }}</text></view>
</view>
<!-- 分账明细列表 -->
<view v-if="ledgerItems.length">
<view class="section-title">分账明细</view>
<view class="list-wrap">
<view v-for="row in ledgerItems" :key="row._wxKey" class="detail-card">
<view class="row"><text class="label">费用类型</text><text class="value">{{ row.fee_type_txt || '-' }}</text></view>
<view class="row"><text class="label">分账金额</text><text class="value amount-text">{{ formatMoney(row.money) }}</text></view>
<view class="row">
<text class="label">结算状态</text>
<text class="value" :class="row.status_txt === '已结算' ? 'text-success' : ''">{{ row.status_txt || '-' }}</text>
</view>
</view>
</view>
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue'
import { formatMoney } from '../../common/format.js'
import { getLedgerDetail } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
export default {
components: { ClinicPageLayout },
data() {
return {
ledgerLogId: 0,
detail: { items: [], sum_money: '0' },
ledgerLogSummary: null,
ledgerItems: [],
loading: true,
}
},
onLoad(q) {
this.ledgerLogId = Number(q.ledger_log_id || 0)
this.load()
},
methods: {
formatMoney,
load() {
this.loading = true
getLedgerDetail({ ledger_log_id: this.ledgerLogId }).then(w => {
if (w.ok) {
this.detail = w.data || {}
const log = this.detail.ledger_log
if (log) {
this.ledgerLogSummary = log
}
this.ledgerItems = withWxKey(this.detail.items || [], 'id', 'led')
}
}).finally(() => { this.loading = false })
},
},
}
</script>
<style lang="scss" scoped>
$theme-primary: #6acdbb;
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-muted: #86909C;
.detail-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx;
}
/* 巨幕卡片 */
.hero-card {
display: flex;
flex-direction: column;
align-items: center;
background: #ffffff;
border-radius: 24rpx;
padding: 48rpx 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
}
.hero-label {
font-size: 26rpx;
color: $color-text-muted;
margin-bottom: 12rpx;
font-family: SF Pro Text, monospace, sans-serif;
}
.hero-amount {
font-size: 64rpx;
font-weight: 700;
color: $color-text-main;
font-family: DIN Alternate, monospace, sans-serif;
}
/* 汇总信息岛 */
.info-island {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
}
.island-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-main;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #F2F3F5;
}
/* 分账明细 */
.section-title {
font-size: 28rpx;
font-weight: 600;
color: $color-text-muted;
margin-bottom: 16rpx;
padding-left: 8rpx;
}
.list-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.detail-card {
background: #F8FBFB;
border-radius: 16rpx;
padding: 24rpx 32rpx;
}
/* 通用行排版 */
.row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 12rpx 0;
font-size: 26rpx;
line-height: 1.5;
}
.label {
color: $color-text-muted;
min-width: 120rpx;
}
.value {
color: $color-text-main;
text-align: right;
flex: 1;
word-break: break-all;
&.text-primary {
color: $theme-primary;
}
&.font-bold {
font-weight: 700;
font-size: 32rpx;
font-family: DIN Alternate, monospace, sans-serif;
}
}
.amount-text {
font-weight: 600;
font-family: DIN Alternate, monospace, sans-serif;
}
.text-success {
color: $theme-primary;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,194 @@
<template>
<clinic-page-layout title="结算记录" :show-back="true">
<view class="settlement-container">
<view class="list-wrap">
<view
v-for="item in list"
:key="item.id"
class="premium-card"
hover-class="card-hover"
@click="goDetail(item)"
>
<view class="card-head">
<view class="order-no">
<text class="tag"></text>
<text class="no-text">{{ rowDisplay(item).orderNo }}</text>
</view>
<text class="amount" :class="rowDisplay(item).amountClass">
{{ rowDisplay(item).typeText }} {{ rowDisplay(item).amountText }}
</text>
</view>
<view class="card-body">
<view class="info-row">
<text class="label">费用类型</text>
<text class="value">{{ rowDisplay(item).feeTypeText }}</text>
</view>
<view class="info-row">
<text class="label">结算时间</text>
<text class="value">{{ rowDisplay(item).createdAt }}</text>
</view>
<view class="info-row" v-if="rowDisplay(item).content">
<text class="label">费用说明</text>
<text class="value">{{ rowDisplay(item).content }}</text>
</view>
</view>
</view>
</view>
<view v-if="!loading && list.length === 0" class="empty-state">
暂无结算记录
</view>
</view>
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue'
import { mapSettlementRow } from '../../common/display.js'
import { getLedgerLogList } from '@/api/clinicAdmin.js'
export default {
components: { ClinicPageLayout },
data() {
return {
list: [],
page: 1,
loading: false,
}
},
onShow() {
this.reload()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
rowDisplay(item) {
return mapSettlementRow(item)
},
reload() {
this.load(1, false)
},
load(page, append) {
this.loading = true
getLedgerLogList({ page, pageSize: 15 }).then(w => {
const items = (w.data && w.data.items) || []
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
},
goDetail(item) {
uni.navigateTo({
url: '/subPackages/sub_clinic_admin/withdrawal/settlement/detail?ledger_log_id=' + item.id,
})
},
},
}
</script>
<style lang="scss" scoped>
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
.settlement-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx;
}
.list-wrap {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.premium-card {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
transition: background-color 0.2s;
}
.card-hover {
background-color: #FAFAFA;
transform: scale(0.98);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
border-bottom: 1rpx dashed #E5E6EB;
padding-bottom: 24rpx;
}
.order-no {
display: flex;
align-items: center;
gap: 12rpx;
}
.tag {
font-size: 20rpx;
color: #fff;
background-color: #C9CDD4;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-weight: 600;
}
.no-text {
font-size: 26rpx;
color: $color-text-main;
font-family: monospace, sans-serif;
}
.amount {
font-size: 32rpx;
font-weight: 700;
font-family: DIN Alternate, monospace, sans-serif;
}
/* 兼容你原有的类名逻辑 */
.text-danger { color: #F53F3F !important; }
.text-success { color: #6acdbb !important; }
.text-warning { color: #F5A623 !important; }
.card-body {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.info-row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
line-height: 1.5;
}
.label {
color: $color-text-muted;
min-width: 120rpx;
}
.value {
color: $color-text-body;
text-align: right;
flex: 1;
word-break: break-all;
}
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 120rpx 0;
font-size: 28rpx;
}
</style>

23
utils/wxListKey.js Normal file
View File

@@ -0,0 +1,23 @@
/**
* 微信小程序 v-for :key 仅支持简单属性访问,不支持模板表达式。
* 在 JS 中为列表项预生成 _wxKey 后,模板使用 :key="item._wxKey"
*/
export function withWxKey(list, idField = 'id', fallbackPrefix = 'i') {
return (list || []).map((item, index) => ({
...item,
_wxKey: item[idField] != null && item[idField] !== ''
? String(item[idField])
: `${fallbackPrefix}${index}`,
}))
}
/** 单条消息/列表项补全 _wxKey用于 push、$set 等增量更新) */
export function ensureWxKey(item, idField = 'id', fallback) {
if (!item || typeof item !== 'object') return item
if (item._wxKey != null && item._wxKey !== '') return item
const id = item[idField]
return {
...item,
_wxKey: id != null && id !== '' ? String(id) : String(fallback),
}
}