1. 优化门店管理员的功能

This commit is contained in:
李琦
2026-06-05 12:28:36 +08:00
parent 777dd139a2
commit c5b574da98
120 changed files with 15818 additions and 615 deletions

191
api/businessApi.js Normal file
View File

@@ -0,0 +1,191 @@
import { req } from '@/common/js/index.js';
import { unwrapClinicRes } from '@/api/clinicAdmin.js';
/**
* 按前缀生成业务 API订单/对账/提现/仓库等)
*/
export function createBusinessApi(prefix, headerFlag) {
const headerKey = headerFlag || '_businessAdmin';
function bizRequest(options) {
return req.request({
...options,
header: {
...(options.header || {}),
[headerKey]: '1',
},
}).then((res) => {
const data = unwrapClinicRes(res);
const ok = res && (res.code === 0 || (res.errcode != null && Number(res.errcode) !== -1));
return { res, data, ok };
});
}
return {
prefix,
getMyInfo() {
return bizRequest({ url: `${prefix}-auth/my-info`, method: 'GET' });
},
getInputStats() {
return bizRequest({ url: `${prefix}-auth/input-stats`, method: 'GET' });
},
getMyBalance() {
return bizRequest({ url: `${prefix}-account/my-balance`, method: 'GET' });
},
getMyCard() {
return bizRequest({ url: `${prefix}-account/my-card`, method: 'GET' });
},
saveMyCard(data) {
return bizRequest({ url: `${prefix}-account/save-card`, method: 'POST', data });
},
getLedgerLogList(params) {
return bizRequest({ url: `${prefix}-settlement/ledger-log-list`, method: 'GET', data: params });
},
getLedgerDetail(params) {
return bizRequest({ url: `${prefix}-settlement/ledger-detail`, method: 'GET', data: params });
},
getAccountChangeLogList(params) {
return bizRequest({ url: `${prefix}-account-change-log/list`, method: 'GET', data: params });
},
getWithdrawalList(params) {
return bizRequest({ url: `${prefix}-withdrawal/list`, method: 'GET', data: params });
},
getWithdrawalAuditList(params) {
return bizRequest({ url: `${prefix}-withdrawal/audit-list`, method: 'GET', data: params });
},
getWithdrawalDetail(id) {
return bizRequest({ url: `${prefix}-withdrawal/detail`, method: 'GET', data: { id } });
},
submitWithdrawal(data) {
return bizRequest({ url: `${prefix}-withdrawal/withdrawal`, method: 'POST', data });
},
passWithdrawal(data) {
return bizRequest({ url: `${prefix}-withdrawal/pass-application`, method: 'POST', data });
},
refuseWithdrawal(data) {
return bizRequest({ url: `${prefix}-withdrawal/refuse-application`, method: 'POST', data });
},
getReconciliationList(params) {
return bizRequest({ url: `${prefix}-reconciliation/list`, method: 'GET', data: params });
},
getReconciliationDrugList(params) {
return bizRequest({ url: `${prefix}-reconciliation/drug-list`, method: 'GET', data: params });
},
getReconciliationDrugOptions(params) {
return bizRequest({ url: `${prefix}-reconciliation/drug-options`, method: 'GET', data: params });
},
getReconciliationOrderOptions(params) {
return bizRequest({ url: `${prefix}-reconciliation/order-options`, method: 'GET', data: params });
},
getOrderSaleAmount(params) {
return bizRequest({ url: `${prefix}-order/sale-amount`, method: 'GET', data: params });
},
getPrescriptionDetail(id) {
return bizRequest({ url: `${prefix}-prescription/detail`, method: 'GET', data: { id } });
},
getOrderList(params) {
return bizRequest({ url: `${prefix}-order/list`, method: 'GET', data: params });
},
getOrderStatusOption() {
return bizRequest({ url: `${prefix}-order/status-option`, method: 'GET' });
},
getOrderDetail(id) {
return bizRequest({ url: `${prefix}-order/detail`, method: 'GET', data: { id } });
},
getOrderTrace(params) {
return bizRequest({ url: `${prefix}-order/trace`, method: 'GET', data: params });
},
getOrderLedgerDetail(params) {
return bizRequest({ url: `${prefix}-order/ledger-detail`, method: 'GET', data: params });
},
getOrderReconciliationDetail(params) {
return bizRequest({ url: `${prefix}-order/reconciliation-detail`, method: 'GET', data: params });
},
refundOrder(data) {
return bizRequest({ url: `${prefix}-order/refund`, method: 'POST', data });
},
wareSendOrder(data) {
return bizRequest({ url: `${prefix}-order/ware-send`, method: 'POST', data });
},
getExpressDetailByOrderId(orderId) {
return bizRequest({
url: `${prefix}-express-detail/detail-by-order`,
method: 'POST',
data: { order_id: orderId },
});
},
getWarehouseList(params) {
return bizRequest({ url: `${prefix}-warehouse/list`, method: 'GET', data: params });
},
getWarehouseDetail(id) {
return bizRequest({ url: `${prefix}-warehouse/detail`, method: 'GET', data: { id } });
},
updateWarehouseStatus(id) {
return bizRequest({ url: `${prefix}-warehouse/update-status`, method: 'GET', data: { id } });
},
updateWarehousePrice(data) {
return bizRequest({ url: `${prefix}-warehouse/update`, method: 'POST', data });
},
getWarehouseProductTypeOptions() {
return bizRequest({ url: `${prefix}-warehouse/product-type-options`, method: 'GET' });
},
getStoreOption() {
return bizRequest({ url: `${prefix}-store/option`, method: 'GET' });
},
getStoreInputList(params) {
return bizRequest({ url: `${prefix}-store-input/list`, method: 'GET', data: params });
},
getStoreInputDetail(id) {
return bizRequest({ url: `${prefix}-store-input/detail`, method: 'GET', data: { id } });
},
getPromoterOptions() {
return bizRequest({ url: `${prefix}-store-input/promoter-options`, method: 'GET' });
},
getStoreInputOption() {
return bizRequest({ url: `${prefix}-store-input/option`, method: 'GET' });
},
createStoreInput(data) {
return bizRequest({ url: `${prefix}-store-input/create`, method: 'POST', data });
},
updateStoreInput(data) {
return bizRequest({ url: `${prefix}-store-input/update`, method: 'POST', data });
},
auditStoreInput(data) {
return bizRequest({ url: `${prefix}-store-input/audit`, method: 'POST', data });
},
getDoctorInputList(params) {
return bizRequest({ url: `${prefix}-doctor-input/list`, method: 'GET', data: params });
},
getDoctorInputDetail(id) {
return bizRequest({ url: `${prefix}-doctor-input/detail`, method: 'GET', data: { id } });
},
createDoctorInput(data) {
return bizRequest({ url: `${prefix}-doctor-input/create`, method: 'POST', data });
},
updateDoctorInput(data) {
return bizRequest({ url: `${prefix}-doctor-input/update`, method: 'POST', data });
},
auditDoctorInput(data) {
return bizRequest({ url: `${prefix}-doctor-input/audit`, method: 'POST', data });
},
getDoctorTitleOption() {
return bizRequest({
url: `${prefix}-pharmacist/title-option`,
method: 'GET',
data: { type: 1 },
});
},
getDepartOption() {
return bizRequest({ url: `${prefix}-doctor/depart-option`, method: 'GET' });
},
};
}
export const platformAdminApi = createBusinessApi('/newApi/platform-admin', '_platformAdmin');
export const salespersonApi = createBusinessApi('/newApi/salesperson', '_salesperson');
export function getActiveBusinessApi() {
const mode = uni.getStorageSync('loginMode');
if (mode === 'platform_admin') return platformAdminApi;
if (mode === 'salesperson') return salespersonApi;
return null;
}

View File

@@ -47,6 +47,10 @@ export function getClinicAdminMyInfo() {
return clinicRequest({ url: `${PREFIX}-auth/my-info`, method: 'GET' }); return clinicRequest({ url: `${PREFIX}-auth/my-info`, method: 'GET' });
} }
export function getClinicAdminStoreQr() {
return clinicRequest({ url: `${PREFIX}-auth/store-qr`, method: 'GET' });
}
export function getClinicAdminBalance() { export function getClinicAdminBalance() {
return clinicRequest({ url: `${PREFIX}-account/my-balance`, method: 'GET' }); return clinicRequest({ url: `${PREFIX}-account/my-balance`, method: 'GET' });
} }
@@ -59,6 +63,10 @@ export function saveClinicAdminCard(data) {
return clinicRequest({ url: `${PREFIX}-account/save-card`, method: 'POST', data }); return clinicRequest({ url: `${PREFIX}-account/save-card`, method: 'POST', data });
} }
export const getMyBalance = getClinicAdminBalance;
export const getMyCard = getClinicAdminCard;
export const saveMyCard = saveClinicAdminCard;
export function getLedgerLogList(params) { export function getLedgerLogList(params) {
return clinicRequest({ url: `${PREFIX}-settlement/ledger-log-list`, method: 'GET', data: params }); return clinicRequest({ url: `${PREFIX}-settlement/ledger-log-list`, method: 'GET', data: params });
} }

39
api/clinicSalesperson.js Normal file
View File

@@ -0,0 +1,39 @@
import { req } from '@/common/js/index.js';
import { unwrapClinicRes } from '@/api/clinicAdmin.js';
const PREFIX = '/newApi/clinic-salesperson-self';
const AUTH_PREFIX = '/newApi/clinic-salesperson-auth';
function clinicSalespersonRequest(options) {
return req.request({
...options,
header: {
...(options.header || {}),
_clinicSalesperson: '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 getClinicSalespersonMyInfo() {
return clinicSalespersonRequest({ url: `${AUTH_PREFIX}/my-info`, method: 'GET' });
}
export function getClinicSalespersonCommissionList(params) {
return clinicSalespersonRequest({ url: `${PREFIX}/commission-list`, method: 'GET', data: params });
}
export function getClinicSalespersonSettlementList(params) {
return clinicSalespersonRequest({ url: `${PREFIX}/settlement-list`, method: 'GET', data: params });
}
export function getClinicSalespersonSettlementDetail(id) {
return clinicSalespersonRequest({ url: `${PREFIX}/settlement-detail`, method: 'GET', data: { id } });
}
export function getClinicSalespersonUserBindList(params) {
return clinicSalespersonRequest({ url: `${PREFIX}/user-bind-list`, method: 'GET', data: params });
}

5
api/platformAdmin.js Normal file
View File

@@ -0,0 +1,5 @@
import { platformAdminApi } from '@/api/businessApi.js';
export { platformAdminApi };
export const getPlatformAdminMyInfo = () => platformAdminApi.getMyInfo();
export default platformAdminApi;

5
api/salesperson.js Normal file
View File

@@ -0,0 +1,5 @@
import { salespersonApi } from '@/api/businessApi.js';
export const getSalespersonMyInfo = salespersonApi.getMyInfo.bind(salespersonApi);
export const getSalespersonInputStats = salespersonApi.getInputStats.bind(salespersonApi);
export default salespersonApi;

95
api/upload.js Normal file
View File

@@ -0,0 +1,95 @@
import { req } from '@/common/js/index.js';
const SALESPERSON_PREFIX = '/newApi/salesperson-upload/';
const PLATFORM_PREFIX = '/newApi/platform-admin-upload/';
function uploadChatFileByPrefix(prefix, headerKey, params) {
return req.request({
url: `${prefix}upload-chat-file`,
type: 'upload',
method: 'POST',
filePath: params.filePath,
name: params.name || 'file',
formData: params.formData || {},
header: {
[headerKey]: '1',
},
});
}
/**
* 业务员录入/聊天同款:服务端转存 OSS返回 url
*/
export function uploadSalespersonChatFileApi(params) {
return uploadChatFileByPrefix(SALESPERSON_PREFIX, '_salesperson', params);
}
/**
* 平台管理员录入上传(与业务员对称)
*/
export function uploadPlatformAdminChatFileApi(params) {
return uploadChatFileByPrefix(PLATFORM_PREFIX, '_platformAdmin', params);
}
/**
* 按当前 loginMode 选择上传接口
*/
export function uploadBusinessChatFileApi(params) {
const mode = uni.getStorageSync('loginMode');
if (mode === 'platform_admin') {
return uploadPlatformAdminChatFileApi(params);
}
return uploadSalespersonChatFileApi(params);
}
function unwrapUploadResult(res) {
if (res == null) return null;
if (res.result !== undefined && res.result !== null) return res.result;
if (res.data && res.data.result !== undefined) return res.data.result;
return res.data != null ? res.data : res;
}
export function extractUploadUrl(res) {
const inner = unwrapUploadResult(res);
if (!inner) return null;
return inner.url || inner.path || null;
}
const LEGACY_ADMIN_UPLOAD_URL = 'https://api.xiaokang88.com/open-api/upload/old-admin-img';
/**
* 录入/管理端旧版图片上传(与 d-upload、select-avatar-image 一致)
*/
export function uploadLegacyAdminImage(params) {
const { filePath, name = 'file', formData = { store_id: '11001' } } = params || {};
return new Promise((resolve, reject) => {
uni.uploadFile({
url: LEGACY_ADMIN_UPLOAD_URL,
filePath,
name,
formData,
header: {
authorization: `Bearer ${uni.getStorageSync('token')}`,
},
success: (uploadRes) => {
try {
const data =
typeof uploadRes.data === 'string' ? JSON.parse(uploadRes.data) : uploadRes.data;
if (![200, 201, 204].includes(uploadRes.statusCode)) {
reject(new Error((data && data.msg) || '上传失败'));
return;
}
const url = data && data.data && data.data.url;
if (!url) {
reject(new Error('上传失败'));
return;
}
resolve(url);
} catch (e) {
reject(e);
}
},
fail: reject,
});
});
}

View File

@@ -366,18 +366,24 @@ export default class {
return new Promise((resolve, rej) => { return new Promise((resolve, rej) => {
uni.canvasToTempFilePath({ uni.canvasToTempFilePath({
canvasId: this.canvasName, canvasId: this.canvasName,
fileType: 'png',
quality: 1,
success: (res) => { success: (res) => {
uni.getFileSystemManager().readFile({ uni.getFileSystemManager().readFile({
filePath: res.tempFilePath, filePath: res.tempFilePath,
encoding: 'base64', encoding: 'base64',
success: r => { success: (r) => {
resolve(r.data); resolve({
} base64: r.data,
}) tempFilePath: res.tempFilePath,
});
},
fail: (err) => rej(err),
});
}, },
fail: function(err) { fail: (err) => {
rej(err); rej(err);
} },
}, _this) }, _this)
}) })
} }

View File

@@ -17,7 +17,8 @@ const arr = [
'patient_mobile', 'patient_mobile',
'id_card', 'id_card',
'idcard', 'idcard',
'password' 'password',
'phone'
] ]
// 敏感数据(解密后做脱敏;需与下方 switch 分支一致) // 敏感数据(解密后做脱敏;需与下方 switch 分支一致)
@@ -26,7 +27,8 @@ const sensitiveData = [
'idcard', 'idcard',
'mobile', 'mobile',
'express_mobile', 'express_mobile',
'patient_mobile' 'patient_mobile',
'phone'
] ]
/** /**
* 请求拦截器 * 请求拦截器
@@ -43,22 +45,50 @@ const reqInterceptor = async (options) => {
const skipToken = options.header.isToken === false const skipToken = options.header.isToken === false
const isClinicAdminReq = options.header._clinicAdmin === '1' const isClinicAdminReq = options.header._clinicAdmin === '1'
|| (options.url && String(options.url).indexOf('clinic-admin') !== -1) || (options.url && String(options.url).indexOf('clinic-admin') !== -1)
const isPlatformAdminReq = options.header._platformAdmin === '1'
|| (options.url && String(options.url).indexOf('platform-admin') !== -1)
const isSalespersonReq = options.header._salesperson === '1'
|| (options.url && String(options.url).indexOf('salesperson-') !== -1)
const isClinicSalespersonReq = options.header._clinicSalesperson === '1'
|| (options.url && String(options.url).indexOf('clinic-salesperson-') !== -1)
const isDoctorWxAuthReq = options.url && String(options.url).indexOf('doctor-wx-auth') !== -1 const isDoctorWxAuthReq = options.url && String(options.url).indexOf('doctor-wx-auth') !== -1
// 内部控制位,不得作为 HTTP 头发出 // 内部控制位,不得作为 HTTP 头发出
delete options.header.isToken delete options.header.isToken
delete options.header._clinicAdmin delete options.header._clinicAdmin
delete options.header._platformAdmin
delete options.header._salesperson
delete options.header._clinicSalesperson
const clinicToken = uni.getStorageSync('clinic_admin_token') const clinicToken = uni.getStorageSync('clinic_admin_token')
const platformToken = uni.getStorageSync('platform_admin_token')
const salespersonToken = uni.getStorageSync('salesperson_token')
const clinicSalespersonToken = uni.getStorageSync('clinic_salesperson_token')
const doctorToken = uni.getStorageSync('token') const doctorToken = uni.getStorageSync('token')
const loginMode = uni.getStorageSync('loginMode') const loginMode = uni.getStorageSync('loginMode')
if (!skipToken) { if (!skipToken) {
if (isDoctorWxAuthReq) { if (isDoctorWxAuthReq) {
if (loginMode === 'clinic_admin' && clinicToken) { if (loginMode === 'platform_admin' && platformToken) {
options.header['authorization'] = `Bearer ${platformToken}`
} else if (loginMode === 'salesperson' && salespersonToken) {
options.header['authorization'] = `Bearer ${salespersonToken}`
} else if (loginMode === 'clinic_salesperson' && clinicSalespersonToken) {
options.header['authorization'] = `Bearer ${clinicSalespersonToken}`
} else if (loginMode === 'clinic_admin' && clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}` options.header['authorization'] = `Bearer ${clinicToken}`
} else if (doctorToken) { } else if (doctorToken) {
options.header['authorization'] = `Bearer ${doctorToken}` options.header['authorization'] = `Bearer ${doctorToken}`
} else if (platformToken) {
options.header['authorization'] = `Bearer ${platformToken}`
} else if (salespersonToken) {
options.header['authorization'] = `Bearer ${salespersonToken}`
} else if (clinicToken) { } else if (clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}` options.header['authorization'] = `Bearer ${clinicToken}`
} }
} else if (isPlatformAdminReq && platformToken) {
options.header['authorization'] = `Bearer ${platformToken}`
} else if (isSalespersonReq && salespersonToken) {
options.header['authorization'] = `Bearer ${salespersonToken}`
} else if (isClinicSalespersonReq && clinicSalespersonToken) {
options.header['authorization'] = `Bearer ${clinicSalespersonToken}`
} else if (isClinicAdminReq && clinicToken) { } else if (isClinicAdminReq && clinicToken) {
options.header['authorization'] = `Bearer ${clinicToken}` options.header['authorization'] = `Bearer ${clinicToken}`
} else if (doctorToken) { } else if (doctorToken) {
@@ -109,8 +139,20 @@ const resInterceptor = (response, conf = {}) => {
icon: 'error', icon: 'error',
title: response['msg'] || response['message'] || errorCode[statusCode] title: response['msg'] || response['message'] || errorCode[statusCode]
}) })
const isClinic = uni.getStorageSync('loginMode') === 'clinic_admin' const loginMode = uni.getStorageSync('loginMode')
if (isClinic) { if (loginMode === 'platform_admin') {
uni.removeStorageSync('platform_admin_token')
uni.removeStorageSync('platform_admin_user')
uni.removeStorageSync('loginMode')
} else if (loginMode === 'salesperson') {
uni.removeStorageSync('salesperson_token')
uni.removeStorageSync('salesperson_user')
uni.removeStorageSync('loginMode')
} else if (loginMode === 'clinic_salesperson') {
uni.removeStorageSync('clinic_salesperson_token')
uni.removeStorageSync('clinic_salesperson_user')
uni.removeStorageSync('loginMode')
} else if (loginMode === 'clinic_admin') {
uni.removeStorageSync('clinic_admin_token') uni.removeStorageSync('clinic_admin_token')
uni.removeStorageSync('clinic_admin_user') uni.removeStorageSync('clinic_admin_user')
uni.removeStorageSync('loginMode') uni.removeStorageSync('loginMode')
@@ -182,6 +224,7 @@ function getRes(obj, isDecode = true) {
} else { } else {
// 判断obj[key]是否在arr中 // 判断obj[key]是否在arr中
if (arr.indexOf(key) !== -1) { if (arr.indexOf(key) !== -1) {
if (obj[key] == null || obj[key] === '') continue
let aseFile = '' let aseFile = ''
if (isDecode === true) { if (isDecode === true) {
aseFile = customBase64Decode(obj[key]) aseFile = customBase64Decode(obj[key])
@@ -207,6 +250,7 @@ function getRes(obj, isDecode = true) {
break; break;
case 'mobile': case 'mobile':
case 'express_mobile': case 'express_mobile':
case 'phone':
// 保留前三位和后四位,中间用星号代替 // 保留前三位和后四位,中间用星号代替
aseFile = aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') + aseFile.slice(-4); aseFile = aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') + aseFile.slice(-4);
break; break;
@@ -233,6 +277,7 @@ function getRes(obj, isDecode = true) {
} }
function customBase64Decode(data) { function customBase64Decode(data) {
if (data == null || data === '') return data
// return data // return data
try { try {
// 第一次Base64解码 // 第一次Base64解码

View File

@@ -1,112 +1,310 @@
<template> <template>
<view> <view>
<u-popup v-model="show" mode="bottom" :closeable="true" @close="show=false" close-icon-pos='top-right'
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="usePageContainer && show"
:show="show"
:overlay="false"
@leave="close"
/>
<!-- #endif -->
<u-popup v-model="show" mode="bottom" :closeable="true" @close="close" close-icon-pos='top-right'
:safe-area-inset-bottom="true" :mask-close-able="false"> :safe-area-inset-bottom="true" :mask-close-able="false">
<view class="handwritten flex-col flex-ali-center"> <view class="handwritten flex-col flex-ali-center">
<text class="title">签字确认</text> <text class="title">签字确认</text>
<view class=""> <view class="">
<canvas :style="{width: '686rpx',height: '480rpx',border:'1px solid #2979FF'}" disable-scroll="true" <canvas :style="{width: '686rpx',height: '480rpx',border:'1px solid #2979FF'}" disable-scroll="true"
data-id="1" @touchstart="penStart" @touchmove="penMove" @touchend="penEnd" data-id="1" @touchstart="penStart" @touchmove="penMove" @touchend="penEnd"
canvas-id="canvas"></canvas> canvas-id="canvas"></canvas>
</view> </view>
<view class="flex-row" style="margin-top: 80rpx;"> <view class="flex-row" style="margin-top: 80rpx;">
<view class="retDraw" @click="retDraw">重写</view> <view class="retDraw" @click="retDraw">重写</view>
<view style="width: 343rpx;height: 86rpx;"> <view style="width: 343rpx;height: 86rpx;">
<u-button :throttle-time="0" @click="saveCanvas();loading=true" type="primary" :loading="loading"
<u-button :throttle-time="0" @click="onConfirm" type="primary" :loading="loading"
:custom-style="{borderRadius:'0'}">确定</u-button> :custom-style="{borderRadius:'0'}">确定</u-button>
</view> </view>
</view> </view>
</view> </view>
</u-popup> </u-popup>
</view> </view>
</template> </template>
<script> <script>
import Mycanvas from "@/common/js/handwriting.js"; import Mycanvas from "@/common/js/handwriting.js";
export default { export default {
name: 'd-handwritten', name: 'd-handwritten',
props: { props: {
// 是否显示d-handwritten
value: { value: {
type: Boolean, type: Boolean,
default: false default: false
}, },
usePageContainer: {
type: Boolean,
default: false
},
}, },
data() { data() {
return { return {
show: this.value, show: this.value,
canvas: '',
canvas: null,
loading: false loading: false
} }
}, },
watch: { watch: {
value() {
this.show = this.value value(v) {
}
this.show = v
},
show(v) {
this.$emit('input', v)
if (v) {
this.$nextTick(() => this.initCanvas())
}
},
}, },
methods: {
// 清除画布 mounted() {
retDraw() {
this.canvas.retDraw();
},
// 笔迹开始
penStart(event) {
this.canvas.penStart(event);
},
// 笔迹移动
penMove(event) {
this.canvas.penMove(event);
},
// 笔迹结束
penEnd(event) {
this.canvas.penEnd(event);
},
// 保存
async saveCanvas() {
let pic = await this.canvas.saveCanvas(this);
this.loading = false;
this.$emit('saveCanvas', pic)
}
},
onReady() {
this.$nextTick(() => { this.$nextTick(() => {
this.canvas = new Mycanvas({
lineColor: this.lineColor, if (this.show) this.initCanvas()
lineSise: this.lineSise,
canvasName: "canvas"
}, this)
}) })
}
},
methods: {
initCanvas() {
this.canvas = new Mycanvas({
lineColor: '#1A1A1A',
lineSise: 50,
canvasName: 'canvas',
}, this)
},
close() {
this.show = false
},
retDraw() {
if (this.canvas && typeof this.canvas.retDraw === 'function') {
this.canvas.retDraw()
}
},
penStart(event) {
if (!this.canvas) return
this.canvas.penStart(event)
},
penMove(event) {
if (!this.canvas) return
this.canvas.penMove(event)
},
penEnd(event) {
if (!this.canvas) return
this.canvas.penEnd(event)
},
onConfirm() {
this.loading = true
this.saveCanvas()
},
async saveCanvas() {
if (!this.canvas || typeof this.canvas.saveCanvas !== 'function') {
this.loading = false
uni.showToast({ title: '签名板未就绪', icon: 'none' })
return
}
try {
const pic = await this.canvas.saveCanvas(this)
this.loading = false
const base64 = typeof pic === 'string' ? pic : (pic && pic.base64)
if (!base64 && !(pic && pic.tempFilePath)) {
uni.showToast({ title: '请先签名', icon: 'none' })
return
}
this.show = false
this.$emit('saveCanvas', pic)
} catch (e) {
this.loading = false
uni.showToast({ title: '签名导出失败', icon: 'none' })
}
},
},
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.handwritten { .handwritten {
width: 750rpx; width: 750rpx;
background: #FFFFFF; background: #FFFFFF;
border-radius: 16rpx 16rpx 0px 0px; border-radius: 16rpx 16rpx 0px 0px;
height: calc(840rpx + env(safe-area-inset-bottom)); height: calc(840rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
} }
.title { .title {
font-size: 32rpx; font-size: 32rpx;
font-family: PingFang SC-Regular, PingFang SC; font-family: PingFang SC-Regular, PingFang SC;
font-weight: 400; font-weight: 400;
color: #323233; color: #323233;
margin-top: 30rpx; margin-top: 30rpx;
margin-bottom: 48rpx; margin-bottom: 48rpx;
} }
.retDraw { .retDraw {
width: 343rpx; width: 343rpx;
height: 86rpx; height: 86rpx;
background: #FFFFFF rgba(41, 121, 255, 0); background: #FFFFFF rgba(41, 121, 255, 0);
font-size: 32rpx; font-size: 32rpx;
font-family: PingFang SC-Medium, PingFang SC; font-family: PingFang SC-Medium, PingFang SC;
font-weight: 500; font-weight: 500;
color: #2979FF; color: #2979FF;
line-height: 86rpx; line-height: 86rpx;
text-align: center; text-align: center;
} }
</style> </style>

View File

@@ -453,6 +453,56 @@
"style": { "navigationBarTitleText": "药品详情", "navigationStyle": "custom" } "style": { "navigationBarTitleText": "药品详情", "navigationStyle": "custom" }
} }
] ]
}, {
"root": "subPackages/sub_business_shared",
"pages": [
{ "path": "order/index", "style": { "navigationBarTitleText": "商品订单", "navigationStyle": "custom" } },
{ "path": "order/detail", "style": { "navigationBarTitleText": "订单详情", "navigationStyle": "custom" } },
{ "path": "order/trace/index", "style": { "navigationBarTitleText": "订单追溯", "navigationStyle": "custom" } },
{ "path": "reconciliation/index", "style": { "navigationBarTitleText": "对账单", "navigationStyle": "custom" } },
{ "path": "withdrawal/index", "style": { "navigationBarTitleText": "提现管理", "navigationStyle": "custom" } },
{ "path": "withdrawal/apply", "style": { "navigationBarTitleText": "申请提现", "navigationStyle": "custom" } },
{ "path": "withdrawal/card-edit", "style": { "navigationBarTitleText": "编辑账户", "navigationStyle": "custom" } },
{ "path": "withdrawal/record-detail", "style": { "navigationBarTitleText": "提现详情", "navigationStyle": "custom" } },
{ "path": "withdrawal/settlement/index", "style": { "navigationBarTitleText": "结算记录", "navigationStyle": "custom" } },
{ "path": "withdrawal/settlement/detail", "style": { "navigationBarTitleText": "结算明细", "navigationStyle": "custom" } },
{ "path": "withdrawal/account-change/index", "style": { "navigationBarTitleText": "资金变动", "navigationStyle": "custom" } },
{ "path": "warehouse/index", "style": { "navigationBarTitleText": "仓库管理", "navigationStyle": "custom" } },
{ "path": "warehouse/detail", "style": { "navigationBarTitleText": "药品详情", "navigationStyle": "custom" } }
]
}, {
"root": "subPackages/sub_platform_admin",
"pages": [
{ "path": "home/index", "style": { "navigationBarTitleText": "工作台", "navigationStyle": "custom" } },
{ "path": "my/index", "style": { "navigationBarTitleText": "我的", "navigationStyle": "custom" } },
{ "path": "withdrawal/audit/index", "style": { "navigationBarTitleText": "提现审核", "navigationStyle": "custom" } },
{ "path": "store-input/audit/index", "style": { "navigationBarTitleText": "门店审核", "navigationStyle": "custom" } },
{ "path": "store-input/audit/detail", "style": { "navigationBarTitleText": "审核详情", "navigationStyle": "custom" } },
{ "path": "doctor-input/audit/index", "style": { "navigationBarTitleText": "医生审核", "navigationStyle": "custom" } },
{ "path": "doctor-input/audit/detail", "style": { "navigationBarTitleText": "审核详情", "navigationStyle": "custom" } }
]
}, {
"root": "subPackages/sub_salesperson",
"pages": [
{ "path": "home/index", "style": { "navigationBarTitleText": "工作台", "navigationStyle": "custom" } },
{ "path": "my/index", "style": { "navigationBarTitleText": "我的", "navigationStyle": "custom" } },
{ "path": "store-input/index", "style": { "navigationBarTitleText": "列表", "navigationStyle": "custom" } },
{ "path": "store-input/form", "style": { "navigationBarTitleText": "录入", "navigationStyle": "custom" } },
{ "path": "store-input/detail", "style": { "navigationBarTitleText": "录入详情", "navigationStyle": "custom" } },
{ "path": "doctor-input/index", "style": { "navigationBarTitleText": "列表", "navigationStyle": "custom" } },
{ "path": "doctor-input/form", "style": { "navigationBarTitleText": "录入", "navigationStyle": "custom" } },
{ "path": "doctor-input/detail", "style": { "navigationBarTitleText": "预填详情", "navigationStyle": "custom" } }
]
}, {
"root": "subPackages/sub_clinic_salesperson",
"pages": [
{ "path": "home/index", "style": { "navigationBarTitleText": "工作台", "navigationStyle": "custom" } },
{ "path": "my/index", "style": { "navigationBarTitleText": "我的", "navigationStyle": "custom" } },
{ "path": "earnings/index", "style": { "navigationBarTitleText": "我的收益", "navigationStyle": "custom" } },
{ "path": "leads/index", "style": { "navigationBarTitleText": "获客记录", "navigationStyle": "custom" } },
{ "path": "commission/index", "style": { "navigationBarTitleText": "分成记录", "navigationStyle": "custom" } },
{ "path": "settlement/index", "style": { "navigationBarTitleText": "结算记录", "navigationStyle": "custom" } }
]
}, { }, {
"root": "subPackages/sub_agreement", "root": "subPackages/sub_agreement",
"pages": [{ "pages": [{

View File

@@ -12,25 +12,12 @@
<script> <script>
import DoctorLogin from '@/components/doctor-login/doctor-login.vue'; import DoctorLogin from '@/components/doctor-login/doctor-login.vue';
import AccountSwitchPanel from '@/components/account-switch/account-switch-panel.vue'; import AccountSwitchPanel from '@/components/account-switch/account-switch-panel.vue';
import { redirectIfLoggedIn } from '@/utils/loginSession.js';
export default { export default {
components: { DoctorLogin, AccountSwitchPanel }, components: { DoctorLogin, AccountSwitchPanel },
onShow() { onShow() {
const clinicToken = uni.getStorageSync('clinic_admin_token'); redirectIfLoggedIn();
const token = uni.getStorageSync('token');
const loginMode = uni.getStorageSync('loginMode');
if (loginMode === 'clinic_admin' && clinicToken) {
uni.reLaunch({ url: '/subPackages/sub_clinic_admin/home/index' });
return;
}
if (token) {
const role = Number(uni.getStorageSync('identity') || uni.getStorageSync('role') || 0);
if (role === 2) {
uni.reLaunch({ url: '/pages/pharmacist/index' });
} else {
uni.reLaunch({ url: '/pages/workbench/index' });
}
}
}, },
methods: { methods: {
openAccountPicker({ accounts, defaultAccountId, defaultAccountType }) { openAccountPicker({ accounts, defaultAccountId, defaultAccountType }) {

View File

@@ -0,0 +1,26 @@
import { resolveBusinessContext, pageUrl, businessPageRoot } from './context.js';
export default {
inject: {
businessContext: { default: null },
},
computed: {
bizCtx() {
return resolveBusinessContext(this.businessContext);
},
bizApi() {
return this.bizCtx.api;
},
bizCap() {
return this.bizCtx.capabilities || {};
},
bizRoot() {
return businessPageRoot(this.bizCtx);
},
},
methods: {
bizPage(subPath) {
return pageUrl(this.bizCtx, subPath);
},
},
};

View File

@@ -0,0 +1,102 @@
import { platformAdminApi, salespersonApi } from '@/api/businessApi.js';
import * as clinicApi from '@/api/clinicAdmin.js';
const BUSINESS_PACKAGE_ROOT = '/subPackages/sub_business_shared';
const PLATFORM_HOME = '/subPackages/sub_platform_admin/home/index';
const PLATFORM_MY = '/subPackages/sub_platform_admin/my/index';
const SALESPERSON_HOME = '/subPackages/sub_salesperson/home/index';
const SALESPERSON_MY = '/subPackages/sub_salesperson/my/index';
export function getPlatformAdminContext() {
return {
mode: 'platform_admin',
api: platformAdminApi,
userStorageKey: 'platform_admin_user',
homePath: PLATFORM_HOME,
myPath: PLATFORM_MY,
packageRoot: '/subPackages/sub_platform_admin',
businessPackageRoot: BUSINESS_PACKAGE_ROOT,
brandTitle: '平台管理工作台',
capabilities: {
canShip: true,
canRefund: true,
canStoreFilter: true,
canWithdraw: true,
canWithdrawAudit: true,
canWarehouse: true,
warehouseType: 'platform',
canStoreInputAudit: true,
canDoctorInputAudit: true,
},
tabList: [
{ text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: PLATFORM_HOME },
{ text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: PLATFORM_MY },
],
};
}
export function getSalespersonContext() {
return {
mode: 'salesperson',
api: salespersonApi,
userStorageKey: 'salesperson_user',
homePath: SALESPERSON_HOME,
myPath: SALESPERSON_MY,
packageRoot: '/subPackages/sub_salesperson',
businessPackageRoot: BUSINESS_PACKAGE_ROOT,
brandTitle: '业务工作台',
capabilities: {
canShip: false,
canRefund: false,
canStoreFilter: true,
canStoreInput: true,
canDoctorInput: true,
},
tabList: [
{ text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: SALESPERSON_HOME },
{ text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: SALESPERSON_MY },
],
};
}
export function getClinicAdminContext() {
return {
mode: 'clinic_admin',
api: clinicApi,
userStorageKey: 'clinic_admin_user',
homePath: '/subPackages/sub_clinic_admin/home/index',
myPath: '/subPackages/sub_clinic_admin/my/index',
packageRoot: '/subPackages/sub_clinic_admin',
businessPackageRoot: '/subPackages/sub_clinic_admin',
brandTitle: '诊所管理系统',
capabilities: {
canShip: false,
canRefund: false,
canStoreFilter: false,
canWithdraw: true,
canWithdrawAudit: false,
canWarehouse: true,
warehouseType: 'store',
},
tabList: [
{ text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: '/subPackages/sub_clinic_admin/home/index' },
{ text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: '/subPackages/sub_clinic_admin/my/index' },
],
};
}
export function resolveBusinessContext(injected) {
if (injected && injected.api) return injected;
const mode = uni.getStorageSync('loginMode');
if (mode === 'platform_admin') return getPlatformAdminContext();
if (mode === 'salesperson') return getSalespersonContext();
return getClinicAdminContext();
}
export function businessPageRoot(ctx) {
return ctx.businessPackageRoot || ctx.packageRoot;
}
export function pageUrl(ctx, subPath) {
return `${businessPageRoot(ctx)}/${subPath}`;
}

View File

@@ -0,0 +1,540 @@
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 {}
const isIncrease = item.merged
? (item.type_txt === '增加' || Number(item.amount) >= 0)
: item.type === 1
return {
id: item.id,
orderNo: item.order_no || (item.order_id ? '订单#' + item.order_id : '-'),
orderTypeText: item.order_type_txt || '',
feeTypeText: item.fee_type_txt || '-',
amountText: formatMoney(item.amount),
amountClass: isIncrease ? 'text-success' : 'text-danger',
typeText: item.type_txt || '-',
content: item.content || '',
createdAt: formatDateTime(item.created_at),
merged: !!item.merged,
orderId: item.order_id,
orderType: item.order_type,
_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,
}
}
/** 订单追溯对账汇总summary 字段,对齐 mapReconciliationStoreRow 结构) */
export function mapReconciliationOrderSummary(summary) {
if (!summary) return null
return {
totalSales: formatMoney(summary.total_sales_price),
totalSupply: formatMoney(summary.total_supply_price),
registrationPrice: formatMoney(summary.registration_price),
registerOrderNo: summary.register_order_no || '',
herbal: mapReconciliationCategory(summary, 'herbal'),
medicine: mapReconciliationCategory(summary, 'medicine'),
servicePackage: mapReconciliationCategory(summary, 'service_package'),
otherFees: {
express: formatMoney(summary.express_price),
process: formatMoney(summary.process_price),
treatment: formatMoney(summary.treatment_price),
},
_raw: summary,
}
}
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,122 @@
/**
* 诊所端列表筛选条件本地缓存(对齐 PC reconciliation formCache
*/
import {
defaultMonthStartDate,
defaultTodayDate,
} from './reconciliationParams.js'
const KEY_PREFIX = () => uni.getStorageSync('loginMode') || 'clinic_admin'
const KEY_ORDER = () => `${KEY_PREFIX()}.order.filter`
const KEY_RECONCILIATION = () => `${KEY_PREFIX()}.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,24 @@
<template>
<view class="page-sticky-header">
<slot />
</view>
</template>
<script>
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
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,80 @@
<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 businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js';
export default {
name: 'BusinessPageLayout',
mixins: [businessMixin],
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,
};
},
computed: {
tabList() {
return this.bizCtx.tabList || [];
},
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;
},
methods: {
onTabChange(index) {
if (index === this.tabIndex) return;
const target = this.tabList[index];
if (target && target.pagePath) {
uni.reLaunch({ url: target.pagePath });
}
},
},
};
</script>
<style lang="scss" scoped>
.clinic-layout {
min-height: 100vh;
background: #f9fafb;
}
</style>

View File

@@ -0,0 +1,84 @@
<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 '@/subPackages/sub_business_shared/common/tabbar.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
name: 'BusinessPageLayout',
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>

View File

@@ -0,0 +1,81 @@
<template>
<business-page-layout title="录入详情" :show-back="true">
<view class="detail-wrap" v-if="info.id">
<view class="row" v-for="f in fields" :key="f.k"><text class="k">{{ f.l }}</text><text class="v">{{ display(f.k) }}</text></view>
<view v-if="canEdit" class="actions">
<button @click="goEdit">继续编辑</button>
</view>
<view v-if="canAudit" class="actions">
<button class="ok" @click="audit(1)">审核通过</button>
<button class="no" @click="audit(2)">审核拒绝</button>
</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
props: {
inputType: { type: String, default: 'store' },
editPath: { type: String, default: '' },
},
data() { return { id: 0, info: {} } },
computed: {
fields() {
if (this.inputType === 'doctor') {
return [
{ k: 'name', l: '姓名' }, { k: 'mobile', l: '手机' }, { k: 'depart_id', l: '科室ID' },
{ k: 'good_at', l: '擅长' }, { k: 'intro', l: '简介' },
]
}
return [
{ k: 'name', l: '名称' }, { k: 'mobile', l: '电话' }, { k: 'contact', l: '联系人' },
{ k: 'position', l: '地址' }, { k: 'erp_id', l: 'ERP ID' },
]
},
canEdit() { return !this.canAudit && Number(this.info.status) === 0 },
canAudit() {
if (this.inputType === 'doctor') return !!this.bizCap.canDoctorInputAudit
return !!this.bizCap.canStoreInputAudit
},
},
onLoad(q) { this.id = Number(q.id || 0); this.load() },
methods: {
detailApi() {
return this.inputType === 'doctor'
? this.bizApi.getDoctorInputDetail(this.id)
: this.bizApi.getStoreInputDetail(this.id)
},
auditApi(data) {
return this.inputType === 'doctor'
? this.bizApi.auditDoctorInput(data)
: this.bizApi.auditStoreInput(data)
},
load() {
this.detailApi().then(w => { if (w.ok) this.info = w.data || {} })
},
display(k) { return this.info[k] != null && this.info[k] !== '' ? this.info[k] : '-' },
goEdit() { if (this.editPath) uni.navigateTo({ url: `${this.editPath}?id=${this.id}` }) },
audit(status) {
this.auditApi({ id: this.id, status, audit_remark: '' }).then(w => {
if (w.ok) { uni.showToast({ title: '操作成功' }); setTimeout(() => uni.navigateBack(), 500) }
})
},
},
}
</script>
<style scoped>
.detail-wrap { padding: 24rpx; background: #fff; min-height: 100vh; }
.row { display: flex; padding: 24rpx 0; border-bottom: 1rpx solid #F2F3F5; }
.k { width: 180rpx; color: #86909C; }
.v { flex: 1; }
.actions { display: flex; gap: 16rpx; margin-top: 40rpx; }
.ok { flex: 1; background: #6acdbb; color: #fff; }
.no { flex: 1; background: #F53F3F; color: #fff; }
</style>

View File

@@ -0,0 +1,86 @@
<template>
<business-page-layout :title="title" :show-back="true">
<view class="page-wrap">
<view v-if="!auditMode" class="toolbar">
<button class="btn" @click="goForm()">新建录入</button>
</view>
<view v-for="item in list" :key="item.id" class="card" @click="goDetail(item)">
<view class="row">
<text class="name">{{ item.name }}</text>
<text class="status" :class="'s' + item.status">{{ statusText(item.status) }}</text>
</view>
<view class="sub">{{ item.mobile }} · {{ formatTime(item.created_at) }}</view>
</view>
<view v-if="!list.length" class="empty">暂无记录</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
props: {
auditMode: { type: Boolean, default: false },
inputType: { type: String, default: 'store' },
formPath: { type: String, required: true },
detailPath: { type: String, required: true },
},
data() { return { list: [], page: 1 } },
computed: {
title() {
if (this.inputType === 'doctor') return this.auditMode ? '医生信息审核' : '医生信息预填'
return this.auditMode ? '诊所信息审核' : '诊所信息录入'
},
},
onShow() { this.reload() },
onReachBottom() { this.load(this.page + 1, true) },
methods: {
listApi(params) {
return this.inputType === 'doctor'
? this.bizApi.getDoctorInputList(params)
: this.bizApi.getStoreInputList(params)
},
reload() { this.page = 1; this.load(1, false) },
load(page, append) {
const params = { page, pageSize: 15 }
if (this.auditMode) params.status = 0
this.listApi(params).then(w => {
const items = (w.data && w.data.items) || []
this.list = append ? this.list.concat(items) : items
this.page = page
})
},
statusText(s) {
return { 0: '待审核', 1: '已通过', 2: '已拒绝' }[Number(s)] || '-'
},
formatTime(t) {
if (!t) return '-'
const d = new Date(Number(t) * 1000)
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`
},
goForm() { uni.navigateTo({ url: this.formPath }) },
goDetail(item) {
uni.navigateTo({ url: `${this.detailPath}?id=${item.id}` })
},
},
}
</script>
<style scoped>
.page-wrap { padding: 24rpx; }
.toolbar { margin-bottom: 24rpx; }
.btn { background: #6acdbb; color: #fff; font-size: 28rpx; }
.card { background: #fff; padding: 28rpx; border-radius: 16rpx; margin-bottom: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; }
.name { font-size: 30rpx; font-weight: 600; }
.status { font-size: 24rpx; }
.s0 { color: #FF7D00; }
.s1 { color: #00B42A; }
.s2 { color: #F53F3F; }
.sub { margin-top: 12rpx; color: #86909C; font-size: 24rpx; }
.empty { text-align: center; color: #86909C; padding: 80rpx 0; }
</style>

View File

@@ -0,0 +1,171 @@
<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 '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
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,593 @@
<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 { mapPrescriptionDetailView } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
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
this.bizApi.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,587 @@
<template>
<business-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">
<button class="btn-outline" @click="goTrace">订单追溯</button>
<button v-if="canShip" class="btn-primary" @click="ship">发货</button>
<button v-if="canRefund" class="btn-warn" @click="refund">退款</button>
<button v-if="detailExtra.canViewPrescription" class="btn-primary btn-full" @click="openPrescription">查看处方</button>
</view>
</view>
<!-- 处方弹窗 -->
<prescription-detail-popup ref="prescriptionPopup" />
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import ExpressTimeline from './components/ExpressTimeline.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import {
orderStatusText,
deliveryMethodText,
orderOnlineText,
prescriptionTypeText,
erpSyncText,
mapOrderDetailDisplay,
mapOrderProductItem,
} from '@/subPackages/sub_business_shared/common/display.js'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, 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 false
// if (!this.bizCap.canRefund) return false
// return Number(this.info.is_pay) === 1
},
canShip() {
return false
// return this.bizCap.canShip && Number(this.info.status) === 1 && Number(this.info.is_pay) === 1
},
},
onLoad(q) {
this.id = Number(q.id || 0)
this.load()
},
methods: {
load() {
this.bizApi.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() {
this.bizApi.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
this.bizApi.refundOrder({ id: this.id, refund_reason: '管理员退款' }).then(w => {
if (w.ok) {
uni.showToast({ title: '已提交' })
this.load()
}
})
},
})
},
ship() {
uni.showModal({
title: '确认发货',
content: '确定将该订单标记为已发货?',
success: (r) => {
if (!r.confirm) return
this.bizApi.wareSendOrder({ id: this.id, cancel_remark: '' }).then(w => {
if (w.ok) {
uni.showToast({ title: '发货成功' })
this.load()
}
})
},
})
},
openPrescription() {
const pid = this.detailExtra.prescriptionId
if (!pid) return
this.$refs.prescriptionPopup.open(pid)
},
goTrace() {
uni.navigateTo({
url: `${this.bizRoot}/order/trace/index?order_id=${this.id}&scene=product`,
})
},
},
}
</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-outline {
background: #fff;
color: $theme-primary;
border: 1rpx solid $theme-primary;
}
/* 次要操作(退款)给固定宽度 */
.btn-warn {
width: 220rpx;
background: #fff;
color: $color-danger;
border: 2rpx solid $color-danger;
}
</style>

View File

@@ -0,0 +1,583 @@
<template>
<business-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" />
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import PageStickyHeader from '@/subPackages/sub_business_shared/components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '@/subPackages/sub_business_shared/components/CollapsibleFilterPanel.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapOrderListRow } from '@/subPackages/sub_business_shared/common/display.js'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import {
buildOrderListParams,
buildOrderSaleAmountParams,
} from '@/subPackages/sub_business_shared/common/orderParams.js'
import {
loadOrderFilter,
saveOrderFilter,
clearOrderFilter,
defaultOrderFilter,
} from '@/subPackages/sub_business_shared/common/filterCache.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.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 {
mixins: [businessMixin],
components: { BusinessPageLayout, 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())
this.bizApi.getOrderStatusOption().then(w => {
const opts = (w.data && (w.data.items || w.data)) || []
if (Array.isArray(opts) && opts.length) {
this.statusOptions = [{ label: '全部', value: '' }].concat(opts.map(o => ({
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())
this.bizApi.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(),
})
this.bizApi.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: `${this.bizRoot}/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,480 @@
<template>
<business-page-layout title="订单追溯" :show-back="true">
<view class="trace-container">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else-if="traceData">
<!-- 摘要 -->
<view class="summary-card">
<view class="row"><text class="label">商品订单</text><text class="value">{{ summary.product_order_no || '—' }}</text></view>
<view class="row" v-if="summary.register_order_no"><text class="label">挂号订单</text><text class="value">{{ summary.register_order_no }}</text></view>
<view class="row" v-if="summary.prescription_no"><text class="label">处方号</text><text class="value">{{ summary.prescription_no }}</text></view>
<view class="row"><text class="label">结算状态</text><text class="value">{{ summary.is_settled === 1 ? '已结算' : '未结算' }}</text></view>
</view>
<!-- Tab -->
<view class="main-tabs">
<view
v-for="tab in mainTabs"
:key="tab.key"
class="main-tab"
:class="{ active: activeTab === tab.key }"
@click="switchMainTab(tab.key)"
>{{ tab.label }}</view>
</view>
<!-- 时间线 -->
<view v-show="activeTab === 'timeline'" class="tab-panel">
<view v-if="!timeline.length" class="empty">暂无时间线数据</view>
<view v-for="(node, idx) in timeline" :key="node.key || idx" class="timeline-item">
<view class="tl-dot" />
<view class="tl-body">
<view class="tl-title">{{ node.title }}</view>
<view class="tl-meta">
<text v-if="node.time">{{ node.time }}</text>
<text v-if="node.status" class="tl-status">{{ node.status }}</text>
</view>
<view v-if="node.extra" class="tl-extra">{{ node.extra }}</view>
</view>
</view>
</view>
<!-- 分账明细 -->
<view v-show="activeTab === 'ledger'" class="tab-panel">
<view v-if="ledgerLoading" class="loading-tip">加载分账...</view>
<template v-else>
<view v-if="hasRegisterLedger" class="sub-tabs">
<view
class="sub-tab"
:class="{ active: ledgerSubTab === 'product' }"
@click="switchLedgerSub('product')"
>商品订单</view>
<view
class="sub-tab"
:class="{ active: ledgerSubTab === 'register' }"
@click="switchLedgerSub('register')"
>挂号订单</view>
</view>
<view v-if="currentLedgerItems.length">
<view v-for="row in currentLedgerItems" :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" v-if="row.drug_name"><text class="label">药品</text><text class="value">{{ row.drug_name }}</text></view>
<view class="row" v-if="row.specification"><text class="label">规格</text><text class="value">{{ row.specification }}</text></view>
<view class="row"><text class="label">分账金额</text><text class="value amount">{{ formatMoney(row.money) }}</text></view>
<view class="row"><text class="label">结算状态</text><text class="value">{{ row.status_txt || '-' }}</text></view>
<view class="row" v-if="row.created_at"><text class="label">时间</text><text class="value">{{ row.created_at }}</text></view>
</view>
</view>
<view v-else class="empty">暂无分账明细</view>
</template>
</view>
<!-- 对账明细 -->
<view v-show="activeTab === 'reconciliation'" class="tab-panel">
<view v-if="reconciliationLoading" class="loading-tip">加载对账...</view>
<template v-else>
<view v-if="reconciliationHint" class="hint-box">{{ reconciliationHint }}</view>
<template v-if="reconciliationSummary">
<view class="recon-summary-card">
<view class="recon-head">
<text class="recon-title">对账汇总</text>
<text v-if="reconciliationSummary.registrationPrice !== '¥0.00'" class="mini-tag">
挂号费 {{ reconciliationSummary.registrationPrice }}
</text>
</view>
<view v-if="reconciliationSummary.registerOrderNo" class="recon-sub">
挂号单号 {{ reconciliationSummary.registerOrderNo }}
</view>
<view class="hero-data">
<view class="data-box">
<text class="data-label">总销售额</text>
<text class="data-val text-primary">{{ reconciliationSummary.totalSales }}</text>
</view>
<view class="data-divider" />
<view class="data-box">
<text class="data-label">总供货额</text>
<text class="data-val">{{ reconciliationSummary.totalSupply }}</text>
</view>
</view>
<view class="dashboard-grid">
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">草药</text>
<text class="island-sales">销量 {{ reconciliationSummary.herbal.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ reconciliationSummary.herbal.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ reconciliationSummary.herbal.supplyPrice }}</text></view>
</view>
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">西药</text>
<text class="island-sales">销量 {{ reconciliationSummary.medicine.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ reconciliationSummary.medicine.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ reconciliationSummary.medicine.supplyPrice }}</text></view>
</view>
<view class="dashboard-island">
<view class="island-head">
<text class="island-title">服务包</text>
<text class="island-sales">销量 {{ reconciliationSummary.servicePackage.sales }}</text>
</view>
<view class="island-line"><text>销售</text><text>{{ reconciliationSummary.servicePackage.salesPrice }}</text></view>
<view class="island-line"><text>供货</text><text>{{ reconciliationSummary.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>{{ reconciliationSummary.otherFees.treatment }}</text></view>
<view class="island-line"><text>加工费</text><text>{{ reconciliationSummary.otherFees.process }}</text></view>
<view class="island-line"><text>快递费</text><text>{{ reconciliationSummary.otherFees.express }}</text></view>
</view>
</view>
</view>
</template>
<view v-if="reconciliationItems.length" class="recon-drug-section">
<view class="section-title">药品明细</view>
<view v-for="row in reconciliationItems" :key="row._wxKey" class="detail-card">
<view class="row"><text class="label">药品</text><text class="value">{{ row.drug_name || row.drug_id || '-' }}</text></view>
<view class="row"><text class="label">数量</text><text class="value">{{ row.number }}</text></view>
<view class="row"><text class="label">销售价</text><text class="value">{{ formatMoney(row.total_price) }}</text></view>
<view class="row"><text class="label">供货价</text><text class="value">{{ formatMoney(row.total_buy_price) }}</text></view>
</view>
</view>
<view v-else-if="!reconciliationHint && !reconciliationSummary" class="empty">暂无对账明细</view>
</template>
</view>
</template>
<view v-else-if="!loading" class="empty">加载失败请返回重试</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import { mapReconciliationOrderSummary } from '@/subPackages/sub_business_shared/common/display.js'
import { withWxKey } from '@/utils/wxListKey.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
orderId: 0,
scene: 'product',
loading: true,
traceData: null,
activeTab: 'timeline',
ledgerSubTab: 'product',
ledgerProduct: null,
ledgerRegister: null,
ledgerLoading: false,
reconciliationData: null,
reconciliationLoading: false,
loadedTabs: {},
mainTabs: [
{ key: 'timeline', label: '时间线' },
{ key: 'ledger', label: '分账' },
{ key: 'reconciliation', label: '对账' },
],
}
},
computed: {
summary() {
return this.traceData?.summary || {}
},
timeline() {
return this.traceData?.timeline || []
},
links() {
return this.traceData?.links || {}
},
hasRegisterLedger() {
return !!(this.links.register_id && this.ledgerRegister !== null)
},
currentLedgerItems() {
const data = this.ledgerSubTab === 'register' ? this.ledgerRegister : this.ledgerProduct
const items = data?.items || []
return withWxKey(items, 'id', 'traceLed')
},
reconciliationItems() {
const items = this.reconciliationData?.items || []
return withWxKey(items, 'id', 'traceRec')
},
reconciliationSummary() {
return mapReconciliationOrderSummary(this.reconciliationData?.summary)
},
reconciliationHint() {
return this.reconciliationData?.reconciliation_hint || this.summary.reconciliation_hint || ''
},
},
onLoad(q) {
this.orderId = Number(q.order_id || 0)
this.scene = q.scene || 'product'
this.loadTrace()
},
methods: {
formatMoney,
loadTrace() {
if (this.orderId <= 0) {
this.loading = false
return
}
this.loading = true
this.bizApi.getOrderTrace({ scene: this.scene, order_id: this.orderId }).then(w => {
if (w.ok) {
this.traceData = w.data
} else {
uni.showToast({ title: (w.res && w.res.message) || '加载失败', icon: 'none' })
}
}).finally(() => { this.loading = false })
},
switchMainTab(key) {
this.activeTab = key
if (key === 'ledger' && !this.loadedTabs.ledger) {
this.loadLedger()
} else if (key === 'reconciliation' && !this.loadedTabs.reconciliation) {
this.loadReconciliation()
}
},
switchLedgerSub(key) {
this.ledgerSubTab = key
},
loadLedger() {
const productId = this.links.product_order_id
const registerId = this.links.register_id
if (!productId && !registerId) {
this.loadedTabs.ledger = true
return
}
this.ledgerLoading = true
const reqs = []
if (productId) {
reqs.push(
this.bizApi.getOrderLedgerDetail({ order_id: productId, order_type: 1, scope: 'store' }).then(w => {
this.ledgerProduct = w.ok ? w.data : null
})
)
} else {
this.ledgerProduct = null
}
if (registerId) {
reqs.push(
this.bizApi.getOrderLedgerDetail({ order_id: registerId, order_type: 2, scope: 'store' }).then(w => {
this.ledgerRegister = w.ok ? w.data : null
})
)
} else {
this.ledgerRegister = null
}
Promise.all(reqs).finally(() => {
this.ledgerLoading = false
this.loadedTabs.ledger = true
})
},
loadReconciliation() {
const productId = this.links.product_order_id || (this.scene === 'product' ? this.orderId : 0)
if (!productId) {
this.reconciliationData = { items: [], reconciliation_hint: '暂无关联商品订单' }
this.loadedTabs.reconciliation = true
return
}
this.reconciliationLoading = true
this.bizApi.getOrderReconciliationDetail({ product_order_id: productId }).then(w => {
if (w.ok) {
this.reconciliationData = w.data
}
}).finally(() => {
this.reconciliationLoading = false
this.loadedTabs.reconciliation = true
})
},
},
}
</script>
<style lang="scss" scoped>
.trace-container {
padding: 24rpx;
padding-bottom: 48rpx;
}
.loading-tip, .empty {
text-align: center;
color: #999;
font-size: 28rpx;
padding: 48rpx 0;
}
.summary-card, .detail-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
}
.summary-card .row, .detail-card .row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
padding: 8rpx 0;
color: #333;
}
.label { color: #888; flex-shrink: 0; margin-right: 16rpx; }
.value { text-align: right; word-break: break-all; }
.amount { color: #1677ff; font-weight: 600; }
.main-tabs, .sub-tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
margin-bottom: 24rpx;
overflow: hidden;
}
.main-tab, .sub-tab {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
color: #666;
}
.main-tab.active, .sub-tab.active {
color: #1677ff;
font-weight: 600;
background: #e6f4ff;
}
.tab-panel { min-height: 200rpx; }
.timeline-item {
display: flex;
margin-bottom: 24rpx;
padding-left: 8rpx;
}
.tl-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #1677ff;
margin-top: 10rpx;
margin-right: 20rpx;
flex-shrink: 0;
}
.tl-body { flex: 1; background: #fff; border-radius: 12rpx; padding: 20rpx; }
.tl-title { font-size: 28rpx; font-weight: 600; color: #333; }
.tl-meta { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.tl-status { margin-left: 12rpx; color: #1677ff; }
.tl-extra { font-size: 24rpx; color: #666; margin-top: 8rpx; }
.hint-box {
background: #fff7e6;
color: #d48806;
font-size: 26rpx;
padding: 20rpx 24rpx;
border-radius: 12rpx;
margin-bottom: 24rpx;
}
.sum-row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
color: #333;
margin-bottom: 16rpx;
padding: 0 8rpx;
}
.recon-summary-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 24rpx;
}
.recon-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.recon-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
.recon-sub {
font-size: 24rpx;
color: #888;
margin-bottom: 16rpx;
}
.mini-tag {
font-size: 20rpx;
padding: 4rpx 12rpx;
background: rgba(106, 205, 187, 0.1);
color: #6ACDBB;
border-radius: 8rpx;
}
.hero-data {
display: flex;
align-items: center;
background: #F8FBFB;
border-radius: 12rpx;
padding: 24rpx 0;
margin-bottom: 20rpx;
}
.data-box {
flex: 1;
text-align: center;
}
.data-label {
display: block;
font-size: 24rpx;
color: #888;
margin-bottom: 8rpx;
}
.data-val {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
.text-primary { color: #1677ff; }
.data-divider {
width: 1rpx;
height: 60rpx;
background: #eee;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.dashboard-island {
background: #F8FBFB;
border-radius: 12rpx;
padding: 16rpx;
}
.island-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.island-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
}
.island-sales {
font-size: 22rpx;
color: #888;
}
.island-line {
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: #666;
padding: 4rpx 0;
}
.recon-drug-section { margin-top: 8rpx; }
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
padding: 0 8rpx;
}
</style>

View File

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

View File

@@ -0,0 +1,133 @@
<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>
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
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,314 @@
<template>
<business-page-layout title="药品详情" :show-back="true">
<view v-if="info.id" class="page-container">
<!-- 头部信息卡片 -->
<view class="card hero-card">
<view class="image-wrapper">
<image class="hero" :src="display.imageUrl" mode="aspectFill" />
</view>
<view class="main-info">
<text class="drug-name">{{ display.drugName }}</text>
<text class="pinyin">拼音: {{ display.pinyin_simple || '--' }}</text>
</view>
</view>
<!-- 核心数据卡片 -->
<view class="card data-card">
<view class="row">
<text class="label">状态</text>
<view class="status-tag" :class="display.isOnShelf ? 'tag-on' : 'tag-off'">
{{ display.statusText }}
</view>
</view>
<view class="row">
<text class="label">库存</text>
<text class="value stock-value">{{ display.stock }}</text>
</view>
<view class="divider"></view>
<view class="row">
<text class="label">供货价</text>
<text class="value">{{ display.buyPrice }}</text>
</view>
<view class="row">
<text class="label">建议售价</text>
<text class="value">{{ display.suggestPrice }}</text>
</view>
<view class="row highlight-row">
<text class="label">当前售价</text>
<text class="value price-large">{{ display.price }}</text>
</view>
</view>
<!-- 底部操作按钮组 -->
<view v-if="info.id" class="action-group">
<view class="btn-large btn-outline" hover-class="btn-hover-opacity" @click="openPriceEdit">
修改售价
</view>
<view
class="btn-large"
:class="display.isOnShelf ? 'btn-danger' : 'btn-primary'"
hover-class="btn-hover-opacity"
@click="toggleStatus"
>
{{ display.isOnShelf ? '下架该药品' : '上架该药品' }}
</view>
</view>
</view>
<price-edit-modal
:visible="priceModalVisible"
:item="info.id ? info : null"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue'
import { mapWarehouseRow } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, 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() {
this.bizApi.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
this.bizApi.updateWarehousePrice({ id: this.id, price }).then(w => {
if (w.ok) {
uni.showToast({ title: '修改成功' })
this.closePriceEdit()
this.load()
}
}).finally(() => { this.priceSubmitting = false })
},
toggleStatus() {
this.bizApi.updateWarehouseStatus(this.id).then(w => {
if (w.ok) {
uni.showToast({ title: '操作成功' })
this.load()
}
})
},
},
}
</script>
<style lang="scss" scoped>
/* 同步了列表页的核心色板 */
$theme-color: #6acdbb;
$theme-color-light: rgba(106, 205, 187, 0.12);
$text-primary: #2c3e50;
$text-secondary: #8a97a3;
$text-light: #b0b8c1;
$bg-color: #f7f9fa;
$border-color: #ebedf0;
$danger-color: #ff6b6b;
$danger-color-light: rgba(255, 107, 107, 0.1);
.page-container {
padding: 24rpx;
padding-bottom: 60rpx;
}
.card {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
}
/* 头部卡片特异样式 */
.hero-card {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40rpx;
}
.image-wrapper {
width: 100%;
border-radius: 16rpx;
overflow: hidden;
background: $bg-color;
border: 1rpx solid $border-color;
margin-bottom: 32rpx;
}
.hero {
width: 100%;
height: 400rpx;
display: block;
}
.main-info {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.drug-name {
font-size: 36rpx;
font-weight: 600;
color: $text-primary;
margin-bottom: 12rpx;
line-height: 1.4;
}
.pinyin {
font-size: 26rpx;
color: $text-secondary;
}
/* 核心数据卡片样式 */
.data-card {
padding: 16rpx 32rpx;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
}
.divider {
height: 1rpx;
background-color: $border-color;
margin: 8rpx 0;
}
.label {
font-size: 28rpx;
color: $text-secondary;
}
.value {
font-size: 28rpx;
color: $text-primary;
font-weight: 500;
}
.stock-value {
font-family: monospace; /* 数字更清晰 */
font-size: 30rpx;
}
.highlight-row {
margin-top: 8rpx;
background: #fafbfa; /* 极浅的背景强调 */
margin: 0 -32rpx;
padding: 32rpx;
border-bottom-left-radius: 24rpx;
border-bottom-right-radius: 24rpx;
}
.highlight-row .label {
color: $text-primary;
font-weight: 500;
}
.price-large {
font-size: 40rpx;
color: $theme-color;
font-weight: 600;
}
/* 标签样式 */
.status-tag {
font-size: 24rpx;
font-weight: 500;
padding: 6rpx 20rpx;
border-radius: 12rpx;
}
.tag-on {
color: $theme-color;
background: $theme-color-light;
}
.tag-off {
color: $text-secondary;
background: $bg-color;
}
/* 底部操作按钮组 */
.action-group {
display: flex;
flex-direction: column;
gap: 24rpx;
margin-top: 16rpx;
}
.btn-large {
width: 100%;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: 500;
transition: opacity 0.2s;
box-sizing: border-box;
}
.btn-primary {
background: $theme-color;
color: #ffffff;
box-shadow: 0 8rpx 24rpx $theme-color-light;
}
.btn-danger {
background: $danger-color;
color: #ffffff;
box-shadow: 0 8rpx 24rpx $danger-color-light;
}
.btn-outline {
background: #ffffff;
color: $theme-color;
border: 2rpx solid $theme-color;
}
.btn-hover-opacity {
opacity: 0.7;
}
</style>

View File

@@ -0,0 +1,381 @@
<template>
<business-page-layout :title="pageTitle" :show-back="true">
<!-- 搜索栏优化胶囊式设计增加视觉呼吸感 -->
<view class="search-wrapper">
<view class="search-bar">
<input class="search-input" v-model="keyword" placeholder="搜索药品名称" placeholder-class="search-placeholder" @confirm="reload" />
<view class="search-btn" hover-class="btn-hover" @click="reload">搜索</view>
</view>
</view>
<view class="list-container">
<view
v-for="item in list"
:key="item._wxKey"
class="item-card"
hover-class="card-hover"
@click="goDetail(item)"
>
<!-- 左侧缩略图 -->
<image class="thumb" :src="rowDisplay(item).imageUrl" mode="aspectFill" />
<!-- 右侧内容区 -->
<view class="content">
<!-- 顶部标题与状态标签对齐 -->
<view class="header-row">
<text class="title">{{ rowDisplay(item).drugName }}</text>
<view class="status-tag" :class="rowDisplay(item).isOnShelf ? 'tag-on' : 'tag-off'">
{{ rowDisplay(item).statusText }}
</view>
</view>
<!-- 中部详细信息 -->
<view class="info-group">
<text class="info-text">供货 {{ rowDisplay(item).buyPrice }} · 售价 {{ rowDisplay(item).price }}</text>
<text class="info-text">库存 {{ rowDisplay(item).stock }}</text>
</view>
<!-- 底部操作按钮横向排列增加点击区域与反馈 -->
<view class="action-row" @click.stop>
<view class="action-btn outline" hover-class="btn-hover-opacity" @click.stop="openPriceEdit(item)">
改价
</view>
<view class="action-btn" :class="rowDisplay(item).isOnShelf ? 'btn-danger' : 'btn-primary'" hover-class="btn-hover-opacity" @click.stop="toggleStatus(item)">
{{ rowDisplay(item).isOnShelf ? '下架' : '上架' }}
</view>
</view>
</view>
</view>
</view>
<!-- 空状态优化 -->
<view v-if="!loading && !list.length" class="empty-state">
<view class="empty-icon"></view>
<text class="empty-text">暂无药品数据</text>
</view>
<price-edit-modal
:visible="priceModalVisible"
:item="priceEditItem"
@close="closePriceEdit"
@confirm="onPriceConfirm"
/>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue'
import { withWxKey } from '@/utils/wxListKey.js'
import { mapWarehouseRow } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, 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
}
this.bizApi.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: `${this.bizRoot}/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
this.bizApi.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
this.bizApi.updateWarehouseStatus(item.id).then(w => {
if (w.ok) {
uni.showToast({ title: '操作成功' })
item.status = item.status === 2 ? 1 : 2
}
})
},
})
},
},
}
</script>
<style lang="scss" scoped>
/* 核心主题色变量提取,方便后续统一管理 */
$theme-color: #6acdbb;
$theme-color-light: rgba(106, 205, 187, 0.12);
$text-primary: #2c3e50;
$text-secondary: #8a97a3;
$text-light: #b0b8c1;
$bg-color: #f7f9fa;
$border-color: #ebedf0;
$danger-color: #ff6b6b;
$danger-color-light: rgba(255, 107, 107, 0.1);
.search-wrapper {
padding: 16rpx 24rpx;
background-color: transparent;
position: sticky;
top: 0;
z-index: 10;
}
.search-bar {
display: flex;
align-items: center;
background: #ffffff;
padding: 12rpx 16rpx 12rpx 32rpx;
border-radius: 40rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.search-input {
flex: 1;
font-size: 28rpx;
color: $text-primary;
height: 60rpx;
}
.search-placeholder {
color: $text-light;
}
.search-btn {
background: $theme-color;
color: #ffffff;
font-size: 26rpx;
font-weight: 500;
padding: 12rpx 36rpx;
border-radius: 30rpx;
margin-left: 16rpx;
transition: all 0.2s;
}
.list-container {
padding: 0 24rpx 24rpx;
}
.item-card {
display: flex;
background: #ffffff;
padding: 24rpx;
border-radius: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
transition: transform 0.2s, box-shadow 0.2s;
}
.card-hover {
transform: scale(0.99);
background-color: #fafafa;
}
.thumb {
width: 140rpx;
height: 140rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: $bg-color;
border: 1rpx solid $border-color;
}
.content {
flex: 1;
margin-left: 24rpx;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8rpx;
}
.title {
font-size: 30rpx;
color: $text-primary;
font-weight: 600;
line-height: 1.4;
flex: 1;
margin-right: 16rpx;
/* 单行截断 */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
font-size: 20rpx;
font-weight: 500;
padding: 4rpx 14rpx;
border-radius: 8rpx;
flex-shrink: 0;
line-height: 1.5;
}
.tag-on {
color: $theme-color;
background: $theme-color-light;
}
.tag-off {
color: $text-secondary;
background: $bg-color;
}
.info-group {
display: flex;
flex-direction: column;
gap: 6rpx;
margin-bottom: 16rpx;
}
.info-text {
font-size: 24rpx;
color: $text-secondary;
}
.action-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 16rpx;
margin-top: auto;
}
.action-btn {
font-size: 24rpx;
padding: 10rpx 28rpx;
border-radius: 12rpx;
font-weight: 500;
line-height: 1.5;
transition: opacity 0.2s;
}
.action-btn.outline {
color: $text-primary;
background: #ffffff;
border: 1rpx solid $border-color;
}
.action-btn.btn-primary {
color: #ffffff;
background: $theme-color;
border: 1rpx solid $theme-color;
}
.action-btn.btn-danger {
color: $danger-color;
background: $danger-color-light;
border: 1rpx solid transparent;
}
.btn-hover {
opacity: 0.8;
}
.btn-hover-opacity {
opacity: 0.6;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.empty-icon {
width: 200rpx;
height: 200rpx;
background-color: $bg-color;
border-radius: 50%;
margin-bottom: 30rpx;
position: relative;
}
/* 用伪元素做一个简单的空状态图形占位有条件可替换为真实的Image/SVG */
.empty-icon::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80rpx;
height: 80rpx;
border: 8rpx solid $border-color;
border-radius: 16rpx;
}
.empty-text {
color: $text-light;
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<business-page-layout title="资金变动" :show-back="true">
<view class="page-container">
<view class="filter-bar">
<picker :range="sourceTypeLabels" @change="onSourceTypeChange">
<view class="picker-value">{{ currentSourceLabel }}</view>
</picker>
<input
v-model="orderNo"
class="search-input"
placeholder="订单号"
confirm-type="search"
@confirm="reload"
/>
<view class="search-btn" @click="reload">搜索</view>
</view>
<view class="list-wrap">
<view v-for="item in list" :key="item.id" class="record-card">
<view class="row head">
<text class="source">{{ item.source_type_txt || '-' }}</text>
<text class="change" :class="Number(item.change_amount) >= 0 ? 'inc' : 'dec'">
{{ item.change_amount }}
</text>
</view>
<view class="row"><text class="label">变更字段</text><text class="value">{{ item.field_name_txt || item.field_name }}</text></view>
<view class="row"><text class="label">变动前</text><text class="value">{{ item.before_amount }}</text></view>
<view class="row"><text class="label">变动后</text><text class="value">{{ item.after_amount }}</text></view>
<view class="row" v-if="item.order_no"><text class="label">订单号</text><text class="value">{{ item.order_no }}</text></view>
<view class="row"><text class="label">时间</text><text class="value">{{ item.created_at }}</text></view>
<view class="row" v-if="item.remark"><text class="label">备注</text><text class="value">{{ item.remark }}</text></view>
<view
v-if="Number(item.order_type) === 1 && item.order_id"
class="order-link"
@click="goOrderDetail(item)"
>查看订单详情</view>
</view>
</view>
<view v-if="!loading && list.length === 0" class="empty-state">暂无资金变动记录</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
list: [],
page: 1,
loading: false,
orderNo: '',
sourceType: '',
sourceTypeOptions: [
{ label: '全部来源', value: '' },
{ label: '分账', value: 'settle' },
{ label: '提现', value: 'withdraw' },
{ label: '退款', value: 'refund' },
],
}
},
computed: {
sourceTypeLabels() {
return this.sourceTypeOptions.map(o => o.label)
},
currentSourceLabel() {
const hit = this.sourceTypeOptions.find(o => o.value === this.sourceType)
return hit ? hit.label : '全部来源'
},
},
onShow() {
this.reload()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
onSourceTypeChange(e) {
const idx = Number(e.detail.value || 0)
this.sourceType = this.sourceTypeOptions[idx].value
this.reload()
},
reload() {
this.load(1, false)
},
load(page, append) {
this.loading = true
const params = { page, pageSize: 15 }
if (this.sourceType) params.source_type = this.sourceType
if (this.orderNo.trim()) params.order_no = this.orderNo.trim()
this.bizApi.getAccountChangeLogList(params).then(w => {
const items = (w.data && w.data.items) || []
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
},
goOrderDetail(item) {
uni.navigateTo({
url: `${this.bizRoot}/order/detail?id=${item.order_id}`,
})
},
},
}
</script>
<style lang="scss" scoped>
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-muted: #86909C;
$theme-primary: #6acdbb;
.page-container {
min-height: 100vh;
background: $color-bg-base;
padding: 24rpx;
}
.filter-bar {
display: flex;
gap: 12rpx;
margin-bottom: 24rpx;
align-items: center;
}
.picker-value,
.search-input {
background: #fff;
border-radius: 12rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
}
.picker-value {
min-width: 160rpx;
}
.search-input {
flex: 1;
}
.search-btn {
background: $theme-primary;
color: #fff;
border-radius: 12rpx;
padding: 16rpx 24rpx;
font-size: 26rpx;
}
.list-wrap {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.record-card {
background: #fff;
border-radius: 20rpx;
padding: 28rpx;
}
.row {
display: flex;
justify-content: space-between;
font-size: 26rpx;
padding: 8rpx 0;
}
.row.head {
border-bottom: 1rpx dashed #E5E6EB;
margin-bottom: 12rpx;
padding-bottom: 16rpx;
}
.source {
font-weight: 600;
color: $color-text-main;
}
.change {
font-weight: 700;
font-family: DIN Alternate, monospace, sans-serif;
}
.inc { color: $theme-primary; }
.dec { color: #F53F3F; }
.label {
color: $color-text-muted;
}
.value {
color: $color-text-main;
text-align: right;
flex: 1;
margin-left: 16rpx;
word-break: break-all;
}
.order-link {
margin-top: 16rpx;
text-align: right;
color: $theme-primary;
font-size: 26rpx;
}
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 100rpx 0;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<business-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>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
amount: '',
balanceText: '-',
loading: false,
quickAmounts: [
{ key: '1000', value: 1000 },
{ key: '3000', value: 3000 },
{ key: '5000', value: 5000 },
{ key: '10000', value: 10000 },
],
}
},
onLoad() {
if (!this.bizApi.getMyBalance) return
this.bizApi.getMyBalance().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
this.bizApi.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,145 @@
<template>
<business-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>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
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() {
this.bizApi.getMyCard && this.bizApi.getMyCard().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() {
if (!this.bizApi.saveMyCard) return
this.loading = true
this.bizApi.saveMyCard(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,429 @@
<template>
<business-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>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { withWxKey } from '@/utils/wxListKey.js'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import { withdrawalCheckStatusText, formatDateTime } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
cardTitle: '我的账户',
card: {
bank_card: '',
bank_name: ''
},
balanceItems: [
{ key: 'balance', label: '可提现余额 (元)', display: '0.00' },
{ key: 'reviewing', label: '审核中金额 (元)', display: '0.00' },
{ key: 'pending', label: '待结算收益 (元)', display: '0.00' },
],
actions: [
{ key: 'apply', label: '申请提现' },
{ key: 'settlement', label: '结算记录' },
{ key: 'accountChange', 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: `${this.bizRoot}/withdrawal/apply` });
} else if (key === 'edit') {
uni.navigateTo({ url: `${this.bizRoot}/withdrawal/card-edit` });
} else if (key === 'settlement') {
uni.navigateTo({ url: `${this.bizRoot}/withdrawal/settlement/index` });
} else if (key === 'accountChange') {
uni.navigateTo({ url: `${this.bizRoot}/withdrawal/account-change/index` });
}
},
// 提现详情跳转
goWithdrawalDetail(item) {
uni.navigateTo({ url: `${this.bizRoot}/withdrawal/record-detail?id=${item.id}` });
},
// 获取绑定的银行卡信息
loadCard() {
if (!this.bizApi.getMyCard) return
this.bizApi.getMyCard().then(w => {
if (w.ok && w.data) {
this.card = w.data;
this.cardTitle = w.data.bank_user_name || '我的账户';
}
});
},
// 获取余额数据
loadBalance() {
if (!this.bizApi.getMyBalance) return
this.bizApi.getMyBalance().then(w => {
if (w.ok && w.data) {
const fmt = (v) => formatMoney(v || 0).replace('¥', '')
const map = {
balance: fmt(w.data.balance),
reviewing: fmt(w.data.withdrawn_frozen),
pending: fmt(w.data.pending_earnings),
}
this.balanceItems = this.balanceItems.map(item => ({
...item,
display: map[item.key] ?? '0.00',
}))
}
});
},
// 刷新提现列表
reloadList() {
this.page = 1;
this.loadList(1, false);
},
// 加载提现列表数据
loadList(page, append) {
this.loading = true;
this.bizApi.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(3, 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,171 @@
<template>
<business-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>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import {
formatDateTime,
withdrawalCheckStatusText,
dakuanStatusText as dakuanStatusLabel,
} from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return { id: 0, info: {} }
},
onLoad(q) {
this.id = Number(q.id || 0)
this.bizApi.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,203 @@
<template>
<business-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>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import { withWxKey } from '@/utils/wxListKey.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
ledgerLogId: 0,
orderId: 0,
orderType: 0,
detail: { items: [], sum_money: '0' },
ledgerLogSummary: null,
ledgerItems: [],
loading: true,
}
},
onLoad(q) {
this.ledgerLogId = Number(q.ledger_log_id || 0)
this.orderId = Number(q.order_id || 0)
this.orderType = Number(q.order_type || 0)
this.load()
},
methods: {
formatMoney,
load() {
this.loading = true
const params = this.orderId > 0 && this.orderType > 0
? { order_id: this.orderId, order_type: this.orderType }
: { ledger_log_id: this.ledgerLogId }
this.bizApi.getLedgerDetail(params).then(w => {
if (w.ok) {
this.detail = w.data || {}
const log = this.detail.ledger_log
this.ledgerLogSummary = log || null
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,302 @@
<template>
<business-page-layout title="结算记录" :show-back="true">
<view class="settlement-container">
<view class="filter-bar">
<input
v-model="orderNo"
class="search-input"
placeholder="搜索订单号"
confirm-type="search"
@confirm="reload"
/>
<view class="search-btn" @click="reload">搜索</view>
</view>
<view class="merge-bar">
<text class="merge-label">同订单合并</text>
<switch :checked="mergeByOrder" color="#6acdbb" @change="onMergeChange" />
</view>
<view class="list-wrap">
<view
v-for="item in list"
:key="item.id"
class="premium-card"
>
<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" v-if="rowDisplay(item).orderTypeText">
<text class="label">订单类型</text>
<text class="value">{{ rowDisplay(item).orderTypeText }}</text>
</view>
<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 class="card-actions">
<text class="action-link" @click.stop="goSettlementDetail(item)">结算明细</text>
<text
v-if="Number(item.order_type) === 1"
class="action-link primary"
@click.stop="goOrderDetail(item)"
>订单详情</text>
</view>
</view>
</view>
<view v-if="!loading && list.length === 0" class="empty-state">
暂无结算记录
</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import { mapSettlementRow } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
data() {
return {
list: [],
page: 1,
loading: false,
mergeByOrder: true,
orderNo: '',
}
},
onShow() {
this.reload()
},
onReachBottom() {
this.load(this.page + 1, true)
},
methods: {
rowDisplay(item) {
return mapSettlementRow(item)
},
onMergeChange(e) {
this.mergeByOrder = !!e.detail.value
this.reload()
},
reload() {
this.load(1, false)
},
load(page, append) {
this.loading = true
const params = {
page,
pageSize: 15,
merge_by_order: this.mergeByOrder ? 1 : 0,
exclude_zero_amount: 1,
}
if (this.orderNo.trim()) {
params.order_no = this.orderNo.trim()
}
this.bizApi.getLedgerLogList(params).then(w => {
const items = (w.data && w.data.items) || []
this.list = append ? this.list.concat(items) : items
this.page = page
}).finally(() => { this.loading = false })
},
goSettlementDetail(item) {
if (item.merged) {
uni.navigateTo({
url: `${this.bizRoot}/withdrawal/settlement/detail?order_id=${item.order_id}&order_type=${item.order_type}`,
})
return
}
uni.navigateTo({
url: `${this.bizRoot}/withdrawal/settlement/detail?ledger_log_id=${item.id}`,
})
},
goOrderDetail(item) {
if (Number(item.order_type) !== 1 || !item.order_id) {
uni.showToast({ title: '暂不支持查看该订单详情', icon: 'none' })
return
}
uni.navigateTo({
url: `${this.bizRoot}/order/detail?id=${item.order_id}`,
})
},
},
}
</script>
<style lang="scss" scoped>
$color-bg-base: #F5F7F8;
$color-text-main: #1D2129;
$color-text-body: #4E5969;
$color-text-muted: #86909C;
$theme-primary: #6acdbb;
.settlement-container {
background-color: $color-bg-base;
min-height: 100vh;
padding: 24rpx;
}
.filter-bar {
display: flex;
gap: 16rpx;
margin-bottom: 16rpx;
}
.search-input {
flex: 1;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 24rpx;
font-size: 28rpx;
}
.search-btn {
background: $theme-primary;
color: #fff;
border-radius: 12rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
font-size: 28rpx;
}
.merge-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
border-radius: 16rpx;
padding: 20rpx 28rpx;
margin-bottom: 24rpx;
}
.merge-label {
font-size: 28rpx;
color: $color-text-main;
}
.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);
}
.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; }
.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;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 32rpx;
margin-top: 24rpx;
padding-top: 20rpx;
border-top: 1rpx solid #F2F3F5;
}
.action-link {
font-size: 26rpx;
color: $color-text-muted;
}
.action-link.primary {
color: $theme-primary;
font-weight: 600;
}
.empty-state {
text-align: center;
color: $color-text-muted;
padding: 120rpx 0;
font-size: 28rpx;
}
</style>

View File

@@ -1,6 +1,6 @@
export const TAB_WORKBENCH = 0 export const TAB_WORKBENCH = 0
export const TAB_MY = 1 export const TAB_MY = 1
const ROOT = '/subPackages/sub_clinic_salesperson';
export const CLINIC_TAB_ROUTES = [ export const CLINIC_TAB_ROUTES = [
'/subPackages/sub_clinic_admin/home/index', '/subPackages/sub_clinic_admin/home/index',
'/subPackages/sub_clinic_admin/my/index', '/subPackages/sub_clinic_admin/my/index',
@@ -9,14 +9,16 @@ export const CLINIC_TAB_ROUTES = [
export function getClinicTabList() { export function getClinicTabList() {
return [ return [
{ {
iconPath: require('@/static/image/w-sf.png'), iconPath: 'home',
selectedIconPath: require('@/static/image/sf.png'), selectedIconPath: 'home-fill',
text: '工作台', text: '工作台',
pagePath: `${ROOT}/home/index`,
}, },
{ {
iconPath: require('@/static/image/w-wd.png'), iconPath: 'account',
selectedIconPath: require('@/static/image/wd.png'), selectedIconPath: 'account-fill',
text: '我的', text: '我的',
pagePath: `${ROOT}/my/index`,
}, },
] ]
} }

View File

@@ -21,7 +21,7 @@
</template> </template>
<script> <script>
import { getClinicTabList, switchClinicTab } from '../common/tabbar.js' import { getClinicTabList, switchClinicTab } from '@/subPackages/sub_clinic_admin/common/tabbar.js'
export default { export default {
name: 'ClinicPageLayout', name: 'ClinicPageLayout',

View File

@@ -2,44 +2,54 @@
<clinic-page-layout title="工作台" :show-tabbar="true" :tab-index="0"> <clinic-page-layout title="工作台" :show-tabbar="true" :tab-index="0">
<view class="workspace-container"> <view class="workspace-container">
<!-- 头部品牌卡片保留高质感的品牌渐变色改为通栏无圆角 --> <!-- 极简流线型顶部个人信息区与推广员/平台管理风格一致 -->
<view class="brand-card"> <view class="hero-section">
<view class="brand-info"> <view class="brand-info-wrap">
<view class="badge"> <view class="brand-text">
<text class="badge-text">诊所管理系统</text> <view class="badge"><text class="badge-text">诊所管理系统</text></view>
<text class="store-name">{{ storeName }}</text>
<text class="store-sub">欢迎回来今天也要高效工作</text>
</view>
<!-- 诊所专属二维码 -->
<view class="qr-container" @click="openStoreQrCode">
<image
v-if="storeQrCode"
class="qr-image"
:src="storeQrCode"
mode="aspectFit"
/>
<view v-else class="qr-placeholder">
<u-icon name="scan" size="48" color="#666"></u-icon>
</view>
<text class="qr-hint">诊所名片</text>
</view> </view>
<text class="store-name">{{ storeName }}</text>
<text class="store-subtitle">欢迎回来今天也要高效工作</text>
</view> </view>
<!-- 极简几何装饰线拒绝突兀圆块 -->
<view class="brand-decorator"></view>
</view> </view>
<!-- 聚合面板分区 --> <!-- 主体内容区快捷服务多彩微拟物双列卡片 -->
<view v-for="section in sections" :key="section.key" class="section-panel"> <view class="content-wrapper">
<!-- 面板标题 --> <view v-for="section in sections" :key="section.key" class="section-panel">
<view class="panel-header"> <view class="panel-header">
<text class="panel-title">{{ section.title }}</text> <text class="panel-title">{{ section.title }}</text>
<!-- 极简的装饰点阵 -->
<view class="panel-dots">
<text class="dot"></text><text class="dot"></text><text class="dot"></text>
</view> </view>
</view>
<!-- 4列高密度网格 --> <view class="service-grid">
<view class="grid-layout"> <view
<view v-for="item in section.items"
v-for="item in section.items" :key="item.key"
:key="item.key" class="service-card"
class="grid-item" hover-class="service-card-hover"
hover-class="grid-item-active" @click="go(item.path)"
:hover-stay-time="100" >
@click="go(item.path)" <view class="service-icon-box" :style="{ background: item.theme.bg }">
> <u-icon :name="item.icon" size="38" :color="item.theme.color"></u-icon>
<view class="icon-container"> </view>
<text class="icon-char">{{ item.icon }}</text> <view class="service-info">
<text class="service-name">{{ item.label }}</text>
<text class="service-desc">{{ item.desc || '快捷进入' }}</text>
</view>
</view> </view>
<text class="item-label">{{ item.label }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -49,22 +59,34 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { getWarehouseProductTypeOptions } from '@/api/clinicAdmin.js' import { getWarehouseProductTypeOptions, getClinicAdminStoreQr } from '@/api/clinicAdmin.js'
import { openQrCodePreview, buildClinicQrPayload } from '@/utils/qrCodePreview.js'
// 全局主题色池
const GLOBAL_THEMES = [
{ bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)', color: '#F97316' }, // 橙金
{ bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)', color: '#3B82F6' }, // 科技蓝
{ bg: 'linear-gradient(135deg, #FAF5FF, #F3E8FF)', color: '#A855F7' }, // 优雅紫
{ bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)', color: '#22C55E' }, // 安全绿
]
// 补充了 desc 字段以适配新的 UI
const FINANCE_MENUS = [ const FINANCE_MENUS = [
{ key: 'withdrawal', label: '提现管理', icon: '¥', path: '/subPackages/sub_clinic_admin/withdrawal/index' }, { key: 'withdrawal', label: '提现管理', desc: '提现流水查询', icon: 'red-packet-fill', theme: GLOBAL_THEMES[0], path: '/subPackages/sub_clinic_admin/withdrawal/index' },
{ key: 'reconciliation', label: '对账单', icon: '账', path: '/subPackages/sub_clinic_admin/reconciliation/index' }, { key: 'reconciliation', label: '对账单', desc: '财务对账明细', icon: 'file-text-fill', theme: GLOBAL_THEMES[1], path: '/subPackages/sub_clinic_admin/reconciliation/index' },
] ]
const BUSINESS_MENUS = [ const BUSINESS_MENUS = [
{ key: 'order', label: '商品订单', icon: '单', path: '/subPackages/sub_clinic_admin/order/index' }, { key: 'order', label: '商品订单', desc: '全平台订单概览', icon: 'bag-fill', theme: GLOBAL_THEMES[3], path: '/subPackages/sub_clinic_admin/order/index' },
] ]
const WAREHOUSE_FALLBACK = { const WAREHOUSE_FALLBACK = {
key: 'warehouse', key: 'warehouse',
label: '仓库管理', label: '仓库管理',
icon: '仓', // 将兜底图标改为名字首字 desc: '库存管理中心',
icon: 'car-fill',
theme: { bg: 'linear-gradient(135deg, #F3F4F6, #E5E7EB)', color: '#6B7280' }, // 占位灰
path: '/subPackages/sub_clinic_admin/warehouse/index', path: '/subPackages/sub_clinic_admin/warehouse/index',
} }
@@ -73,6 +95,8 @@ export default {
data() { data() {
return { return {
storeName: '', storeName: '',
storeQrCode: '',
storeQrData: null,
warehouseMenus: [WAREHOUSE_FALLBACK], warehouseMenus: [WAREHOUSE_FALLBACK],
} }
}, },
@@ -88,21 +112,47 @@ export default {
onShow() { onShow() {
const user = uni.getStorageSync('clinic_admin_user') || {} const user = uni.getStorageSync('clinic_admin_user') || {}
this.storeName = user.store_name || (user.store && user.store.name) || user.nick_name || '诊所管理' this.storeName = user.store_name || (user.store && user.store.name) || user.nick_name || '诊所管理'
this.loadStoreQr()
this.loadWarehouseMenus() this.loadWarehouseMenus()
}, },
methods: { methods: {
async loadStoreQr() {
this.storeQrCode = ''
this.storeQrData = null
const wrap = await getClinicAdminStoreQr()
if (!wrap || !wrap.ok) return
const store = wrap.data || {}
if (!store.qr_code) return
this.storeQrData = store
this.storeQrCode = store.qr_code
if (store.name) this.storeName = store.name
},
openStoreQrCode() {
if (!this.storeQrCode || !this.storeQrData) {
uni.showToast({ title: '诊所尚未生成二维码', icon: 'none' })
return
}
const { type, payload } = buildClinicQrPayload(this.storeQrData)
openQrCodePreview({ type, payload })
},
loadWarehouseMenus() { loadWarehouseMenus() {
getWarehouseProductTypeOptions().then(w => { getWarehouseProductTypeOptions().then(w => {
const raw = w.data const raw = w.data
const options = Array.isArray(raw) ? raw : [] const options = Array.isArray(raw) ? raw : []
const warehouseMenus = options.map((opt) => { const warehouseMenus = options.map((opt, index) => {
const value = opt.value != null ? opt.value : opt.id const value = opt.value != null ? opt.value : opt.id
const label = opt.label || opt.name || '仓库' const label = opt.label || opt.name || '仓库'
const title = label.indexOf('仓库') >= 0 ? label : label const title = label.indexOf('仓库') >= 0 ? label : label
// 为动态仓库分配不同的主题色
const theme = GLOBAL_THEMES[index % GLOBAL_THEMES.length]
return { return {
key: 'warehouse-' + value, key: 'warehouse-' + value,
label: title, label: title,
icon: label.charAt(0), // 提取仓库名称的第一个字符作为 Icon desc: `${title}库存管理`, // 增强描述
icon: 'car-fill', // 统一使用车辆图标代表仓库物流
theme: theme,
path: '/subPackages/sub_clinic_admin/warehouse/index?type=' + encodeURIComponent(value) + '&label=' + encodeURIComponent(title), path: '/subPackages/sub_clinic_admin/warehouse/index?type=' + encodeURIComponent(value) + '&label=' + encodeURIComponent(title),
} }
}) })
@@ -119,163 +169,163 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 品牌主题色 */ /* 全局容器背景:更干净的高级浅灰底色 */
$theme-primary: #6acdbb;
$theme-dark: #4db6a8;
/* 页面底色采用极淡的灰底,用来衬托纯白面板 */
$color-page-bg: #F5F7F8;
$color-text-title: #222B2A;
$color-text-sub: #5B6B69;
.workspace-container { .workspace-container {
/* 移除左右和顶部 padding保留底部安全距离 */ padding-bottom: 80rpx;
padding: 0 0 60rpx; background-color: #F4F6F8;
background-color: $color-page-bg;
min-height: 100vh; min-height: 100vh;
box-sizing: border-box;
} }
/* 顶部品牌卡片:通栏布局,移除圆角 */ /* ================= 极简顶部英雄区 ================= */
.brand-card { .hero-section {
position: relative; background: linear-gradient(135deg, #6acdbb 0%, #4db6a8 100%);
background: linear-gradient(135deg, $theme-primary 0%, $theme-dark 100%); padding: 40rpx 40rpx 60rpx;
padding: 48rpx 40rpx 44rpx; border-bottom-left-radius: 48rpx;
margin-bottom: 24rpx; border-bottom-right-radius: 48rpx;
color: #ffffff; box-shadow: 0 4rpx 20rpx rgba(77, 182, 168, 0.2);
box-shadow: 0 4rpx 16rpx rgba(106, 205, 187, 0.15);
overflow: hidden;
} }
.brand-info { .brand-info-wrap {
position: relative; display: flex;
z-index: 2; justify-content: space-between;
align-items: center;
}
.brand-text {
flex: 1;
} }
.badge { .badge {
display: inline-flex; display: inline-flex;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.15);
border: 1rpx solid rgba(255, 255, 255, 0.3); backdrop-filter: blur(8px);
padding: 6rpx 16rpx; padding: 6rpx 20rpx;
border-radius: 30rpx; border-radius: 30rpx;
margin-bottom: 24rpx; margin-bottom: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.2);
} }
.badge-text { .badge-text {
font-size: 20rpx; font-size: 22rpx;
font-weight: 600; color: #fff;
color: #ffffff; font-weight: 500;
letter-spacing: 2rpx;
} }
.store-name { .store-name {
font-size: 40rpx; font-size: 42rpx;
font-weight: 700; font-weight: 600;
color: #fff;
display: block; display: block;
line-height: 1.3; margin-bottom: 6rpx;
margin-bottom: 8rpx;
} }
.store-subtitle { .store-sub {
font-size: 24rpx; font-size: 26rpx;
opacity: 0.85; color: rgba(255, 255, 255, 0.85);
display: block; display: block;
} }
.brand-decorator { /* 二维码容器样式 */
position: absolute; .qr-container {
right: -60rpx;
bottom: -60rpx;
width: 280rpx;
height: 280rpx;
border: 36rpx solid rgba(255, 255, 255, 0.06);
border-radius: 50%;
z-index: 1;
}
/* 聚合大面板:通栏布局,移除圆角 */
.section-panel {
background-color: #ffffff;
padding: 32rpx 32rpx 40rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.02);
}
/* 面板标题区 */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 8rpx;
margin-bottom: 32rpx;
}
.panel-title {
font-size: 30rpx;
font-weight: 700;
color: $color-text-title;
}
.panel-dots {
display: flex;
gap: 6rpx;
.dot {
width: 8rpx;
height: 8rpx;
background-color: rgba(106, 205, 187, 0.3);
border-radius: 50%;
}
}
/* 4列网格布局 */
.grid-layout {
display: grid;
grid-template-columns: repeat(4, 1fr); /* 严格一行四列 */
row-gap: 40rpx; /* 行间距适中,列间距靠 fr 自动均分 */
}
/* 单个网格元素 */
.grid-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
transition: opacity 0.2s;
} }
.grid-item-active { .qr-image, .qr-placeholder {
opacity: 0.6; width: 110rpx;
} height: 110rpx;
background: #fff;
/* 图标容器:采用类 iOS 的超椭圆,并透出主题色 */ border-radius: 24rpx;
.icon-container { padding: 10rpx;
width: 88rpx; box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
height: 88rpx;
border-radius: 28rpx;
background: linear-gradient(135deg, rgba(106, 205, 187, 0.15), rgba(106, 205, 187, 0.05));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 16rpx;
} }
.icon-char { .qr-hint {
font-size: 38rpx; font-size: 20rpx;
color: $theme-dark; color: rgba(255, 255, 255, 0.9);
margin-top: 12rpx;
background: rgba(0, 0, 0, 0.1);
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
/* ================= 主体内容区:快捷服务 ================= */
.content-wrapper {
margin-top: 30rpx; /* 轻微向上偏移,与顶部区域产生连接感 */
position: relative;
z-index: 10;
}
.section-panel {
margin: 0 30rpx 30rpx;
background: transparent;
}
.panel-header {
margin-bottom: 24rpx;
padding: 0 10rpx;
}
.panel-title {
font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #1E293B; /* 深石板灰 */
} }
/* 文字标签:处理过长文本的截断 */ /* 宫格卡片布局:与平台端/推广员端一致的两列 */
.item-label { .service-grid {
font-size: 24rpx; display: grid;
color: $color-text-sub; grid-template-columns: repeat(2, 1fr);
font-weight: 500; gap: 20rpx;
text-align: center; }
width: 100%;
padding: 0 8rpx; .service-card {
box-sizing: border-box; background: #FFFFFF;
white-space: nowrap; border-radius: 28rpx;
overflow: hidden; padding: 30rpx 24rpx;
text-overflow: ellipsis; display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.01);
}
.service-card-hover {
transform: translateY(-2rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.service-icon-box {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.5); /* 顶部微高光,增加拟物质感 */
}
.service-info {
display: flex;
flex-direction: column;
}
.service-name {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 6rpx;
}
.service-desc {
font-size: 20rpx;
color: #94A3B8; /* 银灰色 */
} }
</style> </style>

View File

@@ -18,13 +18,13 @@
</view> </view>
</view> </view>
<!-- 信息与设置面板通栏 --> <!-- &lt;!&ndash; 信息与设置面板通栏 &ndash;&gt;-->
<view class="setting-panel"> <!-- <view class="setting-panel">-->
<view class="setting-item"> <!-- <view class="setting-item">-->
<text class="item-label">所属诊所</text> <!-- <text class="item-label">所属诊所</text>-->
<text class="item-value">{{ storeName }}</text> <!-- <text class="item-value">{{ storeName }}</text>-->
</view> <!-- </view>-->
</view> <!-- </view>-->
<!-- 操作面板通栏 --> <!-- 操作面板通栏 -->
<view class="setting-panel"> <view class="setting-panel">
@@ -46,8 +46,9 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue' import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
import { clearLoginStorage } from '@/utils/loginSession.js'
export default { export default {
components: { ClinicPageLayout, AccountSwitchEntry }, components: { ClinicPageLayout, AccountSwitchEntry },
@@ -76,9 +77,7 @@ export default {
confirmColor: '#f53f3f', // 将确认按钮改为警示红,提升用户体验 confirmColor: '#f53f3f', // 将确认按钮改为警示红,提升用户体验
success: (res) => { success: (res) => {
if (!res.confirm) return if (!res.confirm) return
uni.removeStorageSync('clinic_admin_token') clearLoginStorage()
uni.removeStorageSync('clinic_admin_user')
uni.removeStorageSync('loginMode')
uni.reLaunch({ url: '/pages/login/index' }) uni.reLaunch({ url: '/pages/login/index' })
}, },
}) })

View File

@@ -23,7 +23,7 @@
</template> </template>
<script> <script>
import { mapExpressDetail } from '../../common/display.js' import { mapExpressDetail } from '@/subPackages/sub_clinic_admin/common/display.js'
export default { export default {
name: 'ExpressTimeline', name: 'ExpressTimeline',

View File

@@ -169,7 +169,7 @@
<script> <script>
import { getPrescriptionDetail } from '@/api/clinicAdmin.js' import { getPrescriptionDetail } from '@/api/clinicAdmin.js'
import { mapPrescriptionDetailView } from '../../common/display.js' import { mapPrescriptionDetailView } from '@/subPackages/sub_clinic_admin/common/display.js'
export default { export default {
name: 'PrescriptionDetailPopup', name: 'PrescriptionDetailPopup',

View File

@@ -137,7 +137,7 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import ExpressTimeline from './components/ExpressTimeline.vue' import ExpressTimeline from './components/ExpressTimeline.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue' import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import { import {
@@ -153,8 +153,8 @@ import {
erpSyncText, erpSyncText,
mapOrderDetailDisplay, mapOrderDetailDisplay,
mapOrderProductItem, mapOrderProductItem,
} from '../common/display.js' } from '@/subPackages/sub_clinic_admin/common/display.js'
import { formatMoney } from '../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
export default { export default {
components: { ClinicPageLayout, ExpressTimeline, PrescriptionDetailPopup }, components: { ClinicPageLayout, ExpressTimeline, PrescriptionDetailPopup },

View File

@@ -135,24 +135,24 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import PageStickyHeader from '../components/PageStickyHeader.vue' import PageStickyHeader from '@/subPackages/sub_clinic_admin/components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '../components/CollapsibleFilterPanel.vue' import CollapsibleFilterPanel from '@/subPackages/sub_clinic_admin/components/CollapsibleFilterPanel.vue'
import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue' import PrescriptionDetailPopup from './components/PrescriptionDetailPopup.vue'
import { getOrderList, getOrderStatusOption, getOrderSaleAmount } from '@/api/clinicAdmin.js' import { getOrderList, getOrderStatusOption, getOrderSaleAmount } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js' import { withWxKey } from '@/utils/wxListKey.js'
import { mapOrderListRow } from '../common/display.js' import { mapOrderListRow } from '@/subPackages/sub_clinic_admin/common/display.js'
import { formatMoney } from '../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { import {
buildOrderListParams, buildOrderListParams,
buildOrderSaleAmountParams, buildOrderSaleAmountParams,
} from '../common/orderParams.js' } from '@/subPackages/sub_clinic_admin/common/orderParams.js'
import { import {
loadOrderFilter, loadOrderFilter,
saveOrderFilter, saveOrderFilter,
clearOrderFilter, clearOrderFilter,
defaultOrderFilter, defaultOrderFilter,
} from '../common/filterCache.js' } from '@/subPackages/sub_clinic_admin/common/filterCache.js'
const DELIVERY_OPTIONS = [ const DELIVERY_OPTIONS = [
{ label: '全部', value: '' }, { label: '全部', value: '' },

View File

@@ -152,9 +152,9 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { formatMoney } from '../../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { mapReconciliationOrderSummary } from '../../common/display.js' import { mapReconciliationOrderSummary } from '@/subPackages/sub_clinic_admin/common/display.js'
import { import {
getOrderTrace, getOrderTrace,
getOrderLedgerDetail, getOrderLedgerDetail,

View File

@@ -210,9 +210,9 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import PageStickyHeader from '../components/PageStickyHeader.vue' import PageStickyHeader from '@/subPackages/sub_clinic_admin/components/PageStickyHeader.vue'
import CollapsibleFilterPanel from '../components/CollapsibleFilterPanel.vue' import CollapsibleFilterPanel from '@/subPackages/sub_clinic_admin/components/CollapsibleFilterPanel.vue'
import { import {
getReconciliationList, getReconciliationList,
getReconciliationDrugList, getReconciliationDrugList,
@@ -225,19 +225,19 @@ import {
mapReconciliationDrugRow, mapReconciliationDrugRow,
mapReconciliationStoreCount, mapReconciliationStoreCount,
mapReconciliationDrugCount, mapReconciliationDrugCount,
} from '../common/display.js' } from '@/subPackages/sub_clinic_admin/common/display.js'
import { import {
buildStoreListParams, buildStoreListParams,
buildDrugListParams, buildDrugListParams,
buildSearchTimeQueryParams, buildSearchTimeQueryParams,
normalizeReconciliationResponse, normalizeReconciliationResponse,
} from '../common/reconciliationParams.js' } from '@/subPackages/sub_clinic_admin/common/reconciliationParams.js'
import { import {
loadReconciliationFilter, loadReconciliationFilter,
saveReconciliationFilter, saveReconciliationFilter,
clearReconciliationFilter, clearReconciliationFilter,
defaultReconciliationFilter, defaultReconciliationFilter,
} from '../common/filterCache.js' } from '@/subPackages/sub_clinic_admin/common/filterCache.js'
export default { export default {
components: { ClinicPageLayout, PageStickyHeader, CollapsibleFilterPanel }, components: { ClinicPageLayout, PageStickyHeader, CollapsibleFilterPanel },

View File

@@ -67,14 +67,14 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue' import PriceEditModal from './components/PriceEditModal.vue'
import { import {
getWarehouseDetail, getWarehouseDetail,
updateWarehouseStatus, updateWarehouseStatus,
updateWarehousePrice, updateWarehousePrice,
} from '@/api/clinicAdmin.js' } from '@/api/clinicAdmin.js'
import { mapWarehouseRow } from '../common/display.js' import { mapWarehouseRow } from '@/subPackages/sub_clinic_admin/common/display.js'
export default { export default {
components: { ClinicPageLayout, PriceEditModal }, components: { ClinicPageLayout, PriceEditModal },

View File

@@ -64,7 +64,7 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import PriceEditModal from './components/PriceEditModal.vue' import PriceEditModal from './components/PriceEditModal.vue'
import { import {
getWarehouseList, getWarehouseList,
@@ -72,7 +72,7 @@ import {
updateWarehousePrice, updateWarehousePrice,
} from '@/api/clinicAdmin.js' } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js' import { withWxKey } from '@/utils/wxListKey.js'
import { mapWarehouseRow } from '../common/display.js' import { mapWarehouseRow } from '@/subPackages/sub_clinic_admin/common/display.js'
export default { export default {
components: { ClinicPageLayout, PriceEditModal }, components: { ClinicPageLayout, PriceEditModal },

View File

@@ -43,7 +43,7 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { getAccountChangeLogList } from '@/api/clinicAdmin.js' import { getAccountChangeLogList } from '@/api/clinicAdmin.js'
export default { export default {

View File

@@ -13,8 +13,8 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { formatMoney } from '../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { getClinicAdminBalance, submitWithdrawal } from '@/api/clinicAdmin.js' import { getClinicAdminBalance, submitWithdrawal } from '@/api/clinicAdmin.js'
export default { export default {

View File

@@ -23,7 +23,7 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { getClinicAdminCard, saveClinicAdminCard } from '@/api/clinicAdmin.js' import { getClinicAdminCard, saveClinicAdminCard } from '@/api/clinicAdmin.js'
export default { export default {

View File

@@ -74,11 +74,11 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { getClinicAdminCard, getClinicAdminBalance, getWithdrawalList } from '@/api/clinicAdmin.js' import { getClinicAdminCard, getClinicAdminBalance, getWithdrawalList } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js' import { withWxKey } from '@/utils/wxListKey.js'
import { formatMoney } from '../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { withdrawalCheckStatusText, formatDateTime } from '../common/display.js' import { withdrawalCheckStatusText, formatDateTime } from '@/subPackages/sub_clinic_admin/common/display.js'
export default { export default {
components: { ClinicPageLayout }, components: { ClinicPageLayout },

View File

@@ -42,13 +42,13 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { formatMoney } from '../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { import {
formatDateTime, formatDateTime,
withdrawalCheckStatusText, withdrawalCheckStatusText,
dakuanStatusText as dakuanStatusLabel, dakuanStatusText as dakuanStatusLabel,
} from '../common/display.js' } from '@/subPackages/sub_clinic_admin/common/display.js'
import { getWithdrawalDetail } from '@/api/clinicAdmin.js' import { getWithdrawalDetail } from '@/api/clinicAdmin.js'
export default { export default {

View File

@@ -37,8 +37,8 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { formatMoney } from '../../common/format.js' import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { getLedgerDetail } from '@/api/clinicAdmin.js' import { getLedgerDetail } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js' import { withWxKey } from '@/utils/wxListKey.js'

View File

@@ -71,8 +71,8 @@
</template> </template>
<script> <script>
import ClinicPageLayout from '../../components/clinic-page-layout.vue' import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { mapSettlementRow } from '../../common/display.js' import { mapSettlementRow } from '@/subPackages/sub_clinic_admin/common/display.js'
import { getLedgerLogList } from '@/api/clinicAdmin.js' import { getLedgerLogList } from '@/api/clinicAdmin.js'
export default { export default {

View File

@@ -0,0 +1,49 @@
<template>
<clinic-salesperson-page-layout title="分成记录" :show-back="true">
<view class="page">
<view v-if="!list.length" class="empty">暂无分成记录</view>
<view v-for="item in list" :key="item.id" class="row">
<view>
<text class="order">{{ item.order_no }}</text>
<text class="drug">{{ item.drug_name }} ×{{ item.qty }}</text>
</view>
<view class="right">
<text class="amount">¥{{ item.commission_amount }}</text>
<text class="status">{{ item.settlement_id > 0 ? '已结算' : '待结算' }}</text>
</view>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import { getClinicSalespersonCommissionList } from '@/api/clinicSalesperson.js'
export default {
components: { ClinicSalespersonPageLayout },
data() {
return { list: [] }
},
onLoad() {
this.loadList()
},
methods: {
async loadList() {
const wrap = await getClinicSalespersonCommissionList({ page: 1, pageSize: 50 })
if (wrap && wrap.ok) this.list = wrap.data?.items || []
},
},
}
</script>
<style lang="scss" scoped>
.page { padding: 24rpx; }
.empty { text-align: center; color: #999; padding: 80rpx 0; }
.row { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; display: flex; justify-content: space-between; }
.order { display: block; font-size: 26rpx; font-weight: 600; }
.drug { font-size: 24rpx; color: #666; }
.right { text-align: right; }
.amount { display: block; font-size: 28rpx; color: #4db6a8; font-weight: 600; }
.status { font-size: 22rpx; color: #999; }
</style>

View File

@@ -0,0 +1,20 @@
const ROOT = '/subPackages/sub_clinic_salesperson';
export function getClinicSalespersonContext() {
return {
tabList: [
{
iconPath: 'home',
selectedIconPath: 'home-fill',
text: '工作台',
pagePath: `${ROOT}/home/index`,
},
{
iconPath: 'account',
selectedIconPath: 'account-fill',
text: '我的',
pagePath: `${ROOT}/my/index`,
},
],
};
}

View File

@@ -0,0 +1,78 @@
<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 { getClinicSalespersonContext } from '@/subPackages/sub_clinic_salesperson/common/context.js';
export default {
name: 'ClinicSalespersonPageLayout',
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: getClinicSalespersonContext().tabList,
};
},
computed: {
bodyStyle() {
const top = this.statusBarHeight + this.navbarHeight;
let bottom = '32rpx';
if (this.showTabbar) {
// bottom = 'calc(100rpx + env(safe-area-inset-bottom))';
bottom = '-7rpx';
}
return {
paddingTop: top + 'px',
paddingBottom: bottom,
minHeight: '100vh',
boxSizing: 'border-box',
};
},
},
created() {
this.statusBarHeight = this.$statusBarHeight || 0;
this.navbarHeight = this.$navbarHeight || 44;
},
methods: {
onTabChange(index) {
if (index === this.tabIndex) return;
const target = this.tabList[index];
if (target && target.pagePath) {
uni.reLaunch({ url: target.pagePath });
}
},
},
};
</script>
<style lang="scss" scoped>
.clinic-layout {
min-height: 100vh;
background: #f9fafb;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<clinic-salesperson-page-layout title="我的收益" :show-back="true">
<view class="page">
<view class="card" v-for="item in cards" :key="item.label">
<text class="label">{{ item.label }}</text>
<text class="value">{{ item.value }}</text>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import { getClinicSalespersonMyInfo } from '@/api/clinicSalesperson.js'
export default {
components: { ClinicSalespersonPageLayout },
data() {
return { cards: [] }
},
onLoad() {
this.loadData()
},
methods: {
async loadData() {
const wrap = await getClinicSalespersonMyInfo()
if (!wrap || !wrap.ok) return
const stats = wrap.data?.stats || {}
this.cards = [
{ label: '累计分成', value: `¥${stats.money || '0.00'}` },
{ label: '待结算', value: `¥${stats.pending_amount || '0.00'}` },
{ label: '已结算', value: `¥${stats.settled_amount || '0.00'}` },
{ label: '累计获客', value: `${stats.user_number || 0}` },
]
},
},
}
</script>
<style lang="scss" scoped>
.page { padding: 24rpx; }
.card { background: #fff; border-radius: 16rpx; padding: 32rpx; margin-bottom: 20rpx; display: flex; justify-content: space-between; }
.label { color: #666; font-size: 28rpx; }
.value { font-size: 32rpx; font-weight: 600; color: #333; }
</style>

View File

@@ -0,0 +1,498 @@
<template>
<clinic-salesperson-page-layout title="推广员工作台" :show-tabbar="true" :tab-index="0">
<view class="workspace-container">
<!-- 极简流线型顶部个人信息区 -->
<view class="hero-section">
<view class="brand-info-wrap">
<view class="brand-text">
<view class="badge"><text class="badge-text">推广员工作台</text></view>
<text class="store-name">{{ displayName }}</text>
<text class="store-sub">{{ storeName }}</text>
</view>
<view class="qr-container" @click="openQrCode">
<image
v-if="qrCode"
class="qr-image"
:src="qrCode"
mode="aspectFit"
/>
<view v-else class="qr-placeholder">
<!-- 替换电子名片 emoji -->
<u-icon name="scan" size="48" color="#666"></u-icon>
</view>
<text class="qr-hint">电子名片</text>
</view>
</view>
</view>
<!-- 核心资产看板深色高定质感 -->
<view class="asset-dashboard">
<!-- 主资产卡片 (深邃质感) -->
<view class="primary-asset-card">
<!-- 装饰性光晕 -->
<view class="glow-orb glow-top-right"></view>
<view class="glow-orb glow-bottom-left"></view>
<view class="card-content-relative">
<view class="asset-header">
<view class="asset-title-wrap">
<text class="asset-title">待结算收益 ()</text>
<view class="asset-status-dot"></view>
</view>
<view class="asset-tag">即将入账</view>
</view>
<view class="asset-amount-wrap">
<text class="asset-currency">¥</text>
<text class="asset-amount-huge">{{ stats.pending_amount || '0.00' }}</text>
</view>
</view>
</view>
<!-- 副指标区胶囊状悬浮卡片 -->
<view class="secondary-stats-row">
<view class="stat-capsule">
<view class="capsule-icon-wrap bg-blue">
<!-- 替换已结算 emoji -->
<u-icon name="rmb-circle-fill" size="36" color="#3B82F6"></u-icon>
</view>
<view class="capsule-text-wrap">
<text class="capsule-label">已结算</text>
<text class="capsule-value">{{ stats.settled_amount || '0.00' }}</text>
</view>
</view>
<view class="stat-capsule">
<view class="capsule-icon-wrap bg-purple">
<!-- 替换累计获客 emoji -->
<u-icon name="account-fill" size="36" color="#A855F7"></u-icon>
</view>
<view class="capsule-text-wrap">
<text class="capsule-label">累计获客</text>
<text class="capsule-value">{{ stats.user_number || 0 }}</text>
</view>
</view>
</view>
</view>
<!-- 快捷服务区多彩微拟物质感 -->
<view class="section-panel">
<view class="panel-header">
<text class="panel-title">常用服务</text>
</view>
<view class="service-grid">
<view
v-for="(item, index) in enrichedMenuItems"
:key="item.key"
class="service-card"
hover-class="service-card-hover"
@click="go(item.path)"
>
<view class="service-icon-box" :style="{ background: item.theme.bg }">
<!-- 替换为 uView 动态图标 -->
<u-icon :name="item.icon" size="38" :color="item.theme.color"></u-icon>
</view>
<view class="service-info">
<text class="service-name">{{ item.label }}</text>
<text class="service-desc">{{ item.desc }}</text>
</view>
</view>
</view>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import { getClinicSalespersonMyInfo } from '@/api/clinicSalesperson.js'
import { openQrCodePreview, buildSalespersonQrPayload } from '@/utils/qrCodePreview.js'
const ROOT = '/subPackages/sub_clinic_salesperson'
export default {
components: { ClinicSalespersonPageLayout },
data() {
return {
displayName: '',
storeName: '',
qrCode: '',
profileInfo: null,
stats: {},
menuItems: [
{ key: 'earnings', label: '我的收益', path: `${ROOT}/earnings/index` },
{ key: 'leads', label: '获客记录', path: `${ROOT}/leads/index` },
{ key: 'commission', label: '分成记录', path: `${ROOT}/commission/index` },
{ key: 'settlement', label: '结算记录', path: `${ROOT}/settlement/index` },
],
}
},
computed: {
// 动态注入多彩主题和增强文案,使用 uView 图标名称
enrichedMenuItems() {
const themes = [
// 收益:使用钻石/钱包相关图标
{ bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)', color: '#F97316', desc: '查看收益明细', icon: 'red-packet-fill' },
// 获客:使用目标/数据相关图标
{ bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)', color: '#3B82F6', desc: '客户转化漏斗', icon: 'bag-fill' },
// 分成:使用图表/比例相关图标
{ bg: 'linear-gradient(135deg, #FAF5FF, #F3E8FF)', color: '#A855F7', desc: '分佣比例与记录', icon: 'red-packet-fill' },
// 结算:使用卡片/提现相关图标
{ bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)', color: '#22C55E', desc: '提现与账单', icon: 'rmb-circle-fill' },
];
return this.menuItems.map((item, index) => ({
...item,
icon: themes[index % themes.length].icon,
desc: themes[index % themes.length].desc,
theme: themes[index % themes.length]
}));
}
},
onShow() {
this.loadInfo()
},
methods: {
async loadInfo() {
const wrap = await getClinicSalespersonMyInfo()
if (!wrap || !wrap.ok) return
const info = wrap.data || {}
this.profileInfo = info
this.displayName = info.salesperson?.nick_name || '推广员'
this.storeName = info.store?.name || ''
this.qrCode = info.qr_code || ''
this.stats = info.stats || {}
uni.setStorageSync('clinic_salesperson_user', info)
},
openQrCode() {
if (!this.qrCode) {
uni.showToast({ title: '暂无推广二维码', icon: 'none' })
return
}
const { type, payload } = buildSalespersonQrPayload(this.profileInfo || {
qr_code: this.qrCode,
store: { name: this.storeName },
salesperson: { nick_name: this.displayName },
})
openQrCodePreview({ type, payload })
},
go(path) {
uni.navigateTo({ url: path })
},
},
}
</script>
<style lang="scss" scoped>
/* 全局容器背景 */
.workspace-container {
padding-bottom: 80rpx;
background-color: #F4F6F8; /* 更干净的高级浅灰底色 */
min-height: 100vh;
}
/* ================= 极简顶部英雄区 ================= */
.hero-section {
background: linear-gradient(135deg, #6acdbb 0%, #4db6a8 100%);
padding: 40rpx 40rpx 120rpx; /* 底部预留负边距空间 */
border-bottom-left-radius: 48rpx;
border-bottom-right-radius: 48rpx;
box-shadow: 0 4rpx 20rpx rgba(77, 182, 168, 0.2);
}
.brand-info-wrap {
display: flex;
justify-content: space-between;
align-items: center;
}
.brand-text {
flex: 1;
}
.badge {
display: inline-flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
padding: 6rpx 20rpx;
border-radius: 30rpx;
margin-bottom: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.badge-text {
font-size: 22rpx;
color: #fff;
font-weight: 500;
letter-spacing: 2rpx;
}
.store-name {
font-size: 42rpx;
font-weight: 600;
color: #fff;
display: block;
margin-bottom: 6rpx;
}
.store-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
display: block;
}
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
}
.qr-image, .qr-placeholder {
width: 110rpx;
height: 110rpx;
background: #fff;
border-radius: 24rpx;
padding: 10rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
/* 移除了无用的 .qr-icon-placeholder 类 */
.qr-hint {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 12rpx;
background: rgba(0, 0, 0, 0.1);
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
/* ================= 核心资产看板区 ================= */
.asset-dashboard {
margin: -80rpx 30rpx 40rpx;
position: relative;
z-index: 10;
}
/* 主卡片:黑金/深色沉浸质感 */
.primary-asset-card {
position: relative;
background: linear-gradient(145deg, #1E293B 0%, #0F172A 100%);
border-radius: 32rpx;
padding: 40rpx;
box-shadow: 0 16rpx 40rpx rgba(15, 23, 42, 0.15);
overflow: hidden;
margin-bottom: 24rpx;
}
/* 抽象光晕装饰背景,提升高定感 */
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.4;
pointer-events: none;
}
.glow-top-right {
width: 200rpx;
height: 200rpx;
background: #4DB6A8;
top: -50rpx;
right: -50rpx;
}
.glow-bottom-left {
width: 150rpx;
height: 150rpx;
background: #3B82F6;
bottom: -40rpx;
left: -40rpx;
}
.card-content-relative {
position: relative;
z-index: 2;
}
.asset-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.asset-title-wrap {
display: flex;
align-items: center;
}
.asset-title {
font-size: 26rpx;
color: #94A3B8;
font-weight: 500;
}
.asset-status-dot {
width: 12rpx;
height: 12rpx;
background-color: #10B981;
border-radius: 50%;
margin-left: 12rpx;
box-shadow: 0 0 8rpx rgba(16, 185, 129, 0.6);
}
.asset-tag {
font-size: 20rpx;
color: #F8FAFC;
background: rgba(255, 255, 255, 0.1);
padding: 6rpx 16rpx;
border-radius: 20rpx;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.asset-amount-wrap {
display: flex;
align-items: baseline;
color: #F8FAFC; /* 亮白色金额 */
}
.asset-currency {
font-size: 40rpx;
font-weight: 600;
margin-right: 8rpx;
}
.asset-amount-huge {
font-size: 80rpx;
font-weight: 700;
letter-spacing: -2rpx;
font-family: "DIN Alternate", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
}
/* 胶囊副指标区 */
.secondary-stats-row {
display: flex;
justify-content: space-between;
gap: 20rpx;
}
.stat-capsule {
flex: 1;
background: #FFFFFF;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
}
.capsule-icon-wrap {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.bg-blue { background: #EFF6FF; }
.bg-purple { background: #FAF5FF; }
/* 移除了无用的 .capsule-icon 类 */
.capsule-text-wrap {
display: flex;
flex-direction: column;
}
.capsule-label {
font-size: 22rpx;
color: #64748B;
margin-bottom: 4rpx;
}
.capsule-value {
font-size: 32rpx;
font-weight: 700;
color: #0F172A;
font-family: "DIN Alternate", sans-serif;
}
/* ================= 快捷服务区:多彩拟物 ================= */
.section-panel {
margin: 0 30rpx;
background: transparent;
}
.panel-header {
margin-bottom: 24rpx;
padding: 0 10rpx;
}
.panel-title {
font-size: 34rpx;
font-weight: 700;
color: #1E293B;
}
.service-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.service-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 30rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.01);
}
.service-card-hover {
transform: translateY(-2rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.service-icon-box {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.5); /* 顶部微高光,增加拟物质感 */
}
/* 移除了无用的 .service-icon 类 */
.service-info {
display: flex;
flex-direction: column;
}
.service-name {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 6rpx;
}
.service-desc {
font-size: 20rpx;
color: #94A3B8;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<clinic-salesperson-page-layout title="获客记录" :show-back="true">
<view class="page">
<view v-if="!list.length" class="empty">暂无获客记录</view>
<view v-for="item in list" :key="item.id" class="row">
<view>
<text class="name">{{ item.user_nick_name || '用户' }}</text>
<text class="phone">{{ item.user_phone }}</text>
</view>
<text class="time">{{ item.bind_at_text }}</text>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import { getClinicSalespersonUserBindList } from '@/api/clinicSalesperson.js'
export default {
components: { ClinicSalespersonPageLayout },
data() {
return { list: [] }
},
onLoad() {
this.loadList()
},
methods: {
async loadList() {
const wrap = await getClinicSalespersonUserBindList({ page: 1, pageSize: 50 })
if (wrap && wrap.ok) this.list = wrap.data?.items || []
},
},
}
</script>
<style lang="scss" scoped>
.page { padding: 24rpx; }
.empty { text-align: center; color: #999; padding: 80rpx 0; }
.row { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; display: flex; justify-content: space-between; align-items: center; }
.name { display: block; font-size: 28rpx; font-weight: 600; }
.phone { font-size: 24rpx; color: #666; }
.time { font-size: 22rpx; color: #999; }
</style>

View File

@@ -0,0 +1,215 @@
<template>
<clinic-salesperson-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>
<text v-if="phone" class="phone-line">{{ phone }}</text>
<text v-if="storeName" class="store-line">{{ storeName }}</text>
</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">
<account-switch-entry />
<view
class="setting-item action-item"
hover-class="item-hover"
:hover-stay-time="100"
@click="logout"
>
<text class="danger-text">退出登录</text>
<u-icon name="arrow-right" color="#C9CDD4" size="28" />
</view>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
import { getClinicSalespersonMyInfo } from '@/api/clinicSalesperson.js'
import { clearLoginStorage } from '@/utils/loginSession.js'
export default {
components: { ClinicSalespersonPageLayout, AccountSwitchEntry },
data() {
return {
displayName: '',
phone: '',
storeName: '',
loading: false,
}
},
computed: {
avatarText() {
return (this.displayName || '推').slice(0, 1)
},
},
onShow() {
this.loadProfile()
},
methods: {
async loadProfile() {
if (this.loading) return
this.loading = true
try {
const wrap = await getClinicSalespersonMyInfo()
if (!wrap || !wrap.ok) return
const info = wrap.data || {}
this.displayName = info.salesperson?.nick_name || '推广员'
this.phone = info.salesperson?.phone || ''
this.storeName = info.store?.name || ''
uni.setStorageSync('clinic_salesperson_user', info)
} finally {
this.loading = false
}
},
logout() {
uni.showModal({
title: '提示',
content: '确定退出登录?',
confirmColor: '#f53f3f',
success: (res) => {
if (!res.confirm) return
clearLoginStorage()
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-muted: #86909c;
$color-border: #f2f3f5;
$color-danger: #f53f3f;
.mine-container {
padding: 0 0 60rpx;
background-color: $color-page-bg;

View File

@@ -0,0 +1,54 @@
<template>
<clinic-salesperson-page-layout title="结算记录" :show-back="true">
<view class="page">
<view v-if="!list.length" class="empty">暂无结算记录</view>
<view v-for="item in list" :key="item.id" class="row" @click="openDetail(item.id)">
<view>
<text class="no">{{ item.settlement_no }}</text>
<text class="time">{{ item.created_at_text }}</text>
</view>
<text class="amount">¥{{ item.amount }}</text>
</view>
</view>
</clinic-salesperson-page-layout>
</template>
<script>
import ClinicSalespersonPageLayout from '@/subPackages/sub_clinic_salesperson/components/clinic-salesperson-page-layout.vue'
import { getClinicSalespersonSettlementList, getClinicSalespersonSettlementDetail } from '@/api/clinicSalesperson.js'
export default {
components: { ClinicSalespersonPageLayout },
data() {
return { list: [] }
},
onLoad() {
this.loadList()
},
methods: {
async loadList() {
const wrap = await getClinicSalespersonSettlementList({ page: 1, pageSize: 50 })
if (wrap && wrap.ok) this.list = wrap.data?.items || []
},
async openDetail(id) {
const wrap = await getClinicSalespersonSettlementDetail(id)
if (!wrap || !wrap.ok) return
const d = wrap.data || {}
uni.showModal({
title: '结算详情',
content: `单号:${d.settlement_no}\n金额¥${d.amount}\n明细${d.record_count}`,
showCancel: false,
})
},
},
}
</script>
<style lang="scss" scoped>
.page { padding: 24rpx; }
.empty { text-align: center; color: #999; padding: 80rpx 0; }
.row { background: #fff; border-radius: 12rpx; padding: 24rpx; margin-bottom: 16rpx; display: flex; justify-content: space-between; align-items: center; }
.no { display: block; font-size: 26rpx; font-weight: 600; }
.time { font-size: 22rpx; color: #999; }
.amount { font-size: 30rpx; color: #4db6a8; font-weight: 600; }
</style>

View File

@@ -0,0 +1,632 @@
import { addressOption } from '@/subPackages/sub_clinic_salesperson/utils/address.js';
/** 跨级搜索结果 */
/** 列表面板统一行 */
const MUNICIPALITY_LABELS = new Set([
'北京市',
'天津市',
'上海市',
'重庆市',
]);
let provinceIndexCache = null;
function isPrefectureCityLabel(label) {
return label.endsWith('市') && !label.endsWith('自治区');
}
function isDistrictLikeLabel(label) {
return /(?:区|县|旗)$/.test(label);
}
function detectMunicipality(province, children) {
if (MUNICIPALITY_LABELS.has(province.label)) {
return true;
}
if (!province.label.endsWith('市') || children.length === 0) {
return false;
}
const districtCount = children.filter((c) => isDistrictLikeLabel(c.label)).length;
return districtCount / children.length >= 0.8;
}
function buildProvinceIndexList() {
return (addressOption).map((province) => {
const children = (province)
.children ?? [];
return {
province: {
value: province.value,
label: province.label,
pvalue: province.pvalue,
},
children: children.map((c) => ({
value: c.value,
label: c.label,
pvalue: c.pvalue,
})),
isMunicipality: detectMunicipality(province, children),
};
});
}
function getProvinceIndexList() {
if (!provinceIndexCache) {
provinceIndexCache = buildProvinceIndexList();
}
return provinceIndexCache;
}
function findProvinceIndex(provinceId) {
if (!provinceId) {
return undefined;
}
return getProvinceIndexList().find((p) => p.province.value === provinceId);
}
function findChild(
provinceIndex,
childId,
) {
if (!childId) {
return undefined;
}
return provinceIndex.children.find((c) => c.value === childId);
}
function getCityNamePrefix(label) {
return label.replace(/市$/, '');
}
/** 省级列表 */
export function getProvinces() {
return getProvinceIndexList().map((p) => p.province);
}
/** 市级列表(直辖市返回空) */
export function getCities(provinceId) {
const index = findProvinceIndex(provinceId);
if (!index || index.isMunicipality) {
return [];
}
return index.children;
}
/** 区级列表 */
export function getDistricts(
provinceId,
cityId,
) {
const index = findProvinceIndex(provinceId);
if (!index) {
return [];
}
if (index.isMunicipality) {
return index.children;
}
if (!cityId) {
return [];
}
const city = findChild(index, cityId);
if (!city || !isPrefectureCityLabel(city.label)) {
return [];
}
const prefix = getCityNamePrefix(city.label);
return index.children.filter(
(c) =>
c.value !== cityId &&
!isPrefectureCityLabel(c.label) &&
c.label.startsWith(prefix),
);
}
/** 当前层级候选列表 */
export function getLevelOptions(
level,
ctx,
) {
if (level === 'province') {
return getProvinces();
}
if (level === 'city') {
return getCities(ctx.provinceId);
}
return getDistricts(ctx.provinceId, ctx.cityId);
}
/** 选择某级后的下一层级(无则 undefined */
export function getNextLevel(
currentLevel,
ctx,
) {
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return undefined;
}
if (currentLevel === 'province') {
return index.isMunicipality ? 'district' : 'city';
}
if (currentLevel === 'city') {
if (!ctx.cityId) {
return undefined;
}
const districts = getDistricts(ctx.provinceId, ctx.cityId);
return districts.length > 0 ? 'district' : undefined;
}
return undefined;
}
/** 当前层级选中后是否可直接完成(叶节点) */
export function canCompleteAfterSelect(
level,
ctx,
item,
) {
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return false;
}
if (level === 'district') {
return true;
}
if (level === 'city') {
if (index.isMunicipality) {
return false;
}
if (!isPrefectureCityLabel(item.label)) {
return true;
}
return getDistricts(ctx.provinceId, item.value).length === 0;
}
return false;
}
/** 组装对外 address 值 */
export function buildAddressOutput(
provinceId,
cityId,
districtId,
) {
if (!provinceId) {
return undefined;
}
const leafId = districtId ?? cityId;
if (!leafId) {
return undefined;
}
return [provinceId, leafId];
}
/** 由 address 反查省/市/区 */
export function resolveAddressValue(
value,
) {
if (!value || value.length < 2) {
return {};
}
const [provinceId, leafId] = value;
const index = findProvinceIndex(provinceId);
if (!index) {
return { provinceId };
}
const leaf = findChild(index, leafId);
if (!leaf) {
return { provinceId, province: index.province };
}
if (index.isMunicipality) {
return {
provinceId,
districtId: leafId,
province: index.province,
district: leaf,
};
}
const isLeafDistrict =
!isPrefectureCityLabel(leaf.label) && isDistrictLikeLabel(leaf.label);
if (isPrefectureCityLabel(leaf.label)) {
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
if (isLeafDistrict) {
for (const city of index.children.filter((c) =>
isPrefectureCityLabel(c.label),
)) {
const prefix = getCityNamePrefix(city.label);
if (leaf.label.startsWith(prefix)) {
return {
provinceId,
cityId: city.value,
districtId: leafId,
province: index.province,
city,
district: leaf,
};
}
}
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
return {
provinceId,
cityId: leafId,
province: index.province,
city: leaf,
};
}
/** 面包屑展示项 */
export function formatBreadcrumbItems(
resolved,
) {
const items = [];
if (resolved.provinceId && resolved.province) {
items.push({
level: 'province',
id: resolved.provinceId,
label: resolved.province.label,
});
}
const index = findProvinceIndex(resolved.provinceId);
if (index?.isMunicipality) {
if (resolved.districtId && resolved.district) {
items.push({
level: 'district',
id: resolved.districtId,
label: resolved.district.label,
});
}
return items;
}
if (resolved.cityId && resolved.city) {
items.push({
level: 'city',
id: resolved.cityId,
label: resolved.city.label,
});
}
if (resolved.districtId && resolved.district) {
items.push({
level: 'district',
id: resolved.districtId,
label: resolved.district.label,
});
}
return items;
}
/** 触发器展示文案 */
export function formatAddressDisplay(value) {
const items = formatBreadcrumbItems(resolveAddressValue(value));
return items.map((i) => i.label).join(' / ');
}
/** 当前层级搜索占位 */
export function getLevelSearchPlaceholder(level) {
const map = {
province: '搜索省',
city: '搜索市/县',
district: '搜索区',
};
return map[level];
}
/** 模糊筛选 */
export function filterAddressOptions(
options,
keyword,
) {
const q = keyword.trim().toLowerCase();
if (!q) {
return options;
}
return options.filter((o) => o.label.toLowerCase().includes(q));
}
/** 从已选上下文生成面包屑(面板内) */
export function buildBreadcrumbFromContext(
ctx,
) {
return formatBreadcrumbItems({
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: ctx.districtId,
province: findProvinceIndex(ctx.provinceId)?.province,
city: findChild(findProvinceIndex(ctx.provinceId), ctx.cityId),
district: findChild(findProvinceIndex(ctx.provinceId), ctx.districtId),
});
}
/** 根据面板内选中生成 ResolvedAddress */
export function contextToResolved(ctx) {
const index = findProvinceIndex(ctx.provinceId);
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: ctx.districtId,
province: index?.province,
city: index ? findChild(index, ctx.cityId) : undefined,
district: index ? findChild(index, ctx.districtId) : undefined,
};
}
/** 是否直辖市 */
export function isMunicipalityProvince(provinceId) {
return !!findProvinceIndex(provinceId)?.isMunicipality;
}
/** 打开面板时的初始层级(有回填则进入待选级,否则省) */
export function getInitialPickerLevel(
resolved,
) {
const index = findProvinceIndex(resolved.provinceId);
if (!resolved.provinceId || !index) {
return 'province';
}
if (index.isMunicipality) {
return 'district';
}
if (!resolved.cityId) {
return 'city';
}
const districts = getDistricts(resolved.provinceId, resolved.cityId);
if (!resolved.districtId && districts.length > 0) {
return 'district';
}
return 'city';
}
/** 当前级候选转列表行 */
export function levelOptionsToRows(
level,
ctx,
) {
return getLevelOptions(level, ctx).map((node) => ({
level,
node,
label: node.label,
complete: canCompleteAfterSelect(
level,
level === 'city'
? { provinceId: ctx.provinceId, cityId: node.value }
: level === 'district'
? {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: node.value,
}
: ctx,
node,
),
}));
}
/** 跨级模糊搜索(含第三级区/县) */
export function searchAddressHits(
keyword,
ctx,
) {
const q = keyword.trim().toLowerCase();
if (!q) {
return [];
}
const hits = [];
const seen = new Set();
const push = (hit) => {
const key = `${hit.level}-${hit.node.value}`;
if (!seen.has(key)) {
seen.add(key);
hits.push(hit);
}
};
if (!ctx.provinceId) {
for (const p of getProvinces()) {
if (p.label.toLowerCase().includes(q)) {
push({
level: 'province',
node: p,
pathLabel: p.label,
complete: false,
});
}
}
return hits;
}
const index = findProvinceIndex(ctx.provinceId);
if (!index) {
return hits;
}
if (index.isMunicipality) {
for (const d of index.children) {
if (d.label.toLowerCase().includes(q)) {
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
const cityNode = ctx.cityId ? findChild(index, ctx.cityId) : undefined;
if (!ctx.cityId) {
for (const child of index.children) {
if (!child.label.toLowerCase().includes(q)) {
continue;
}
const rowCtx = { provinceId: ctx.provinceId, cityId: child.value };
push({
level: 'city',
node: child,
pathLabel: `${index.province.label} / ${child.label}`,
complete: canCompleteAfterSelect('city', rowCtx, child),
});
}
for (const child of index.children.filter((c) => isPrefectureCityLabel(c.label))) {
for (const d of getDistricts(ctx.provinceId, child.value)) {
if (!d.label.toLowerCase().includes(q)) {
continue;
}
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${child.label} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
if (cityNode && cityNode.label.toLowerCase().includes(q)) {
push({
level: 'city',
node: cityNode,
pathLabel: `${index.province.label} / ${cityNode.label}`,
complete: canCompleteAfterSelect('city', ctx, cityNode),
});
}
for (const d of getDistricts(ctx.provinceId, ctx.cityId)) {
if (d.label.toLowerCase().includes(q)) {
push({
level: 'district',
node: d,
pathLabel: `${index.province.label} / ${cityNode?.label ?? ''} / ${d.label}`,
complete: true,
});
}
}
return hits;
}
/** 搜索命中转列表行 */
export function searchHitsToRows(hits) {
return hits.map((hit) => ({
level: hit.level,
node: hit.node,
label: hit.pathLabel,
complete: hit.complete,
}));
}
/** 应用跨级搜索选中 */
export function applySearchHit(
hit,
ctx,
) {
if (hit.level === 'province') {
return {
provinceId: hit.node.value,
cityId: undefined,
districtId: undefined,
};
}
if (hit.level === 'city') {
return {
provinceId: ctx.provinceId,
cityId: hit.node.value,
districtId: undefined,
};
}
if (hit.level === 'district' && findProvinceIndex(ctx.provinceId)?.isMunicipality) {
return {
provinceId: ctx.provinceId,
cityId: undefined,
districtId: hit.node.value,
};
}
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: hit.node.value,
};
}
/** 高亮项是否有下一级 */
export function rowHasNextLevel(
row,
ctx,
) {
if (row.level === 'province') {
return !!getNextLevel('province', {
provinceId: row.node.value,
cityId: undefined,
districtId: undefined,
});
}
if (row.level === 'city') {
return !!getNextLevel('city', {
provinceId: ctx.provinceId,
cityId: row.node.value,
districtId: undefined,
});
}
return false;
}
/** 点击面包屑项后截断选中并返回新上下文 */
export function truncateContextToBreadcrumb(
ctx,
item,
) {
if (item.level === 'province') {
return {
provinceId: item.id,
cityId: undefined,
districtId: undefined,
};
}
if (item.level === 'city') {
return {
provinceId: ctx.provinceId,
cityId: item.id,
districtId: undefined,
};
}
return {
provinceId: ctx.provinceId,
cityId: ctx.cityId,
districtId: item.id,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -323,10 +323,10 @@ import {
extractSendToUserMessageId extractSendToUserMessageId
} from '@/api/chat.js'; } from '@/api/chat.js';
import { parseMessageContent } from '../utils/messageParse.js'; import { parseMessageContent } from '../utils/messageParse.js';
import PatientDetailPanel from '../components/PatientDetailPanel.vue'; import PatientDetailPanel from '@/subPackages/sub_online_reception/components/PatientDetailPanel.vue';
import MessageBubble from '../components/MessageBubble.vue'; import MessageBubble from '@/subPackages/sub_online_reception/components/MessageBubble.vue';
import ReceptionActionBar from '../components/ReceptionActionBar.vue'; import ReceptionActionBar from '@/subPackages/sub_online_reception/components/ReceptionActionBar.vue';
import RefuseReceptionModal from '../components/RefuseReceptionModal.vue'; import RefuseReceptionModal from '@/subPackages/sub_online_reception/components/RefuseReceptionModal.vue';
import { withWxKey, ensureWxKey } from '@/utils/wxListKey.js'; import { withWxKey, ensureWxKey } from '@/utils/wxListKey.js';
import { prepareImagePath } from '@/common/js/image-compress.js'; import { prepareImagePath } from '@/common/js/image-compress.js';

View File

@@ -0,0 +1,26 @@
import { resolveBusinessContext, pageUrl, businessPageRoot } from './context.js';
export default {
inject: {
businessContext: { default: null },
},
computed: {
bizCtx() {
return resolveBusinessContext(this.businessContext);
},
bizApi() {
return this.bizCtx.api;
},
bizCap() {
return this.bizCtx.capabilities || {};
},
bizRoot() {
return businessPageRoot(this.bizCtx);
},
},
methods: {
bizPage(subPath) {
return pageUrl(this.bizCtx, subPath);
},
},
};

View File

@@ -0,0 +1,49 @@
import { platformAdminApi } from '@/api/businessApi.js';
const BUSINESS_PACKAGE_ROOT = '/subPackages/sub_business_shared';
const PLATFORM_HOME = '/subPackages/sub_platform_admin/home/index';
const PLATFORM_MY = '/subPackages/sub_platform_admin/my/index';
export function getPlatformAdminContext() {
return {
mode: 'platform_admin',
api: platformAdminApi,
userStorageKey: 'platform_admin_user',
homePath: PLATFORM_HOME,
myPath: PLATFORM_MY,
packageRoot: '/subPackages/sub_platform_admin',
businessPackageRoot: BUSINESS_PACKAGE_ROOT,
brandTitle: '平台管理工作台',
capabilities: {
canShip: true,
canRefund: true,
canStoreFilter: true,
canWithdraw: true,
canWithdrawAudit: true,
canWarehouse: true,
warehouseType: 'platform',
canStoreInput: true,
canDoctorInput: true,
canStoreInputAudit: true,
canDoctorInputAudit: true,
},
tabList: [
{ text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: PLATFORM_HOME },
{ text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: PLATFORM_MY },
],
};
}
export function resolveBusinessContext(injected) {
if (injected && injected.api) return injected;
if (uni.getStorageSync('loginMode') === 'platform_admin') return getPlatformAdminContext();
return getPlatformAdminContext();
}
export function businessPageRoot(ctx) {
return ctx.businessPackageRoot || ctx.packageRoot;
}
export function pageUrl(ctx, subPath) {
return `${businessPageRoot(ctx)}/${subPath}`;
}

View File

@@ -0,0 +1,80 @@
<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 businessMixin from '@/subPackages/sub_platform_admin/common/businessMixin.js';
export default {
name: 'BusinessPageLayout',
mixins: [businessMixin],
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,
};
},
computed: {
tabList() {
return this.bizCtx.tabList || [];
},
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;
},
methods: {
onTabChange(index) {
if (index === this.tabIndex) return;
const target = this.tabList[index];
if (target && target.pagePath) {
uni.reLaunch({ url: target.pagePath });
}
},
},
};
</script>
<style lang="scss" scoped>
.clinic-layout {
min-height: 100vh;
background: #f9fafb;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<input-detail-page :record-id="recordId" input-type="doctor" />
</template>
<script>
import InputDetailPage from '@/subPackages/sub_platform_admin/input/InputDetailPage.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
export default {
components: { InputDetailPage },
provide() { return { businessContext: getPlatformAdminContext() } },
data() {
return { recordId: 0 }
},
onLoad(q) {
this.recordId = Number(q.id || 0)
},
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<input-list-page
ref="listPage"
:audit-mode="true"
input-type="doctor"
form-path=""
detail-path="/subPackages/sub_platform_admin/doctor-input/audit/detail"
/>
</template>
<script>
import InputListPage from '@/subPackages/sub_platform_admin/input/InputListPage.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
export default {
components: { InputListPage },
provide() { return { businessContext: getPlatformAdminContext() } },
onShow() {
this.$nextTick(() => this.$refs.listPage?.reload?.())
},
onReachBottom() {
const p = this.$refs.listPage
if (p) p.load(p.page + 1, true)
},
}
</script>

View File

@@ -0,0 +1,340 @@
<template>
<business-page-layout title="工作台" :show-tabbar="true" :tab-index="0">
<view class="workspace-container">
<!-- 极简流线型顶部个人信息区 -->
<view class="hero-section">
<view class="brand-info-wrap">
<view class="brand-text">
<view class="badge"><text class="badge-text">平台管理工作台</text></view>
<text class="store-name">{{ displayName }}</text>
<text class="store-sub">欢迎回来今天也要高效工作</text>
</view>
</view>
</view>
<!-- 快捷服务区多彩微拟物质感 (复用样式逻辑) -->
<view class="content-wrapper">
<view v-for="section in sections" :key="section.key" class="section-panel">
<view class="panel-header">
<text class="panel-title">{{ section.title }}</text>
</view>
<view class="service-grid">
<view
v-for="item in section.items"
:key="item.key"
class="service-card"
hover-class="service-card-hover"
@click="go(item)"
>
<view class="service-icon-box" :style="{ background: item.theme.bg }">
<!-- 使用 uView 1.x u-icon 组件 -->
<u-icon :name="item.icon" size="38" :color="item.theme.color"></u-icon>
</view>
<view class="service-info">
<text class="service-name">{{ item.label }}</text>
<text class="service-desc">{{ item.desc || '快捷进入' }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_platform_admin/components/business-page-layout.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
import { navigateToInputForm } from '@/subPackages/sub_salesperson/common/inputNavigation.js'
import platformAdminApi from '@/api/platformAdmin.js'
const ROOT = '/subPackages/sub_platform_admin'
const SALESPERSON_ROOT = '/subPackages/sub_salesperson'
export default {
components: { BusinessPageLayout },
provide() { return { businessContext: getPlatformAdminContext() } },
data() {
return {
displayName: '',
warehouseMenus: [],
// 预定义的全局主题池,用于动态分配没有明确指定主题的项
globalThemes: [
{ bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)', color: '#F97316' }, // 橙金
{ bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)', color: '#3B82F6' }, // 科技蓝
{ bg: 'linear-gradient(135deg, #FAF5FF, #F3E8FF)', color: '#A855F7' }, // 优雅紫
{ bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)', color: '#22C55E' }, // 安全绿
]
}
},
computed: {
sections() {
const shared = '/subPackages/sub_business_shared'
const root = '/subPackages/sub_platform_admin'
return [
{
key: 'finance',
title: '财务管理',
items: [
{
key: 'withdrawal', label: '提现管理', desc: '提现流水查询', path: `${shared}/withdrawal/index`,
icon: 'red-packet-fill', // uView icon
theme: { bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)', color: '#F97316' } // 橙金
},
{
key: 'withdrawal-audit', label: '提现审核', desc: '处理提现申请', path: `${root}/withdrawal/audit/index`,
icon: 'order',
theme: { bg: 'linear-gradient(135deg, #FEE2E2, #FECACA)', color: '#EF4444' } // 警戒红
},
{
key: 'reconciliation', label: '对账单', desc: '财务对账明细', path: `${shared}/reconciliation/index`,
icon: 'file-text-fill',
theme: { bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)', color: '#3B82F6' } // 科技蓝
},
],
},
{
key: 'business',
title: '业务管理',
items: [
{
key: 'order', label: '商品订单', desc: '全平台订单概览', path: `${shared}/order/index`,
icon: 'bag-fill',
theme: { bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)', color: '#22C55E' } // 安全绿
}
],
},
{
key: 'warehouse',
title: '仓库管理',
// 动态加载的仓库数据,在 onShow 中会进行加工
items: this.warehouseMenus.length ? this.warehouseMenus : [
// 提供一个默认的占位符,防止异步加载完成前显示空白
{
key: 'warehouse-loading', label: '总仓库', desc: '库存管理中心', path: '/subPackages/sub_business_shared/warehouse/index',
icon: 'car-fill',
theme: { bg: 'linear-gradient(135deg, #F3F4F6, #E5E7EB)', color: '#6B7280' } // 占位灰
}
]
},
{
key: 'input-audit',
title: '信息审核',
items: [
{
key: 'store-audit', label: '门店审核', desc: '新入门店审批', path: `${root}/store-input/audit/index`,
icon: 'home-fill',
theme: { bg: 'linear-gradient(135deg, #FAF5FF, #F3E8FF)', color: '#A855F7' } // 优雅紫
},
{
key: 'doctor-audit', label: '医生审核', desc: '入驻医生资质', path: `${root}/doctor-input/audit/index`,
icon: 'account-fill',
theme: { bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)', color: '#3B82F6' } // 科技蓝
},
],
},
{
key: 'input-create',
title: '信息录入',
items: [
{
key: 'store-list', label: '门店列表', desc: '平台门店档案', path: `${SALESPERSON_ROOT}/store-input/index`,
icon: 'grid-fill',
theme: { bg: 'linear-gradient(135deg, #ECFEFF, #CFFAFE)', color: '#06B6D4' } // 青色
},
{
key: 'store-form', label: '门店录入', desc: '新增线下门店', path: `${SALESPERSON_ROOT}/store-input/form`, inputType: 'store', isForm: true,
icon: 'plus-square-fill',
theme: { bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)', color: '#22C55E' } // 绿色
},
{
key: 'doctor-list', label: '医生列表', desc: '平台医生名册', path: `${SALESPERSON_ROOT}/doctor-input/index`,
icon: 'list-dot',
theme: { bg: 'linear-gradient(135deg, #FFFBEB, #FEF3C7)', color: '#D97706' } // 琥珀色
},
{
key: 'doctor-form', label: '医生录入', desc: '新增合作医生', path: `${SALESPERSON_ROOT}/doctor-input/form`, inputType: 'doctor', isForm: true,
icon: 'plus-circle-fill',
theme: { bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)', color: '#F97316' } // 橙金
},
],
},
]
},
},
onShow() {
const user = uni.getStorageSync('platform_admin_user') || {}
this.displayName = user.nick_name || '平台管理员'
// 加载仓库数据
platformAdminApi.getWarehouseProductTypeOptions().then(w => {
const options = Array.isArray(w.data) ? w.data : []
if (!options.length) return
this.warehouseMenus = options.map((opt, index) => {
const value = opt.value != null ? opt.value : opt.id
const label = opt.label || opt.name || '仓库'
// 为动态仓库分配不同的主题色
const theme = this.globalThemes[index % this.globalThemes.length]
return {
key: 'warehouse-' + value,
label,
desc: `${label}库存管理`, // 增强描述
icon: 'car-fill', // 仓库类目统一使用 uView 的车/物流图标
theme: theme,
path: `/subPackages/sub_business_shared/warehouse/index?type=${encodeURIComponent(value)}&label=${encodeURIComponent(label)}`,
}
})
}).catch(() => {})
},
methods: {
go(item) {
if (item.isForm && item.inputType) {
navigateToInputForm(item.path, item.inputType)
return
}
uni.navigateTo({ url: item.path })
},
},
}
</script>
<style lang="scss" scoped>
/* 全局容器背景:更干净的高级浅灰底色 */
.workspace-container {
padding-bottom: 80rpx;
background-color: #F4F6F8;
min-height: 100vh;
}
/* ================= 极简顶部英雄区 ================= */
.hero-section {
background: linear-gradient(135deg, #6acdbb 0%, #4db6a8 100%);
/* 调整内边距,移除原先负边距需要的大底部空间,因为这里不需要悬浮核心资产卡片 */
padding: 40rpx 40rpx 60rpx;
border-bottom-left-radius: 48rpx;
border-bottom-right-radius: 48rpx;
box-shadow: 0 4rpx 20rpx rgba(77, 182, 168, 0.2);
}
.brand-info-wrap {
display: flex;
justify-content: space-between;
align-items: center;
}
.brand-text {
flex: 1;
}
.badge {
display: inline-flex;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(8px);
padding: 6rpx 20rpx;
border-radius: 30rpx;
margin-bottom: 20rpx;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.badge-text {
font-size: 22rpx;
color: #fff;
font-weight: 500;
letter-spacing: 2rpx;
}
.store-name {
font-size: 42rpx;
font-weight: 600;
color: #fff;
display: block;
margin-bottom: 6rpx;
}
.store-sub {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
display: block;
}
/* ================= 主体内容区:快捷服务 ================= */
.content-wrapper {
margin-top: 30rpx; /* 轻微向上偏移,与顶部区域产生连接感 */
position: relative;
z-index: 10;
}
.section-panel {
margin: 0 30rpx 30rpx; /* 增加底部间距 */
background: transparent;
}
.panel-header {
margin-bottom: 24rpx;
padding: 0 10rpx;
}
.panel-title {
font-size: 34rpx;
font-weight: 700;
color: #1E293B; /* 深石板灰 */
}
/* 采用与之前页面一致的宫格卡片布局 */
.service-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.service-card {
background: #FFFFFF;
border-radius: 28rpx;
padding: 30rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.02);
transition: all 0.2s ease;
border: 1px solid rgba(0, 0, 0, 0.01);
}
.service-card-hover {
transform: translateY(-2rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.06);
}
.service-icon-box {
width: 76rpx;
height: 76rpx;
border-radius: 22rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
box-shadow: inset 0 2rpx 4rpx rgba(255, 255, 255, 0.5); /* 顶部微高光,增加拟物质感 */
}
.service-info {
display: flex;
flex-direction: column;
}
.service-name {
font-size: 28rpx;
font-weight: 600;
color: #1E293B;
margin-bottom: 6rpx;
}
.service-desc {
font-size: 20rpx;
color: #94A3B8; /* 银灰色 */
}
</style>

View File

@@ -0,0 +1,350 @@
<template>
<u-popup v-model="visible" mode="bottom" border-radius="24" :safe-area-inset-bottom="true">
<view class="sheet">
<view class="sheet-header">
<text class="sheet-title">{{ sheetTitle }}</text>
<text class="sheet-close" @click="close">关闭</text>
</view>
<scroll-view scroll-y class="sheet-scroll">
<view class="field-block">
<text class="field-label">审核结果</text>
<view class="radio-row">
<view
class="radio-item"
:class="{ active: auditStatus === 1 }"
@click="auditStatus = 1"
>审核通过</view>
<view
class="radio-item reject"
:class="{ active: auditStatus === 2 }"
@click="auditStatus = 2"
>审核拒绝</view>
</view>
</view>
<template v-if="inputType === 'store' && auditStatus === 1">
<view class="field-block">
<text class="field-label required">ERP ID</text>
<input v-model="erpId" class="field-input" placeholder="审核通过时必填" />
</view>
<view class="field-block">
<text class="field-label">MES ID</text>
<input v-model="mesId" class="field-input" placeholder="可选,煎药中心编码" />
</view>
<view class="field-block">
<view class="field-label-row">
<text class="field-label">一并审核关联医生可选</text>
<view v-if="pendingDoctorIds.length" class="link-actions">
<text class="link-btn" @click="selectAllPending">全选</text>
<text class="link-btn" @click="clearDoctors">取消全选</text>
</view>
</view>
<view v-if="!allDoctors.length" class="hint">暂无关联医生预填</view>
<view v-else class="doctor-check-list">
<view
v-for="doc in allDoctors"
:key="doc.id"
class="doctor-check-item"
:class="{ disabled: !isDoctorPending(doc) }"
@click="toggleDoctor(doc)"
>
<view class="check-box" :class="{ checked: isDoctorSelected(doc.id), disabled: !isDoctorPending(doc) }">
<text v-if="isDoctorSelected(doc.id)"></text>
</view>
<view class="doctor-check-body">
<text class="doctor-check-name">{{ doc.name || '-' }}</text>
<text class="doctor-check-meta">{{ doctorMetaText(doc) }}</text>
<text class="doctor-check-status">{{ doctorStatusText(doc.status) }}</text>
</view>
</view>
</view>
</view>
</template>
<view class="field-block">
<text class="field-label">审核备注</text>
<textarea
v-model="auditRemark"
class="field-textarea"
:placeholder="auditStatus === 2 ? '请输入拒绝原因(可选)' : '请输入审核备注(可选)'"
maxlength="500"
/>
</view>
</scroll-view>
<view class="sheet-footer">
<button class="footer-btn cancel" @click="close">取消</button>
<button class="footer-btn confirm" :loading="submitting" @click="submit">确认审核</button>
</view>
</view>
</u-popup>
</template>
<script>
import { inputStatusText } from '@/subPackages/sub_salesperson/common/inputDetailFields.js'
function isDoctorPending(doc) {
return Number(doc?.status) === 0
}
export default {
name: 'InputAuditSheet',
props: {
value: { type: Boolean, default: false },
inputType: { type: String, default: 'store' },
recordId: { type: Number, default: 0 },
info: { type: Object, default: () => ({}) },
defaultStatus: { type: Number, default: 1 },
},
data() {
return {
auditStatus: 1,
auditRemark: '',
erpId: '',
mesId: '',
selectedDoctorIds: [],
submitting: false,
}
},
computed: {
visible: {
get() { return this.value },
set(v) { this.$emit('input', v) },
},
sheetTitle() {
return this.inputType === 'doctor' ? '审核医生预填' : '审核录入信息'
},
allDoctors() {
const list = this.info?.doctor_inputs ?? this.info?.doctorInputs ?? []
return Array.isArray(list) ? list : []
},
pendingDoctorIds() {
return this.allDoctors
.filter(isDoctorPending)
.map((d) => Number(d.id))
.filter((id) => id > 0)
},
},
watch: {
value(open) {
if (open) this.resetForm()
},
auditStatus(v) {
if (v !== 1) {
this.selectedDoctorIds = []
} else if (this.pendingDoctorIds.length) {
this.selectedDoctorIds = [...this.pendingDoctorIds]
}
},
},
methods: {
isDoctorPending,
resetForm() {
this.auditStatus = this.defaultStatus === 2 ? 2 : 1
this.auditRemark = ''
this.erpId = this.info?.erp_id != null ? String(this.info.erp_id) : ''
this.mesId = this.info?.mes_id != null ? String(this.info.mes_id) : ''
this.selectedDoctorIds = this.pendingDoctorIds.length ? [...this.pendingDoctorIds] : []
this.submitting = false
},
close() {
this.visible = false
},
selectAllPending() {
this.selectedDoctorIds = [...this.pendingDoctorIds]
},
clearDoctors() {
this.selectedDoctorIds = []
},
isDoctorSelected(id) {
return this.selectedDoctorIds.includes(Number(id))
},
toggleDoctor(doc) {
if (!isDoctorPending(doc)) return
const id = Number(doc.id)
if (!id) return
const idx = this.selectedDoctorIds.indexOf(id)
if (idx >= 0) this.selectedDoctorIds.splice(idx, 1)
else this.selectedDoctorIds.push(id)
},
doctorMetaText(doc) {
const depart = doc.depart?.name || ''
const title = doc.titleInfo?.name || doc.title_info?.name || ''
return [depart, title].filter(Boolean).join(' · ') || '-'
},
doctorStatusText(status) {
return inputStatusText(status)
},
buildPayload() {
const payload = {
id: this.recordId,
status: this.auditStatus,
audit_remark: this.auditRemark.trim(),
}
if (this.inputType === 'store' && this.auditStatus === 1) {
payload.erp_id = this.erpId.trim()
if (this.mesId.trim()) payload.mes_id = this.mesId.trim()
if (this.selectedDoctorIds.length) payload.doctor_input_ids = [...this.selectedDoctorIds]
}
return payload
},
validate() {
if (this.inputType === 'store' && this.auditStatus === 1 && !this.erpId.trim()) {
uni.showToast({ title: '审核通过时请填写 ERP ID', icon: 'none' })
return false
}
return true
},
submit() {
if (!this.validate()) return
this.submitting = true
this.$emit('confirm', this.buildPayload(), () => {
this.submitting = false
})
},
},
}
</script>
<style scoped>
.sheet {
background: #fff;
max-height: 85vh;
display: flex;
flex-direction: column;
}
.sheet-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.sheet-title { font-size: 32rpx; font-weight: 600; color: #1d2129; }
.sheet-close { font-size: 28rpx; color: #6acdbb; }
.sheet-scroll {
max-height: 60vh;
padding: 16rpx 32rpx 24rpx;
box-sizing: border-box;
}
.field-block { margin-bottom: 28rpx; }
.field-label {
display: block;
font-size: 28rpx;
color: #1d2129;
font-weight: 600;
margin-bottom: 16rpx;
}
.field-label.required::after { content: ' *'; color: #f53f3f; }
.field-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.link-actions { display: flex; gap: 16rpx; }
.link-btn { font-size: 24rpx; color: #6acdbb; }
.field-input {
width: 100%;
height: 72rpx;
padding: 0 20rpx;
border: 1rpx solid #e5e6eb;
border-radius: 12rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.field-textarea {
width: 100%;
min-height: 160rpx;
padding: 16rpx 20rpx;
border: 1rpx solid #e5e6eb;
border-radius: 12rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.radio-row { display: flex; gap: 16rpx; }
.radio-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
border: 1rpx solid #e5e6eb;
border-radius: 12rpx;
font-size: 28rpx;
color: #4e5969;
}
.radio-item.active {
border-color: #6acdbb;
color: #6acdbb;
background: #f0faf8;
}
.radio-item.reject.active {
border-color: #f53f3f;
color: #f53f3f;
background: #fff2f0;
}
.hint { font-size: 26rpx; color: #86909c; }
.doctor-check-list { display: flex; flex-direction: column; gap: 12rpx; }
.doctor-check-item {
display: flex;
align-items: flex-start;
padding: 16rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
.doctor-check-item.disabled { opacity: 0.55; }
.check-box {
width: 36rpx;
height: 36rpx;
border: 2rpx solid #c9cdd4;
border-radius: 8rpx;
margin-right: 16rpx;
margin-top: 6rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #fff;
flex-shrink: 0;
}
.check-box.checked { background: #6acdbb; border-color: #6acdbb; }
.check-box.disabled { background: #f2f3f5; }
.doctor-check-body { flex: 1; min-width: 0; }
.doctor-check-name {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
.doctor-check-meta {
display: block;
font-size: 24rpx;
color: #86909c;
margin-top: 6rpx;
}
.doctor-check-status {
display: block;
font-size: 24rpx;
color: #6acdbb;
margin-top: 4rpx;
}
.sheet-footer {
display: flex;
gap: 16rpx;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
}
.footer-btn {
flex: 1;
font-size: 28rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 12rpx;
}
.footer-btn.cancel {
background: #f2f3f5;
color: #4e5969;
}
.footer-btn.confirm {
background: #6acdbb;
color: #fff;
}
</style>

View File

@@ -0,0 +1,363 @@
<template>
<business-page-layout :title="pageTitle" :show-back="true">
<view v-if="loading" class="loading-wrap">加载中...</view>
<view v-else-if="!info.id" class="loading-wrap">暂无数据</view>
<view v-else class="detail-page">
<scroll-view scroll-y class="detail-scroll">
<view class="detail-wrap">
<view v-for="section in sections" :key="section.title" class="section-block">
<view class="section-title">{{ section.title }}</view>
<template v-for="field in section.fields">
<view
v-if="shouldShowField(field)"
:key="field.key"
class="row"
:class="{ multiline: field.multiline }"
>
<text class="k">{{ field.label }}</text>
<view class="v">
<view v-if="field.type === 'image' && getImages(field).length" class="img-list">
<image
v-for="(url, idx) in getImages(field)"
:key="idx"
:src="url"
class="thumb"
mode="aspectFill"
@click="previewImages(getImages(field), idx)"
/>
</view>
<view v-else-if="field.type === 'images' && getImages(field).length" class="img-list">
<image
v-for="(url, idx) in getImages(field)"
:key="idx"
:src="url"
class="thumb"
mode="aspectFill"
@click="previewImages(getImages(field), idx)"
/>
</view>
<view v-else-if="field.type === 'files' && getFiles(field).length" class="file-list">
<text
v-for="(file, idx) in getFiles(field)"
:key="idx"
class="file-link"
@click="openFile(file.url)"
>{{ file.name }}</text>
</view>
<text v-else class="v-text">{{ fieldValue(field) }}</text>
</view>
</view>
</template>
</view>
<view v-if="showLinkedDoctors" class="section-block linked-doctors-block">
<view class="section-title">关联医生</view>
<view v-if="!linkedDoctors.length" class="linked-empty">暂无关联医生预填</view>
<view
v-for="doc in linkedDoctors"
:key="doc.id"
class="doctor-card"
@click="goDoctorDetail(doc.id)"
>
<image
v-if="doc.avatar"
:src="doc.avatar"
class="doctor-avatar"
mode="aspectFill"
/>
<view v-else class="doctor-avatar doctor-avatar-placeholder">
<text>{{ (doc.name || '?').charAt(0) }}</text>
</view>
<view class="doctor-card-body">
<text class="doctor-name">{{ doc.name || '-' }}</text>
<text class="doctor-meta">{{ doctorMetaText(doc) }}</text>
<text class="doctor-status">{{ doctorStatusText(doc.status) }}</text>
</view>
<u-icon name="arrow-right" color="#c9cdd4" size="28" />
</view>
</view>
</view>
</scroll-view>
<view v-if="canAudit" class="footer-actions">
<u-button class="action-btn reject" @click="openAudit(2)">审核拒绝</u-button>
<u-button class="action-btn" type="primary" :custom-style="primaryStyle" @click="openAudit(1)">审核通过</u-button>
</view>
</view>
<input-audit-sheet
v-model="showAuditSheet"
:input-type="inputType"
:record-id="recordId"
:info="info"
:default-status="auditDefaultStatus"
@confirm="onAuditConfirm"
/>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_platform_admin/components/business-page-layout.vue'
import InputAuditSheet from '@/subPackages/sub_platform_admin/input/InputAuditSheet.vue'
import businessMixin from '@/subPackages/sub_platform_admin/common/businessMixin.js'
import {
getStoreDetailSections,
getDoctorDetailSections,
formatDetailFieldValue,
getDetailImageUrls,
getDetailFileLinks,
getLinkedDoctorInputs,
inputStatusText,
} from '@/subPackages/sub_salesperson/common/inputDetailFields.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout, InputAuditSheet },
props: {
recordId: { type: Number, default: 0 },
inputType: { type: String, default: 'store' },
doctorDetailPath: {
type: String,
default: '/subPackages/sub_platform_admin/doctor-input/audit/detail',
},
},
data() {
return {
info: {},
loading: false,
showAuditSheet: false,
auditDefaultStatus: 1,
primaryStyle: {
backgroundColor: '#6acdbb',
color: '#ffffff',
borderColor: '#6acdbb',
},
}
},
computed: {
pageTitle() {
if (this.inputType === 'doctor') return '医生预填详情'
return '门店录入详情'
},
sections() {
return this.inputType === 'doctor' ? getDoctorDetailSections() : getStoreDetailSections()
},
canAudit() {
if (Number(this.info.status) !== 0) return false
if (this.inputType === 'doctor') return !!this.bizCap.canDoctorInputAudit
return !!this.bizCap.canStoreInputAudit
},
showLinkedDoctors() {
return this.inputType === 'store' && !!this.info.id
},
linkedDoctors() {
return getLinkedDoctorInputs(this.info)
},
},
watch: {
recordId: {
immediate: true,
handler(id) {
if (Number(id) > 0) this.load()
else {
this.info = {}
this.loading = false
}
},
},
},
methods: {
detailApi() {
return this.inputType === 'doctor'
? this.bizApi.getDoctorInputDetail(this.recordId)
: this.bizApi.getStoreInputDetail(this.recordId)
},
auditApi(data) {
return this.inputType === 'doctor'
? this.bizApi.auditDoctorInput(data)
: this.bizApi.auditStoreInput(data)
},
load() {
if (!this.recordId) return
this.loading = true
this.info = {}
this.detailApi()
.then((w) => {
if (w.ok) this.info = w.data || {}
else uni.showToast({ title: (w.res && (w.res.message || w.res.msg)) || '加载失败', icon: 'none' })
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
.finally(() => {
this.loading = false
})
},
shouldShowField(field) {
if (field.show && !field.show(this.info)) return false
if (field.type === 'image' || field.type === 'images') {
return getDetailImageUrls(field, this.info).length > 0
}
if (field.type === 'files') {
return getDetailFileLinks(field, this.info).length > 0
}
const val = formatDetailFieldValue(field, this.info)
return val != null && val !== ''
},
fieldValue(field) {
return formatDetailFieldValue(field, this.info) ?? '-'
},
getImages(field) {
return getDetailImageUrls(field, this.info)
},
getFiles(field) {
return getDetailFileLinks(field, this.info)
},
previewImages(urls, index) {
uni.previewImage({ urls, current: urls[index] })
},
openFile(url) {
if (!url) return
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' }),
})
},
goDoctorDetail(doctorId) {
if (!doctorId || !this.doctorDetailPath) return
uni.navigateTo({ url: `${this.doctorDetailPath}?id=${doctorId}` })
},
doctorMetaText(doc) {
const depart = doc.depart?.name || ''
const title = doc.titleInfo?.name || doc.title_info?.name || ''
return [depart, title].filter(Boolean).join(' · ') || '-'
},
doctorStatusText(status) {
return inputStatusText(status)
},
openAudit(status) {
this.auditDefaultStatus = status
this.showAuditSheet = true
},
onAuditConfirm(payload, done) {
this.auditApi(payload)
.then((w) => {
if (w.ok) {
const doctorCount = payload.doctor_input_ids?.length || 0
const msg = doctorCount > 0
? `审核成功,已一并通过 ${doctorCount} 位医生预填`
: '操作成功'
uni.showToast({ title: msg })
this.showAuditSheet = false
setTimeout(() => uni.navigateBack(), 500)
} else {
uni.showToast({
title: (w.res && (w.res.message || w.res.msg)) || w.msg || '操作失败',
icon: 'none',
})
}
})
.catch(() => uni.showToast({ title: '操作失败', icon: 'none' }))
.finally(() => {
if (typeof done === 'function') done()
})
},
},
}
</script>
<style scoped>
.loading-wrap { text-align: center; color: #86909c; padding: 120rpx 0; font-size: 28rpx; }
.detail-page {
display: flex;
flex-direction: column;
height: calc(100vh - 200rpx);
min-height: 400rpx;
}
.detail-scroll {
flex: 1;
height: 0;
box-sizing: border-box;
}
.detail-wrap { padding: 24rpx; padding-bottom: 32rpx; }
.section-block {
background: #fff;
border-radius: 16rpx;
margin-bottom: 24rpx;
padding: 0 28rpx 8rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
color: #1d2129;
padding: 28rpx 0 16rpx;
border-bottom: 1rpx solid #f2f3f5;
}
.row {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f3f5;
align-items: flex-start;
}
.row:last-child { border-bottom: none; }
.row.multiline .v { line-height: 1.6; }
.k { width: 200rpx; color: #86909c; font-size: 28rpx; flex-shrink: 0; }
.v { flex: 1; min-width: 0; }
.v-text { font-size: 28rpx; color: #1d2129; word-break: break-all; }
.img-list { display: flex; flex-wrap: wrap; gap: 16rpx; }
.thumb { width: 160rpx; height: 160rpx; border-radius: 12rpx; }
.file-list { display: flex; flex-direction: column; gap: 12rpx; }
.file-link { font-size: 28rpx; color: #6acdbb; word-break: break-all; }
.footer-actions {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 24rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #f5f7f8;
flex-shrink: 0;
}
.action-btn { flex: 1; min-width: 200rpx; }
.action-btn.reject { color: #f53f3f; border-color: #f53f3f; }
.linked-empty {
padding: 32rpx 0 40rpx;
font-size: 28rpx;
color: #86909c;
text-align: center;
}
.doctor-card {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f3f5;
}
.doctor-card:last-child { border-bottom: none; }
.doctor-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
flex-shrink: 0;
margin-right: 20rpx;
background: #f2f3f5;
}
.doctor-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #86909c;
}
.doctor-card-body { flex: 1; min-width: 0; }
.doctor-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #1d2129;
margin-bottom: 8rpx;
}
.doctor-meta {
display: block;
font-size: 26rpx;
color: #86909c;
margin-bottom: 6rpx;
}
.doctor-status { font-size: 24rpx; color: #6acdbb; }
</style>

View File

@@ -0,0 +1,110 @@
<template>
<business-page-layout :title="title" :show-back="true">
<view class="page-wrap">
<view v-if="!auditMode" class="toolbar">
<button class="btn" @click="goForm()">新建录入</button>
</view>
<view v-for="item in list" :key="item.id" class="card" @click="goDetail(item)">
<view class="row">
<text class="name">{{ item.name }}</text>
<text class="status" :class="'s' + item.status">{{ statusText(item.status) }}</text>
</view>
<view class="sub">{{ item.mobile }} · {{ displayTime(item.created_at) }}</view>
</view>
<view v-if="!list.length" class="empty">暂无记录</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_platform_admin/components/business-page-layout.vue'
import businessMixin from '@/subPackages/sub_platform_admin/common/businessMixin.js'
export default {
mixins: [businessMixin],
components: { BusinessPageLayout },
props: {
auditMode: { type: Boolean, default: false },
inputType: { type: String, default: 'store' },
formPath: { type: String, default: '' },
detailPath: { type: String, required: true },
},
data() { return { list: [], page: 1 } },
computed: {
title() {
if (this.inputType === 'doctor') return this.auditMode ? '医生信息审核' : '医生信息预填'
return this.auditMode ? '诊所信息审核' : '诊所信息录入'
},
},
mounted() {
this.reload()
},
methods: {
listApi(params) {
return this.inputType === 'doctor'
? this.bizApi.getDoctorInputList(params)
: this.bizApi.getStoreInputList(params)
},
parseListItems(data) {
if (Array.isArray(data)) return data
if (data && Array.isArray(data.items)) return data.items
if (data && Array.isArray(data.data)) return data.data
return []
},
reload() { this.page = 1; this.load(1, false) },
load(page, append) {
const params = { page, pageSize: 15 }
if (this.auditMode) params.status = 0
this.listApi(params)
.then(w => {
const items = this.parseListItems(w.data)
if (!items.length && !w.ok) {
uni.showToast({ title: (w.res && (w.res.message || w.res.msg)) || '加载失败', icon: 'none' })
return
}
this.list = append ? this.list.concat(items) : items
this.page = page
})
.catch(() => {
uni.showToast({ title: '加载失败', icon: 'none' })
})
},
statusText(s) {
return { 0: '待审核', 1: '已通过', 2: '已拒绝' }[Number(s)] || '-'
},
displayTime(t) {
if (t == null || t === '') return '-'
if (typeof t === 'string' && (t.includes('-') || t.includes(':'))) {
return t.length >= 10 ? t.slice(0, 10) : t
}
const n = Number(t)
if (!Number.isFinite(n) || n <= 0) return '-'
const ms = n < 1e12 ? n * 1000 : n
const d = new Date(ms)
const pad = (x) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
},
goForm() {
if (this.formPath) uni.navigateTo({ url: this.formPath })
},
goDetail(item) {
uni.navigateTo({ url: `${this.detailPath}?id=${item.id}` })
},
},
}
</script>
<style scoped>
.page-wrap { padding: 24rpx; }
.toolbar { margin-bottom: 24rpx; }
.btn { background: #6acdbb; color: #fff; font-size: 28rpx; }
.card { background: #fff; padding: 28rpx; border-radius: 16rpx; margin-bottom: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: center; }
.name { font-size: 30rpx; font-weight: 600; }
.status { font-size: 24rpx; }
.s0 { color: #FF7D00; }
.s1 { color: #00B42A; }
.s2 { color: #F53F3F; }
.sub { margin-top: 12rpx; color: #86909C; font-size: 24rpx; }
.empty { text-align: center; color: #86909C; padding: 80rpx 0; }
</style>

View File

@@ -0,0 +1,65 @@
<template>
<business-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">{{ roleText }}</text></view>
</view>
</view>
<view class="setting-panel">
<account-switch-entry />
<view class="setting-item action-item" @click="logout">
<text class="danger-text">退出登录</text>
</view>
</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_platform_admin/components/business-page-layout.vue'
import AccountSwitchEntry from '@/components/account-switch/account-switch-entry.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
import { clearLoginStorage } from '@/utils/loginSession.js'
export default {
components: { BusinessPageLayout, AccountSwitchEntry },
provide() { return { businessContext: getPlatformAdminContext() } },
data() { return { displayName: '', roleText: '平台管理员' } },
computed: {
avatarText() { return (this.displayName || '管').slice(0, 1) },
},
onShow() {
const user = uni.getStorageSync('platform_admin_user') || {}
this.displayName = user.nick_name || '管理员'
this.roleText = (user.roles && user.roles.name) || '平台管理员'
},
methods: {
logout() {
uni.showModal({
title: '提示', content: '确定退出登录?',
success: (res) => {
if (!res.confirm) return
clearLoginStorage()
uni.reLaunch({ url: '/pages/login/index' })
},
})
},
},
}
</script>
<style lang="scss" scoped>
.mine-container { min-height: 100vh; background: #F5F7F8; }
.profile-panel { display: flex; align-items: center; background: #fff; padding: 60rpx 40rpx; margin-bottom: 24rpx; }
.avatar-squircle { width: 128rpx; height: 128rpx; border-radius: 40rpx; background: linear-gradient(135deg, #6acdbb, #4db6a8); color: #fff; font-size: 52rpx; display: flex; align-items: center; justify-content: center; }
.user-info { margin-left: 32rpx; }
.user-name { font-size: 40rpx; font-weight: 700; display: block; margin-bottom: 12rpx; }
.role-badge { display: inline-block; background: rgba(106,205,187,.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
.role-text { font-size: 22rpx; color: #4db6a8; }
.setting-panel { background: #fff; padding: 0 40rpx; margin-bottom: 24rpx; }
.setting-item { padding: 36rpx 0; border-bottom: 1rpx solid #F2F3F5; }
.danger-text { color: #F53F3F; font-size: 30rpx; }
</style>

View File

@@ -0,0 +1,17 @@
<template>
<input-detail-page :record-id="recordId" input-type="store" />
</template>
<script>
import InputDetailPage from '@/subPackages/sub_platform_admin/input/InputDetailPage.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
export default {
components: { InputDetailPage },
provide() { return { businessContext: getPlatformAdminContext() } },
data() {
return { recordId: 0 }
},
onLoad(q) {
this.recordId = Number(q.id || 0)
},
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<input-list-page
ref="listPage"
:audit-mode="true"
input-type="store"
form-path=""
detail-path="/subPackages/sub_platform_admin/store-input/audit/detail"
/>
</template>
<script>
import InputListPage from '@/subPackages/sub_platform_admin/input/InputListPage.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
export default {
components: { InputListPage },
provide() { return { businessContext: getPlatformAdminContext() } },
onShow() {
this.$nextTick(() => this.$refs.listPage?.reload?.())
},
onReachBottom() {
const p = this.$refs.listPage
if (p) p.load(p.page + 1, true)
},
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<business-page-layout title="提现审核" :show-back="true">
<view class="page-wrap">
<view v-for="item in list" :key="item.id" class="card">
<view class="row">
<view class="title-wrap">
<text class="type-tag">{{ item.userTypeText }}</text>
<text class="name">{{ item.entityName }}</text>
</view>
<text class="amount">{{ item.amountText }}</text>
</view>
<view class="sub">订单号{{ item.orderNo }}</view>
<view class="sub">申请时间{{ item.applyTime }}</view>
<view class="status-row">
<text class="status" :class="'s' + item.check_status">{{ item.statusText }}</text>
</view>
<view v-if="item.check_status === 1" class="actions">
<button size="mini" class="ok" :disabled="submitting" @click="auditPass(item)">通过</button>
<button size="mini" class="no" :disabled="submitting" @click="auditRefuse(item)">拒绝</button>
</view>
</view>
<view v-if="!list.length" class="empty">暂无待审核记录</view>
</view>
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_platform_admin/components/business-page-layout.vue'
import { getPlatformAdminContext } from '@/subPackages/sub_platform_admin/common/context.js'
import platformAdminApi from '@/api/platformAdmin.js'
import { formatMoney } from '@/subPackages/sub_business_shared/common/format.js'
import { formatDateTime, withdrawalCheckStatusText } from '@/subPackages/sub_business_shared/common/display.js'
export default {
components: { BusinessPageLayout },
provide() { return { businessContext: getPlatformAdminContext() } },
data() {
return { list: [], submitting: false }
},
onShow() { this.load() },
methods: {
mapItem(item) {
const store = item.store || {}
const supplier = item.supplier || {}
return {
...item,
userTypeText: item.user_type_txt || '平台',
entityName: store.name || supplier.name || '萧康云医',
amountText: formatMoney(item.apply_cash),
orderNo: item.order_no || '-',
applyTime: formatDateTime(item.apply_time),
statusText: item.check_status_txt || withdrawalCheckStatusText(item.check_status),
}
},
load() {
platformAdminApi.getWithdrawalAuditList({ page: 1, pageSize: 50, check_status: 1 }).then(w => {
const items = (w.data && w.data.items) || []
this.list = items.map(item => this.mapItem(item))
})
},
showError(w) {
const msg = (w.res && (w.res.message || w.res.msg)) || '操作失败'
uni.showToast({ title: msg, icon: 'none' })
},
submitAudit(item, api, payload) {
if (this.submitting) return
this.submitting = true
api(payload).then(w => {
if (w.ok) {
uni.showToast({ title: '操作成功' })
this.load()
} else {
this.showError(w)
}
}).finally(() => {
this.submitting = false
})
},
auditPass(item) {
uni.showModal({
title: '审核通过',
content: `确认通过 ${item.entityName} 的提现申请 ${item.amountText}`,
success: (res) => {
if (!res.confirm) return
this.submitAudit(item, platformAdminApi.passWithdrawal, {
id: item.id,
reason: '审核通过',
})
},
})
},
auditRefuse(item) {
uni.showModal({
title: '拒绝审核',
content: '请输入拒绝原因',
editable: true,
placeholderText: '拒绝原因',
success: (res) => {
if (!res.confirm) return
const reason = (res.content || '').trim()
if (!reason) {
uni.showToast({ title: '请输入拒绝原因', icon: 'none' })
return
}
this.submitAudit(item, platformAdminApi.refuseWithdrawal, {
id: item.id,
reason,
})
},
})
},
},
}
</script>
<style scoped>
.page-wrap { padding: 24rpx; }
.card { background: #fff; padding: 28rpx; border-radius: 16rpx; margin-bottom: 16rpx; }
.row { display: flex; justify-content: space-between; align-items: flex-start; }
.title-wrap { flex: 1; min-width: 0; }
.type-tag {
display: inline-block;
margin-right: 12rpx;
padding: 4rpx 12rpx;
background: #E8FFEA;
color: #00B42A;
font-size: 22rpx;
border-radius: 6rpx;
}
.name { font-size: 30rpx; font-weight: 600; }
.amount { font-size: 32rpx; font-weight: 600; color: #1D2129; flex-shrink: 0; margin-left: 16rpx; }
.sub { margin-top: 12rpx; color: #86909C; font-size: 24rpx; }
.status-row { margin-top: 12rpx; }
.status { font-size: 24rpx; }
.s1 { color: #FF7D00; }
.s2 { color: #00B42A; }
.s3 { color: #F53F3F; }
.s4 { color: #F53F3F; }
.actions { display: flex; gap: 16rpx; margin-top: 20rpx; }
.ok { background: #6acdbb; color: #fff; }
.no { background: #F53F3F; color: #fff; }
.empty { text-align: center; color: #86909C; padding: 80rpx 0; }
</style>

View File

@@ -0,0 +1,26 @@
import { resolveBusinessContext, pageUrl, businessPageRoot } from './context.js';
export default {
inject: {
businessContext: { default: null },
},
computed: {
bizCtx() {
return resolveBusinessContext(this.businessContext);
},
bizApi() {
return this.bizCtx.api;
},
bizCap() {
return this.bizCtx.capabilities || {};
},
bizRoot() {
return businessPageRoot(this.bizCtx);
},
},
methods: {
bizPage(subPath) {
return pageUrl(this.bizCtx, subPath);
},
},
};

View File

@@ -0,0 +1,50 @@
import { salespersonApi } from '@/api/businessApi.js';
import { getPlatformAdminContext } from '@/subPackages/sub_business_shared/common/context.js';
const BUSINESS_PACKAGE_ROOT = '/subPackages/sub_business_shared';
const SALESPERSON_HOME = '/subPackages/sub_salesperson/home/index';
const SALESPERSON_MY = '/subPackages/sub_salesperson/my/index';
export function getSalespersonContext() {
return {
mode: 'salesperson',
api: salespersonApi,
userStorageKey: 'salesperson_user',
homePath: SALESPERSON_HOME,
myPath: SALESPERSON_MY,
packageRoot: '/subPackages/sub_salesperson',
businessPackageRoot: BUSINESS_PACKAGE_ROOT,
brandTitle: '业务工作台',
capabilities: {
canShip: false,
canRefund: false,
canStoreFilter: true,
canStoreInput: true,
canDoctorInput: true,
},
tabList: [
{ text: '工作台', iconPath: '/static/tabbar/home.png', selectedIconPath: '/static/tabbar/home-active.png', pagePath: SALESPERSON_HOME },
{ text: '我的', iconPath: '/static/tabbar/my.png', selectedIconPath: '/static/tabbar/my-active.png', pagePath: SALESPERSON_MY },
],
};
}
/** 录入列表/详情:超管与业务员共用页面时按 loginMode 选 API */
export function getInputBusinessContext() {
const mode = uni.getStorageSync('loginMode');
if (mode === 'platform_admin') return getPlatformAdminContext();
return getSalespersonContext();
}
export function resolveBusinessContext(injected) {
if (injected && injected.api) return injected;
return getInputBusinessContext();
}
export function businessPageRoot(ctx) {
return ctx.businessPackageRoot || ctx.packageRoot;
}
export function pageUrl(ctx, subPath) {
return `${businessPageRoot(ctx)}/${subPath}`;
}

View File

@@ -0,0 +1,202 @@
export function inputStatusText(status) {
const map = { 0: '待审核', 1: '审核通过', 2: '审核拒绝' }
return map[Number(status)] ?? '-'
}
function statusText(status) {
return inputStatusText(status)
}
function formatTime(ts) {
if (!ts) return '-'
const d = new Date(Number(ts) * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function storeTypeText(type) {
return Number(type) === 0 ? '诊所' : '药店'
}
function clinicTypeText(type) {
if (Number(type) === 1) return '西医诊所'
if (Number(type) === 2) return '中医诊所'
return '未设置'
}
function doctorTypeText(type) {
if (Number(type) === 1) return '中医医生'
if (Number(type) === 2) return '西医医生'
return '-'
}
function signTypeText(signType) {
if (Number(signType) === 1) return '电子签名'
if (Number(signType) === 2) return '手写签名'
return '-'
}
function yesNoText(v) {
return Number(v) === 1 ? '是' : '否'
}
function regionText(info) {
const p = info.province?.name || ''
const c = info.city?.name || ''
return [p, c].filter(Boolean).join(' ') || '-'
}
function storeNamesText(info) {
const stores = info.stores
if (Array.isArray(stores) && stores.length) {
const names = stores.map((s) => s.store?.name || s.name).filter(Boolean)
if (names.length) return names.join('、')
}
if (info.store?.name) return info.store.name
return '-'
}
function promoterText(info) {
const u = info.promoter || info.inputUser
if (!u) return info.code || '-'
return [u.nick_name, u.code].filter(Boolean).join(' · ') || '-'
}
function bankTypeText(type) {
return Number(type) === 1 ? '对公' : '对私'
}
export function getStoreDetailSections() {
return [
{
title: '基础信息',
fields: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: '门店名称' },
{ key: 'type', label: '门店类型', format: (_, info) => storeTypeText(info.type) },
{ key: 'clinic_type', label: '诊所类型', format: (_, info) => clinicTypeText(info.clinic_type) },
{ key: 'contact', label: '联系人' },
{ key: 'mobile', label: '联系电话' },
{ key: 'region', label: '省市区', format: (_, info) => regionText(info) },
{ key: 'position', label: '详细地址' },
{ key: 'code', label: '业务员', format: (_, info) => promoterText(info) },
{ key: 'is_shipping_free', label: '是否包邮', format: (_, info) => yesNoText(info.is_shipping_free) },
{ key: 'subscribe_price_change', label: '订阅价格波动', format: (_, info) => yesNoText(info.subscribe_price_change) },
{
key: 'business_hours',
label: '营业时间',
format: (_, info) => {
const s = info.start_time || ''
const e = info.end_time || ''
return s && e ? `${s} - ${e}` : '-'
},
},
],
},
{
title: '结算信息',
fields: [
{ key: 'bank_type', label: '账户类型', format: (_, info) => bankTypeText(info.bank_type) },
{ key: 'bank_user_name', label: '开户人' },
{ key: 'bank_card', label: '银行卡号' },
{ key: 'bank_name', label: '开户行' },
{ key: 'bank_no', label: '银联行号' },
{ key: 'z_buy_percent', label: '中药采购比例(%)' },
{ key: 'z_sale_percent', label: '中药销售比例(%)' },
],
},
{
title: '审核信息',
fields: [
{ key: 'inputUser', label: '录入人', format: (_, info) => info.inputUser?.nick_name || '-' },
{ key: 'status', label: '审核状态', format: (_, info) => statusText(info.status) },
{ key: 'auditAdmin', label: '审核人', format: (_, info) => info.auditAdmin?.nick_name || '-', show: (info) => !!info.auditAdmin },
{ key: 'audit_time', label: '审核时间', format: (_, info) => formatTime(info.audit_time), show: (info) => !!info.audit_time },
{ key: 'audit_remark', label: '审核备注', show: (info) => !!info.audit_remark },
],
},
{
title: '图片资料',
fields: [
{ key: 'see_rate', label: '公章', type: 'image' },
{ key: 'url', label: '轮播图', type: 'images' },
{ key: 'contract_files', label: '合同文件', type: 'files' },
],
},
]
}
export function getDoctorDetailSections() {
return [
{
title: '基础信息',
fields: [
{ key: 'id', label: 'ID' },
{ key: 'name', label: '医生姓名' },
{ key: 'mobile', label: '手机号' },
{ key: 'idcard', label: '身份证' },
{ key: 'type', label: '身份', format: (_, info) => doctorTypeText(info.type) },
{ key: 'sign_type', label: '签名类型', format: (_, info) => signTypeText(info.sign_type) },
{ key: 'depart', label: '科室', format: (_, info) => info.depart?.name || '-' },
{ key: 'titleInfo', label: '职称', format: (_, info) => info.titleInfo?.name || '-' },
{ key: 'stores', label: '关联门店', format: (_, info) => storeNamesText(info) },
{ key: 'good_at', label: '擅长', multiline: true },
{ key: 'intro', label: '简介', multiline: true },
],
},
{
title: '审核信息',
fields: [
{ key: 'inputUser', label: '录入人', format: (_, info) => info.inputUser?.nick_name || '-' },
{ key: 'status', label: '审核状态', format: (_, info) => statusText(info.status) },
{ key: 'auditAdmin', label: '审核人', format: (_, info) => info.auditAdmin?.nick_name || '-', show: (info) => !!info.auditAdmin },
{ key: 'audit_time', label: '审核时间', format: (_, info) => formatTime(info.audit_time), show: (info) => !!info.audit_time },
{ key: 'audit_remark', label: '审核备注', show: (info) => !!info.audit_remark },
],
},
{
title: '证照资料',
fields: [
{ key: 'avatar', label: '头像', type: 'image' },
{ key: 'qualification', label: '资格证书', type: 'image' },
{ key: 'practicing', label: '执业证书', type: 'image' },
{ key: 'title', label: '职称证书', type: 'image' },
{ key: 'card_up', label: '身份证正面', type: 'image' },
{ key: 'card_down', label: '身份证反面', type: 'image' },
{ key: 'sign_image', label: '签名图片', type: 'image' },
],
},
]
}
export function formatDetailFieldValue(field, info) {
if (field.show && !field.show(info)) return null
if (field.format) return field.format(field.key, info)
const v = info[field.key]
if (v == null || v === '') return '-'
if (Array.isArray(v) && !v.length) return null
return String(v)
}
export function getDetailImageUrls(field, info) {
const v = info[field.key]
if (!v) return []
if (field.type === 'images' && Array.isArray(v)) return v.filter(Boolean)
if (field.type === 'image' && typeof v === 'string') return [v]
return []
}
/** 门店详情关联的医生预填列表 */
export function getLinkedDoctorInputs(info) {
const list = info?.doctor_inputs ?? info?.doctorInputs ?? []
return Array.isArray(list) ? list : []
}
export function getDetailFileLinks(field, info) {
const files = info[field.key]
if (!Array.isArray(files) || !files.length) return []
return files.map((f) => {
if (typeof f === 'string') return { url: f, name: f.split('/').pop() || '合同文件' }
return { url: f.file_url || f.url || '', name: f.file_name || f.name || '合同文件' }
}).filter((f) => f.url)
}

View File

@@ -0,0 +1,56 @@
import {
createDefaultStoreInputForm,
createDefaultDoctorInputForm,
} from '@/subPackages/sub_salesperson/common/inputFormHelpers.js'
export function serializeStoreDraft(vm) {
return {
form: JSON.parse(JSON.stringify(vm.form)),
ui: {
currentStep: vm.currentStep,
clinicTypeIndex: vm.clinicTypeIndex,
bankTypeIndex: vm.bankTypeIndex,
},
}
}
export function applyStoreDraft(vm, draft) {
if (!draft) return
Object.assign(vm.form, createDefaultStoreInputForm(), draft.form || {})
if (!Array.isArray(vm.form.url)) vm.form.url = []
if (!Array.isArray(vm.form.contract_files)) vm.form.contract_files = []
const ui = draft.ui || {}
vm.currentStep = ui.currentStep != null ? ui.currentStep : 0
vm.clinicTypeIndex = ui.clinicTypeIndex != null ? ui.clinicTypeIndex : (Number(vm.form.clinic_type) === 1 ? 0 : 1)
vm.bankTypeIndex = ui.bankTypeIndex != null
? ui.bankTypeIndex
: Math.max(0, vm.bankTypes.findIndex((b) => b.value === Number(vm.form.bank_account_type)))
}
export function applyDoctorDraft(vm, draft) {
if (!draft) return
Object.assign(vm.form, createDefaultDoctorInputForm(), draft.form || {})
const ui = draft.ui || {}
vm.selectedStoreInputIds = Array.isArray(ui.selectedStoreInputIds)
? [...ui.selectedStoreInputIds]
: (Array.isArray(ui.selectedStoreIds) ? [...ui.selectedStoreIds] : [])
vm.departIndex = ui.departIndex != null ? ui.departIndex : Math.max(0, vm.departOptions.findIndex((d) => d.id === vm.form.depart_id))
vm.titleIndex = ui.titleIndex != null ? ui.titleIndex : Math.max(0, vm.titleOptions.findIndex((t) => t.id === vm.form.title_id))
vm.typeIndex = ui.typeIndex != null ? ui.typeIndex : (Number(vm.form.type) === 2 ? 1 : 0)
vm.signTypeIndex = ui.signTypeIndex != null ? ui.signTypeIndex : (Number(vm.form.sign_type) === 2 ? 1 : 0)
vm.currentStep = ui.currentStep != null ? ui.currentStep : 0
}
export function serializeDoctorDraft(vm) {
return {
form: JSON.parse(JSON.stringify(vm.form)),
ui: {
selectedStoreInputIds: [...(vm.selectedStoreInputIds || vm.selectedStoreIds || [])],
departIndex: vm.departIndex,
titleIndex: vm.titleIndex,
typeIndex: vm.typeIndex,
signTypeIndex: vm.signTypeIndex,
currentStep: vm.currentStep,
},
}
}

View File

@@ -0,0 +1,109 @@
/**
* 业务员录入本地草稿uni.setStorageSync
*/
export const MAX_DRAFTS = 20
const STORAGE_VERSION = 'v1'
function readJson(key) {
try {
const raw = uni.getStorageSync(key)
if (!raw) return []
return typeof raw === 'string' ? JSON.parse(raw) : raw
} catch (e) {
return []
}
}
function writeJson(key, data) {
try {
uni.setStorageSync(key, JSON.stringify(data))
return true
} catch (e) {
return false
}
}
function storageUserSuffix() {
const user = uni.getStorageSync('salesperson_user') || {}
if (user.id != null && user.id !== '') return String(user.id)
const token = uni.getStorageSync('salesperson_token') || ''
if (!token) return 'anon'
let hash = 0
for (let i = 0; i < token.length; i += 1) {
hash = (hash * 31 + token.charCodeAt(i)) | 0
}
return 't' + Math.abs(hash)
}
function storageKey(type) {
return `salesperson.inputDraft.${STORAGE_VERSION}.${storageUserSuffix()}.${type}`
}
function newDraftId() {
return `draft_${Date.now()}_${Math.floor(Math.random() * 10000)}`
}
export function listDrafts(type) {
const list = readJson(storageKey(type))
if (!Array.isArray(list)) return []
return list.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
}
export function getDraft(type, draftId) {
if (!draftId) return null
return listDrafts(type).find((d) => d.id === draftId) || null
}
export function removeDraft(type, draftId) {
const key = storageKey(type)
const list = listDrafts(type).filter((d) => d.id !== draftId)
writeJson(key, list)
}
export function saveDraft(type, { name, form, ui, draftId }) {
const trimmed = String(name || '').trim()
if (!trimmed) {
throw new Error('请输入草稿名称')
}
const key = storageKey(type)
let list = listDrafts(type)
const now = Math.floor(Date.now() / 1000)
const payload = {
id: draftId || newDraftId(),
name: trimmed,
updatedAt: now,
form: JSON.parse(JSON.stringify(form || {})),
ui: JSON.parse(JSON.stringify(ui || {})),
}
const idx = list.findIndex((d) => d.id === payload.id)
if (idx >= 0) {
list[idx] = payload
} else {
if (list.length >= MAX_DRAFTS) {
throw new Error(`最多保存 ${MAX_DRAFTS} 个草稿,请先删除旧草稿`)
}
list.push(payload)
}
const ok = writeJson(key, list)
if (!ok) {
throw new Error('保存失败,存储空间可能不足,请删除部分草稿或图片后再试')
}
return payload
}
export function formatDraftTime(ts) {
if (!ts) return ''
const d = new Date(Number(ts) * 1000)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function draftSummary(type, form) {
if (!form) return ''
if (type === 'store') {
return form.name || form.contact || form.mobile || '未填写名称'
}
return form.name || form.mobile || '未填写姓名'
}

View File

@@ -0,0 +1,195 @@
import { prepareImagePath } from '@/common/js/image-compress.js'
import { uploadLegacyAdminImage, uploadBusinessChatFileApi, extractUploadUrl } from '@/api/upload.js'
/**
* 选择并上传图片(压缩 + old-admin-img 接口)
* @param {{ count?: number }} options count>1 时依次上传多张,返回 url 数组
*/
export function pickAndUploadImage(options = {}) {
const count = Math.min(Math.max(options.count || 1, 1), 9)
return new Promise((resolve, reject) => {
uni.chooseImage({
count,
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
uni.showLoading({ title: '上传中...', mask: true })
try {
const files = res.tempFiles || []
const paths = files.length
? files.map((f) => f.path)
: (res.tempFilePaths || [])
const urls = []
for (const path of paths) {
const prepared = await prepareImagePath({
path,
size: files.find((f) => f.path === path)?.size,
})
if (!prepared) continue
const url = await uploadLegacyAdminImage({
filePath: prepared.path,
name: 'file',
})
urls.push(url)
}
uni.hideLoading()
if (!urls.length) {
reject(new Error('上传失败'))
return
}
resolve(count === 1 ? urls[0] : urls)
} catch (e) {
uni.hideLoading()
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
reject(e)
}
},
fail: (err) => {
if (err && err.errMsg && err.errMsg.indexOf('cancel') !== -1) {
reject(new Error('cancel'))
return
}
reject(err)
},
})
})
}
function getUserDataPath() {
if (typeof wx !== 'undefined' && wx.env && wx.env.USER_DATA_PATH) {
return wx.env.USER_DATA_PATH
}
if (uni.env && uni.env.USER_DATA_PATH) {
return uni.env.USER_DATA_PATH
}
return ''
}
async function uploadSignFilePath(filePath) {
let url = null
try {
const res = await uploadBusinessChatFileApi({ filePath, name: 'file' })
url = extractUploadUrl(res)
} catch (e) {
// 回退与证照相同的上传接口
}
if (!url) {
url = await uploadLegacyAdminImage({ filePath, name: 'file' })
}
if (!url) {
throw new Error('上传成功但未返回地址')
}
return url
}
/**
* 手写签名 canvas 临时文件直传 OSS
* @param {string} tempFilePath canvasToTempFilePath 路径
*/
export function uploadSignCanvasTempPath(tempFilePath) {
return new Promise(async (resolve, reject) => {
if (!tempFilePath) {
reject(new Error('签名为空'))
return
}
uni.showLoading({ title: '上传中...', mask: true })
try {
const url = await uploadSignFilePath(tempFilePath)
uni.hideLoading()
resolve(url)
} catch (e) {
uni.hideLoading()
reject(e)
}
})
}
/**
* 手写签名板 base64 写入临时文件后上传 OSS
* @param {string} base64 canvas 导出的 base64可带 data:image 前缀)
*/
export function uploadSignCanvasBase64(base64) {
return new Promise((resolve, reject) => {
if (!base64) {
reject(new Error('签名为空'))
return
}
const root = getUserDataPath()
if (!root) {
reject(new Error('无法获取临时目录'))
return
}
const fs = uni.getFileSystemManager()
const filePath = `${root}/sign_${Date.now()}.png`
const data = String(base64).replace(/^data:image\/\w+;base64,/, '')
fs.writeFile({
filePath,
data,
encoding: 'base64',
success: async () => {
uni.showLoading({ title: '上传中...', mask: true })
try {
const url = await uploadSignFilePath(filePath)
uni.hideLoading()
resolve(url)
} catch (e) {
uni.hideLoading()
reject(e)
}
},
fail: () => reject(new Error('签名临时文件写入失败')),
})
})
}
export function createDefaultStoreInputForm() {
return {
name: '',
contact: '',
mobile: '',
code: '',
position: '',
province_id: null,
city_id: null,
clinic_type: 2,
type: 0,
is_shipping_free: 0,
subscribe_price_change: 0,
start_time: '08:00',
end_time: '20:00',
see_rate: '',
url: [],
contract_files: [],
z_buy_percent: 100,
z_sale_percent: 100,
bank_user_name: '',
bank_card: '',
bank_name: '',
bank_no: '',
bank_account_type: 1,
address: [],
}
}
export function createDefaultDoctorInputForm() {
return {
name: '',
mobile: '',
idcard: '',
avatar: '',
depart_id: null,
title_id: null,
type: 1,
sign_type: 1,
good_at: '',
intro: '',
qualification: '',
practicing: '',
title: '',
card_up: '',
card_down: '',
sign_image: '',
store_id: 0,
store_ids: [],
}
}

View File

@@ -0,0 +1,42 @@
import { listDrafts } from '@/subPackages/sub_salesperson/common/inputDraftStorage.js'
export function resolveDoctorFormPath(bizCtx) {
const root = (bizCtx && bizCtx.packageRoot) || '/subPackages/sub_salesperson'
return `${root}/doctor-input/form`
}
export function goDoctorInputFromStore(storeInputId, bizCtx) {
const id = Number(storeInputId || 0)
if (!id) return
const path = resolveDoctorFormPath(bizCtx)
uni.navigateTo({ url: `${path}?storeInputId=${id}` })
}
export function navigateToInputForm(formPath, inputType, callbacks = {}) {
const drafts = listDrafts(inputType)
if (!drafts.length) {
uni.navigateTo({ url: formPath })
return
}
uni.showActionSheet({
itemList: ['空白新建', '从草稿继续…'],
success: (res) => {
if (res.tapIndex === 0) {
uni.navigateTo({ url: formPath })
} else if (res.tapIndex === 1) {
if (typeof callbacks.onPickDraft === 'function') {
callbacks.onPickDraft()
} else {
const listPath = formPath.replace('/form', '/index')
uni.navigateTo({ url: `${listPath}?openDraft=1` })
}
}
},
})
}
export function openDraftForm(formPath, draftId) {
if (!draftId) return
const sep = formPath.indexOf('?') >= 0 ? '&' : '?'
uni.navigateTo({ url: `${formPath}${sep}draftId=${encodeURIComponent(draftId)}` })
}

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,104 @@
<template>
<view class="picker-wrap">
<view class="depart-picker" @click="onOpen">
<text :class="selected ? 'value-text' : 'placeholder-text'">{{ displayLabel }}</text>
<u-icon name="arrow-right" color="#c0c4cc" size="28"></u-icon>
</view>
<drawer-page-container v-model="showPopup" title="选择科室">
<view class="search-wrap">
<u-search
v-model="keyword"
placeholder="搜索科室名称"
:show-action="false"
shape="round"
bg-color="#f5f5f5"
/>
</view>
<scroll-view scroll-y class="sheet-scroll">
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="!filteredOptions.length" class="empty">{{ keyword ? '无匹配科室' : '暂无科室' }}</view>
<view
v-for="item in filteredOptions"
:key="item.id"
class="option-item"
@click="onPick(item)"
>
<text class="name">{{ item.name }}</text>
<u-icon v-if="Number(value) === Number(item.id)" name="checkmark" color="#6acdbb" size="32"></u-icon>
</view>
</scroll-view>
</drawer-page-container>
</view>
</template>
<script>
import DrawerPageContainer from '@/subPackages/sub_salesperson/components/DrawerPageContainer.vue'
export default {
name: 'DepartPicker',
components: { DrawerPageContainer },
props: {
value: { type: [Number, String], default: null },
options: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
placeholder: { type: String, default: '请选择科室' },
},
data() {
return {
showPopup: false,
keyword: '',
}
},
computed: {
selected() {
return this.options.find((o) => Number(o.id) === Number(this.value)) || null
},
displayLabel() {
return this.selected ? this.selected.name : this.placeholder
},
filteredOptions() {
const kw = (this.keyword || '').trim().toLowerCase()
if (!kw) return this.options
return this.options.filter((o) => String(o.name || '').toLowerCase().includes(kw))
},
},
methods: {
onOpen() {
this.keyword = ''
this.showPopup = true
},
onPick(item) {
this.$emit('input', item.id)
this.$emit('change', item)
this.showPopup = false
},
},
}
</script>
<style scoped>
.picker-wrap { width: 100%; }
.depart-picker {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 88rpx;
padding: 8rpx 0;
box-sizing: border-box;
}
.value-text { font-size: 28rpx; color: #1d2129; flex: 1; }
.placeholder-text { font-size: 28rpx; color: #c0c4cc; flex: 1; }
.search-wrap { padding: 16rpx 24rpx 8rpx; flex-shrink: 0; }
.sheet-scroll { max-height: 60vh; padding-bottom: 24rpx; }
.option-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.name { font-size: 28rpx; color: #1d2129; }
.empty { text-align: center; color: #86909c; padding: 80rpx 0; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,73 @@
<template>
<view>
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="show"
:show="show"
:overlay="false"
@leave="onPageLeave"
/>
<!-- #endif -->
<u-popup
:value="show"
mode="bottom"
border-radius="24"
:safe-area-inset-bottom="true"
:mask-close-able="maskCloseable"
@input="onPopupInput"
@close="close"
>
<view class="drawer-inner">
<view v-if="title" class="drawer-header">
<text class="drawer-title">{{ title }}</text>
<text v-if="showClose" class="drawer-close" @click="close">关闭</text>
</view>
<slot />
</view>
</u-popup>
</view>
</template>
<script>
export default {
name: 'DrawerPageContainer',
props: {
value: { type: Boolean, default: false },
title: { type: String, default: '' },
showClose: { type: Boolean, default: true },
maskCloseable: { type: Boolean, default: true },
},
computed: {
show: {
get() { return this.value },
set(v) { this.$emit('input', v) },
},
},
methods: {
close() {
this.show = false
this.$emit('close')
},
onPopupInput(v) {
this.show = v
},
onPageLeave() {
this.close()
},
},
}
</script>
<style scoped>
.drawer-inner { background: #fff; max-height: 80vh; display: flex; flex-direction: column; }
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.drawer-title { font-size: 32rpx; font-weight: 600; color: #1d2129; }
.drawer-close { font-size: 28rpx; color: #6acdbb; }
</style>

View File

@@ -0,0 +1,104 @@
<template>
<u-popup v-model="visible" mode="bottom" border-radius="24" :safe-area-inset-bottom="true">
<view class="sheet">
<view class="sheet-header">
<text class="sheet-title">选择草稿</text>
<text class="sheet-close" @click="close">关闭</text>
</view>
<scroll-view scroll-y class="sheet-scroll">
<view v-if="!drafts.length" class="empty">暂无本地草稿</view>
<view
v-for="item in drafts"
:key="item.id"
class="draft-item"
@click="onSelect(item)"
>
<view class="draft-main">
<text class="draft-name">{{ item.name }}</text>
<text class="draft-sub">{{ summary(item) }}</text>
<text class="draft-time">{{ formatTime(item.updatedAt) }}</text>
</view>
<text class="draft-del" @click.stop="onDelete(item)">删除</text>
</view>
</scroll-view>
</view>
</u-popup>
</template>
<script>
import {
listDrafts,
removeDraft,
formatDraftTime,
draftSummary,
} from '@/subPackages/sub_salesperson/common/inputDraftStorage.js'
export default {
name: 'InputDraftSheet',
props: {
value: { type: Boolean, default: false },
inputType: { type: String, default: 'store' },
},
data() {
return {
drafts: [],
}
},
computed: {
visible: {
get() { return this.value },
set(v) { this.$emit('input', v) },
},
},
watch: {
value(v) {
if (v) this.refresh()
},
},
methods: {
refresh() {
this.drafts = listDrafts(this.inputType)
},
formatTime(ts) {
return formatDraftTime(ts)
},
summary(item) {
return draftSummary(this.inputType, item.form)
},
close() {
this.visible = false
},
onSelect(item) {
this.$emit('select', item)
this.close()
},
onDelete(item) {
uni.showModal({
title: '删除草稿',
content: `确定删除「${item.name}」?`,
success: (res) => {
if (!res.confirm) return
removeDraft(this.inputType, item.id)
this.refresh()
this.$emit('deleted', item.id)
},
})
},
},
}
</script>
<style scoped>
.sheet { background: #fff; max-height: 70vh; display: flex; flex-direction: column; }
.sheet-header { display: flex; justify-content: space-between; align-items: center; padding: 28rpx 32rpx; border-bottom: 1rpx solid #f0f0f0; }
.sheet-title { font-size: 32rpx; font-weight: 600; color: #1d2129; }
.sheet-close { font-size: 28rpx; color: #6acdbb; }
.sheet-scroll { max-height: 60vh; padding: 16rpx 24rpx 40rpx; box-sizing: border-box; }
.empty { text-align: center; color: #86909c; padding: 80rpx 0; font-size: 28rpx; }
.draft-item { display: flex; align-items: center; padding: 24rpx; margin-bottom: 16rpx; background: #f7f8fa; border-radius: 12rpx; }
.draft-main { flex: 1; min-width: 0; }
.draft-name { display: block; font-size: 30rpx; font-weight: 600; color: #1d2129; }
.draft-sub { display: block; font-size: 26rpx; color: #4e5969; margin-top: 8rpx; }
.draft-time { display: block; font-size: 24rpx; color: #86909c; margin-top: 8rpx; }
.draft-del { font-size: 26rpx; color: #f53f3f; padding: 8rpx 16rpx; flex-shrink: 0; }
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="input-form-layout">
<view class="step-header">
<u-steps :list="stepList" :current="currentStep" active-color="#6acdbb"></u-steps>
</view>
<scroll-view scroll-y class="form-scroll" :style="scrollStyle">
<slot />
</scroll-view>
<view class="footer-actions">
<slot name="footer-extra" />
<u-button v-if="currentStep > 0" class="btn-prev" @click="$emit('prev')">上一步</u-button>
<u-button
v-if="currentStep < maxStep"
class="btn-next"
type="primary"
:custom-style="primaryStyle"
@click="$emit('next')"
>下一步</u-button>
<u-button
v-if="currentStep === maxStep"
class="btn-submit"
type="primary"
:custom-style="primaryStyle"
@click="$emit('submit')"
>{{ submitLabel }}</u-button>
</view>
</view>
</template>
<script>
export default {
name: 'InputFormLayout',
props: {
stepList: { type: Array, required: true },
currentStep: { type: Number, default: 0 },
submitLabel: { type: String, default: '确认保存' },
scrollHeight: { type: String, default: 'calc(100vh - 480rpx)' },
primaryStyle: {
type: Object,
default: () => ({
backgroundColor: '#6acdbb',
color: '#ffffff',
borderColor: '#6acdbb',
}),
},
},
computed: {
maxStep() {
return Math.max(0, (this.stepList || []).length - 1)
},
scrollStyle() {
return { height: this.scrollHeight }
},
},
}
</script>
<style scoped>
.step-header { background-color: #fff; padding: 30rpx 40rpx; margin-bottom: 20rpx; }
.form-scroll { box-sizing: border-box; }
.footer-actions {
display: flex;
flex-wrap: wrap;
padding: 40rpx 24rpx 80rpx;
gap: 24rpx;
background: transparent;
}
.btn-prev { flex: 1; }
.btn-next, .btn-submit { flex: 2; }
</style>

View File

@@ -0,0 +1,24 @@
<template>
<view class="page-sticky-header">
<slot />
</view>
</template>
<script>
import businessMixin from '@/subPackages/sub_salesperson/common/businessMixin.js'
export default {
mixins: [businessMixin],
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,113 @@
<template>
<view class="picker-wrap">
<view class="promoter-picker" @click="onOpen">
<view v-if="selected" class="promoter-selected">
<image v-if="selected.avatar" :src="selected.avatar" class="avatar" mode="aspectFill" />
<view v-else class="avatar placeholder">{{ (selected.nick_name || '?').charAt(0) }}</view>
<view class="meta">
<text class="name">{{ selected.nick_name || '-' }}</text>
<text class="sub">{{ selected.code }} · {{ selected.role_name }}</text>
</view>
</view>
<text v-else class="placeholder-text">{{ placeholder }}</text>
<u-icon v-if="!disabled" name="arrow-right" color="#c0c4cc" size="28"></u-icon>
</view>
<drawer-page-container v-model="showPopup" title="选择业务员">
<scroll-view scroll-y class="sheet-scroll">
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="!options.length" class="empty">暂无可选业务员</view>
<view
v-for="item in options"
:key="item.id"
class="option-item"
@click="onPick(item)"
>
<image v-if="item.avatar" :src="item.avatar" class="avatar" mode="aspectFill" />
<view v-else class="avatar placeholder">{{ (item.nick_name || '?').charAt(0) }}</view>
<view class="meta">
<text class="name">{{ item.nick_name || '-' }}</text>
<text class="sub">{{ item.code }} · {{ item.role_name }}</text>
</view>
<u-icon v-if="value === item.code" name="checkmark" color="#6acdbb" size="32"></u-icon>
</view>
</scroll-view>
</drawer-page-container>
</view>
</template>
<script>
import DrawerPageContainer from '@/subPackages/sub_salesperson/components/DrawerPageContainer.vue'
export default {
name: 'PromoterPicker',
components: { DrawerPageContainer },
props: {
value: { type: String, default: '' },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: '请选择业务员' },
options: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
},
data() {
return { showPopup: false }
},
computed: {
selected() {
return this.options.find((o) => o.code === this.value) || null
},
},
methods: {
onOpen() {
if (this.disabled) return
this.showPopup = true
},
onPick(item) {
this.$emit('input', item.code)
this.$emit('change', item)
this.showPopup = false
},
},
}
</script>
<style scoped>
.picker-wrap {
width: 100%;
position: relative;
z-index: 1;
}
.promoter-picker {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 88rpx;
padding: 8rpx 0;
box-sizing: border-box;
}
.promoter-selected { display: flex; align-items: center; gap: 16rpx; flex: 1; min-width: 0; }
.avatar { width: 72rpx; height: 72rpx; border-radius: 50%; flex-shrink: 0; }
.avatar.placeholder {
background: #e8f7f4;
color: #6acdbb;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
}
.meta { flex: 1; min-width: 0; }
.name { display: block; font-size: 28rpx; color: #1d2129; }
.sub { display: block; font-size: 24rpx; color: #86909c; margin-top: 6rpx; }
.placeholder-text { font-size: 28rpx; color: #c0c4cc; flex: 1; }
.sheet-scroll { max-height: 60vh; padding-bottom: 24rpx; }
.option-item {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.empty { text-align: center; color: #86909c; padding: 80rpx 0; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,196 @@
<template>
<view class="picker-wrap">
<view class="region-trigger" @click="openDrawer">
<text :class="displayText ? 'region-text' : 'region-placeholder'">{{ displayText || placeholder }}</text>
<u-icon name="arrow-right" color="#c0c4cc" size="28"></u-icon>
</view>
<drawer-page-container v-model="show" title="选择省市区">
<view class="drawer-body">
<view class="search-box">
<u-input v-model="keyword" placeholder="搜索省/市/区" clearable />
</view>
<scroll-view scroll-x class="crumb-scroll">
<view class="crumbs">
<text
v-for="(item, idx) in breadcrumbs"
:key="item.rowKey"
class="crumb"
@click="onCrumb(item)"
>{{ item.label }}<text v-if="idx < breadcrumbs.length - 1" class="crumb-sep"> / </text></text>
<text v-if="!breadcrumbs.length" class="crumb muted">请选择</text>
</view>
</scroll-view>
<scroll-view scroll-y class="list-scroll">
<view
v-for="row in listRows"
:key="row.rowKey"
class="list-row"
@click="onPickRow(row)"
>
<text class="row-label">{{ isSearching ? row.label : row.node.label }}</text>
<text v-if="!isSearching && rowHasNextLevel(row, ctx)" class="row-arrow"></text>
</view>
<view v-if="!listRows.length" class="empty">无匹配结果</view>
</scroll-view>
</view>
</drawer-page-container>
</view>
</template>
<script>
import DrawerPageContainer from '@/subPackages/sub_salesperson/components/DrawerPageContainer.vue'
import {
formatAddressDisplay,
resolveAddressValue,
buildAddressOutput,
buildBreadcrumbFromContext,
getInitialPickerLevel,
levelOptionsToRows,
searchAddressHits,
searchHitsToRows,
applySearchHit,
rowHasNextLevel,
truncateContextToBreadcrumb,
getNextLevel,
canCompleteAfterSelect,
} from '@/subPackages/sub_clinic_salesperson/utils/address-index.js'
export default {
name: 'RegionAddressPicker',
components: { DrawerPageContainer },
props: {
value: { type: Array, default: () => [] },
placeholder: { type: String, default: '请选择省市区' },
},
data() {
return {
show: false,
keyword: '',
currentLevel: 'province',
ctx: { provinceId: undefined, cityId: undefined, districtId: undefined },
}
},
computed: {
displayText() {
return formatAddressDisplay(this.value)
},
isSearching() {
return !!(this.keyword || '').trim()
},
breadcrumbs() {
return buildBreadcrumbFromContext(this.ctx).map((item) => ({
...item,
rowKey: item.level + '-' + item.id,
}))
},
listRows() {
const rows = this.isSearching
? searchHitsToRows(searchAddressHits(this.keyword, this.ctx))
: levelOptionsToRows(this.currentLevel, this.ctx)
return rows.map((row) => ({
...row,
rowKey: row.level + '-' + row.node.value,
}))
},
},
methods: {
rowHasNextLevel,
syncFromValue() {
const resolved = resolveAddressValue(this.value)
this.ctx = {
provinceId: resolved.provinceId,
cityId: resolved.cityId,
districtId: resolved.districtId,
}
this.currentLevel = getInitialPickerLevel(resolved)
this.keyword = ''
},
openDrawer() {
this.syncFromValue()
this.show = true
},
emitValue() {
const out = buildAddressOutput(this.ctx.provinceId, this.ctx.cityId, this.ctx.districtId)
if (out) this.$emit('change', out)
},
onCrumb(item) {
this.ctx = truncateContextToBreadcrumb(this.ctx, item)
this.currentLevel = item.level
this.keyword = ''
},
onPickRow(row) {
if (this.isSearching) {
const hit = searchAddressHits(this.keyword, this.ctx).find(
(h) => h.level === row.level && h.node.value === row.node.value
)
if (!hit) return
this.ctx = applySearchHit(hit, this.ctx)
if (hit.complete) {
this.emitValue()
this.show = false
return
}
const next = getNextLevel(hit.level, this.ctx)
this.currentLevel = next || hit.level
this.keyword = ''
return
}
const pickCtx = row.level === 'city'
? { provinceId: this.ctx.provinceId, cityId: row.node.value }
: row.level === 'district'
? { provinceId: this.ctx.provinceId, cityId: this.ctx.cityId, districtId: row.node.value }
: { provinceId: row.node.value, cityId: undefined, districtId: undefined }
if (row.level === 'province') {
this.ctx = { provinceId: row.node.value, cityId: undefined, districtId: undefined }
this.currentLevel = getNextLevel('province', this.ctx) || 'province'
} else if (row.level === 'city') {
this.ctx = { ...this.ctx, cityId: row.node.value, districtId: undefined }
if (canCompleteAfterSelect('city', pickCtx, row.node)) {
this.emitValue()
this.show = false
return
}
this.currentLevel = getNextLevel('city', this.ctx) || 'city'
} else if (row.level === 'district') {
this.ctx = { ...this.ctx, districtId: row.node.value }
this.emitValue()
this.show = false
}
},
},
}
</script>
<style scoped>
.picker-wrap { width: 100%; }
.region-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 72rpx;
}
.region-text { font-size: 28rpx; color: #303133; flex: 1; }
.region-placeholder { font-size: 28rpx; color: #c0c4cc; flex: 1; }
.drawer-body { display: flex; flex-direction: column; max-height: 70vh; }
.search-box { padding: 16rpx 24rpx; flex-shrink: 0; }
.crumb-scroll { white-space: nowrap; padding: 0 24rpx 16rpx; flex-shrink: 0; }
.crumbs { display: inline-flex; flex-wrap: nowrap; }
.crumb { font-size: 26rpx; color: #6acdbb; }
.crumb.muted { color: #86909c; }
.crumb-sep { color: #c0c4cc; }
.list-scroll { flex: 1; max-height: 52vh; }
.list-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.row-label { font-size: 28rpx; color: #1d2129; flex: 1; }
.row-arrow { font-size: 36rpx; color: #c0c4cc; }
.empty { text-align: center; color: #86909c; padding: 80rpx 0; font-size: 28rpx; }
</style>

View File

@@ -0,0 +1,141 @@
<template>
<view class="picker-wrap">
<view class="store-input-picker" @click="openDrawer">
<text :class="summary ? 'summary-text' : 'placeholder-text'">{{ summary || placeholder }}</text>
<u-icon v-if="!disabled" name="arrow-right" color="#c0c4cc" size="28"></u-icon>
</view>
<drawer-page-container v-model="show" title="选择预填门店">
<view class="drawer-body">
<scroll-view scroll-y class="card-scroll">
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="!options.length" class="empty">暂无预填门店</view>
<view
v-for="item in options"
:key="item.id"
class="store-card"
:class="{ active: innerValue.includes(item.id) }"
@click="toggle(item.id)"
>
<view class="card-head">
<text class="card-name">{{ item.name || '-' }}</text>
<text class="card-status" :class="'s' + item.status">{{ item.status_text }}</text>
</view>
<text class="card-mobile">{{ item.mobile || '-' }}</text>
<text v-if="innerValue.includes(item.id)" class="card-check">已选</text>
</view>
</scroll-view>
<view class="drawer-footer">
<u-button type="primary" :custom-style="primaryStyle" @click="confirm">确定{{ innerValue.length }}</u-button>
</view>
</view>
</drawer-page-container>
</view>
</template>
<script>
import DrawerPageContainer from '@/subPackages/sub_salesperson/components/DrawerPageContainer.vue'
export default {
name: 'StoreInputPicker',
components: { DrawerPageContainer },
props: {
value: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
placeholder: { type: String, default: '请选择预填门店' },
defaultStoreInputId: { type: [Number, String], default: 0 },
},
data() {
return {
show: false,
innerValue: [],
primaryStyle: {
backgroundColor: '#6acdbb',
color: '#ffffff',
borderColor: '#6acdbb',
},
}
},
computed: {
summary() {
if (!this.value.length) return ''
const names = this.options
.filter((o) => this.value.includes(o.id))
.map((o) => o.name)
if (names.length) return names.join('、')
return `已选 ${this.value.length} 个门店`
},
},
watch: {
defaultStoreInputId: {
immediate: true,
handler(id) {
const n = Number(id || 0)
if (n > 0 && !this.value.includes(n)) {
this.$emit('input', [n])
}
},
},
},
methods: {
openDrawer() {
if (this.disabled) return
this.innerValue = [...this.value]
const preset = Number(this.defaultStoreInputId || 0)
if (preset > 0 && !this.innerValue.includes(preset)) {
this.innerValue.push(preset)
}
this.show = true
},
toggle(id) {
const idx = this.innerValue.indexOf(id)
if (idx === -1) this.innerValue.push(id)
else this.innerValue.splice(idx, 1)
},
confirm() {
if (!this.innerValue.length) {
uni.showToast({ title: '请选择预填门店', icon: 'none' })
return
}
this.$emit('input', [...this.innerValue])
this.show = false
},
},
}
</script>
<style scoped>
.picker-wrap { width: 100%; }
.store-input-picker {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 72rpx;
}
.summary-text { font-size: 28rpx; color: #303133; flex: 1; }
.placeholder-text { font-size: 28rpx; color: #c0c4cc; flex: 1; }
.drawer-body { display: flex; flex-direction: column; max-height: 70vh; }
.card-scroll { flex: 1; max-height: 58vh; padding: 16rpx 24rpx; box-sizing: border-box; }
.store-card {
background: #f7f8fa;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
border: 2rpx solid transparent;
position: relative;
}
.store-card.active { border-color: #6acdbb; background: rgba(106, 205, 187, 0.08); }
.card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.card-name { font-size: 30rpx; font-weight: 600; color: #1d2129; flex: 1; }
.card-status { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 8rpx; }
.s0 { color: #ff7d00; background: rgba(255, 125, 0, 0.1); }
.s1 { color: #00b42a; background: rgba(0, 180, 42, 0.1); }
.s2 { color: #f53f3f; background: rgba(245, 63, 63, 0.1); }
.card-mobile { font-size: 26rpx; color: #86909c; }
.card-check { position: absolute; right: 24rpx; bottom: 24rpx; font-size: 24rpx; color: #6acdbb; font-weight: 600; }
.empty { text-align: center; color: #86909c; padding: 80rpx 0; font-size: 28rpx; }
.drawer-footer { padding: 16rpx 24rpx 32rpx; flex-shrink: 0; }
</style>

Some files were not shown because too many files have changed in this diff Show More