feat: 语音开方、修复分账问题、识图成方、识图传方、优化订单卡片展示

This commit is contained in:
李琦
2026-09-23 16:22:27 +08:00
parent 87f21de8de
commit bd080674bf
54 changed files with 9664 additions and 529 deletions

View File

@@ -6,7 +6,7 @@ alwaysApply: true
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show);**禁止 v-if 与 :show 同绑**,开/关拆分与 350ms 卸挂载对齐切换账号,详见 `Page-Container-Drawer.mdc`
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
6. 微信小程序 `.vue` 模板(会编译成 WXML)禁止使用 `??`、`?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.`
7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定

View File

@@ -6,7 +6,7 @@ alwaysApply: true
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
3. 有封装好的方法、组件需要复用,不要重复造轮子
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show);**禁止 v-if 与 :show 同绑**,开/关拆分与 350ms 卸挂载对齐切换账号,详见 `Page-Container-Drawer.mdc`
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
6. 微信小程序 `.vue` 模板(会编译成 WXML)禁止使用 `??`、`?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.`
7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定

View File

@@ -0,0 +1,95 @@
---
description: 小程序抽屉 page-container 返回拦截(对齐切换账号 / 工作台切换诊所)
globs: **/*.{vue,js}
alwaysApply: false
---
# 小程序抽屉返回拦截(强制)
标杆实现:
- **切换账号**:`components/account-switch/account-switch-panel.vue`
- **工作台切换诊所**:`pages/workbench/index.vue`(门店选择 `storePageGuard` + `openShow`)
新增/改抽屉、底部弹层、认领码等凡需拦返回的,**必须**按本规则,禁止再 `v-if` 与 `:show` 绑同一个变量。
## 核心原则
1. 微信**同一页只能有一个** `page-container`;多抽屉必须互斥(先卸再挂,或等约 350ms)。
2. **挂载**与**可见**拆开:
- `pageGuard`(或 `storePageGuard`)→ `v-if`(控制是否挂载)
- `visible` / `panelShow` / `openShow` → `:show`(控制是否展示)
3. 必须用 `v-if`,禁止 `v-show` 控制 `page-container`。
4. 关抽屉:先 `:show = false` 播离场,再 **约 350ms**(或 `@afterleave`)后才 `pageGuard = false` 卸节点。
5. 开抽屉:先 `pageGuard = true`,`$nextTick` 后再 `:show = true`。
## 标准写法(对齐切换账号)
```vue
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="pageGuard"
:show="visible"
:overlay="false"
@leave="close"
@afterleave="releasePageGuard"
/>
<!-- #endif -->
```
```js
// 打开
open() {
this.clearReleaseTimer();
this.pageGuard = true;
this.$nextTick(() => {
this.visible = true;
});
},
// 关闭
close() {
this.visible = false;
this.scheduleReleaseGuard(); // 350ms 后再 pageGuard=false
},
releasePageGuard() {
this.clearReleaseTimer();
this.pageGuard = false;
},
scheduleReleaseGuard() {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.pageGuard = false;
this.releaseTimer = null;
}, 350);
},
```
## 禁止
```vue
<!-- ❌ v-if 与 :show 同绑 → 二次侧滑/返回不再拦截 -->
<page-container v-if="show" :show="show" />
<!-- ❌ 关抽屉立刻 v-if=false → 页面滑动锁残留 / 二次打开失败 -->
<page-container v-if="show" :show="true" />
```
## 同页多抽屉互斥(工作台切换诊所已落地)
打开 A 前必须先关掉 B 的 `page-container`:
- 关本层:可先 `show=false` 再 `scheduleReleaseGuard(350)`。
- **立刻要挂另一层**:必须 **同步** `pageGuard = false`(不要等 350ms),或等对方卸完再挂(约 360ms)。
- 参考:`openStoreSelect` / `openApptDrawer` / `onBriefPopupChange`(工作台)。
## 组件内嵌套两层抽屉(如患者详情 + 回访)
同一组件内也只能保留一个 `page-container`:
1. 开内层前:外层 `panelShow=false` 且 **立刻** `pageGuard=false`(勿只靠 afterleave,晚到会冲掉恢复后的 guard)。
2. 关内层后:再 `pageGuard=true` → `$nextTick` → `panelShow=true`。
3. 恢复逻辑加防重入,避免 `update:visible` + `close` 双事件恢复两次。
## 与 Code-Standards 的关系
`Code-Standards.mdc` 第 4 条要求抽屉必须配 `page-container`;**具体拆分与 350ms 卸挂载以本规则 + 切换账号组件为准**。

View File

@@ -73,6 +73,10 @@ export function createBusinessApi(prefix, headerFlag) {
getWithdrawableOrders(params) {
return bizRequest({ url: `${prefix}-withdrawal/withdrawable-orders`, method: 'GET', data: params || {} });
},
/** 提现规则文案 xk-api GET /{prefix}-withdrawal/rules */
getWithdrawalRules() {
return bizRequest({ url: `${prefix}-withdrawal/rules`, method: 'GET', data: {} });
},
passWithdrawal(data) {
return bizRequest({ url: `${prefix}-withdrawal/pass-application`, method: 'POST', data });
},

View File

@@ -183,6 +183,11 @@ export function getWithdrawableOrders(params) {
return clinicRequest({ url: `${PREFIX}-withdrawal/withdrawable-orders`, method: 'GET', data: params || {} });
}
/** 提现规则文案 xk-api GET /clinic-admin-withdrawal/rules */
export function getWithdrawalRules() {
return clinicRequest({ url: `${PREFIX}-withdrawal/rules`, method: 'GET', data: {} });
}
export function getReconciliationList(params) {
return clinicRequest({ url: `${PREFIX}-reconciliation/list`, method: 'GET', data: params });
}

220
api/imageTransferOrder.js Normal file
View File

@@ -0,0 +1,220 @@
/**
* 识图传方订单(xk-api)
* 医生:/image-transfer-order-wx/* 取值 unwrapXkApi
* 诊所管理员:/clinic-admin-image-transfer/* 请求头 _clinicAdmin
*/
import { req } from '@/common/js/index.js';
function clinicReq(options) {
return req.request({
...options,
header: {
...(options.header || {}),
_clinicAdmin: '1',
},
});
}
/** 平台管理员请求:带 _platformAdmin 头走平台传方接口 */
function platformReq(options) {
return req.request({
...options,
header: {
...(options.header || {}),
_platformAdmin: '1',
},
});
}
/** 医生我的传方 xk-api GET /image-transfer-order-wx/list */
export function listMyImageTransferOrders(data) {
return req.request({
url: '/newApi/image-transfer-order-wx/list',
method: 'GET',
data,
});
}
/** 医生传方详情 xk-api GET /image-transfer-order-wx/detail */
export function getMyImageTransferOrder(data) {
return req.request({
url: '/newApi/image-transfer-order-wx/detail',
method: 'GET',
data,
});
}
/** 医生驳回后再交 xk-api POST /image-transfer-order-wx/resubmit */
export function resubmitMyImageTransferOrder(data) {
return req.request({
url: '/newApi/image-transfer-order-wx/resubmit',
method: 'POST',
data,
});
}
/** 诊所识图 OCR xk-api POST /clinic-admin-image-transfer/ocr-prescription-image */
export function clinicOcrPrescriptionImage(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/ocr-prescription-image',
method: 'POST',
data,
});
}
/** 诊所药库搜索 xk-api GET /clinic-admin-image-transfer/product-list */
export function clinicSearchTransferDrugs(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/product-list',
method: 'GET',
data,
});
}
/** 诊所提交识图传方 xk-api POST /clinic-admin-image-transfer/confirm */
export function clinicConfirmImageTransfer(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/confirm',
method: 'POST',
data,
});
}
/** 诊所识图传方匹配就诊人 xk-api POST /clinic-admin-image-transfer/match-transfer-patient */
export function clinicMatchTransferPatient(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/match-transfer-patient',
method: 'POST',
data,
});
}
/** 诊所识图历史列表 xk-api GET /clinic-admin-image-transfer/ai-list-generations */
export function clinicAiListGenerations(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/ai-list-generations',
method: 'GET',
data,
});
}
/** 诊所识图历史详情 xk-api GET /clinic-admin-image-transfer/ai-generation-detail */
export function clinicAiGenerationDetail(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/ai-generation-detail',
method: 'GET',
data,
});
}
/** 诊所传方列表 xk-api GET /clinic-admin-image-transfer/list */
export function listStoreImageTransferOrders(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/list',
method: 'GET',
data,
});
}
/** 诊所传方详情 xk-api GET /clinic-admin-image-transfer/detail */
export function getStoreImageTransferOrder(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/detail',
method: 'GET',
data,
});
}
/** 诊所驳回后再交 xk-api POST /clinic-admin-image-transfer/resubmit */
export function resubmitStoreImageTransferOrder(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/resubmit',
method: 'POST',
data,
});
}
/** 医生传方报价预览 xk-api POST /image-transfer-order-wx/quote */
export function quoteMyImageTransferOrder(data) {
return req.request({
url: '/newApi/image-transfer-order-wx/quote',
method: 'POST',
data,
});
}
/** 诊所传方报价预览 xk-api POST /clinic-admin-image-transfer/quote */
export function quoteStoreImageTransferOrder(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/quote',
method: 'POST',
data,
});
}
/**
* 诊所委托调剂规则级联列表
* xk-api GET /clinic-admin-image-transfer/process-rule-list
* 入参与医生端一致:pid 拉下级,rule_id 拉备注
*/
export function clinicProcessRuleList(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/process-rule-list',
method: 'GET',
data,
});
}
/** 平台传方列表 xk-api GET /platform-admin-image-transfer/list */
export function listPlatformImageTransferOrders(data) {
return platformReq({
url: '/newApi/platform-admin-image-transfer/list',
method: 'GET',
data,
});
}
/** 平台传方详情 xk-api GET /platform-admin-image-transfer/detail */
export function getPlatformImageTransferOrder(data) {
return platformReq({
url: '/newApi/platform-admin-image-transfer/detail',
method: 'GET',
data,
});
}
/** 平台审方通过 xk-api POST /platform-admin-image-transfer/approve */
export function approvePlatformImageTransferOrder(data) {
return platformReq({
url: '/newApi/platform-admin-image-transfer/approve',
method: 'POST',
data,
});
}
/** 平台审方驳回 xk-api POST /platform-admin-image-transfer/reject */
export function rejectPlatformImageTransferOrder(data) {
return platformReq({
url: '/newApi/platform-admin-image-transfer/reject',
method: 'POST',
data,
});
}
/** 平台失败重试推送 xk-api POST /platform-admin-image-transfer/retry */
export function retryPlatformImageTransferOrder(data) {
return platformReq({
url: '/newApi/platform-admin-image-transfer/retry',
method: 'POST',
data,
});
}
/** 诊所本店医生选项 xk-api GET /clinic-admin-image-transfer/doctor-option */
export function clinicTransferDoctorOption(data) {
return clinicReq({
url: '/newApi/clinic-admin-image-transfer/doctor-option',
method: 'GET',
data,
});
}

View File

@@ -0,0 +1,41 @@
/**
* 处方图 OCR / 识图传方(xk-api)
* 取值:unwrapXkApi → code / result / message
*/
import { req } from '@/common/js/index.js';
/** 处方图 OCR xk-api POST /doctor-reception-wx/ocr-prescription-image */
export function ocrPrescriptionImageApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/ocr-prescription-image',
method: 'POST',
data,
});
}
/** 确认识图传方 xk-api POST /doctor-reception-wx/confirm-transfer-image-ocr */
export function confirmTransferImageOcrApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/confirm-transfer-image-ocr',
method: 'POST',
data,
});
}
/** 识图传方匹配就诊人 xk-api POST /doctor-reception-wx/match-transfer-patient */
export function matchTransferPatientApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/match-transfer-patient',
method: 'POST',
data,
});
}
/** 医生导入传方准备 xk-api GET /transfer-prescription/prepare-doctor-import */
export function prepareDoctorImportApi(transferId, registerId) {
return req.request({
url: '/newApi/transfer-prescription/prepare-doctor-import',
method: 'GET',
data: { id: transferId, register_id: registerId },
});
}

View File

@@ -189,6 +189,19 @@ export function getProductListDoctorReception(data) {
})
}
/**
* 复用/常用方填方前对比中药快照价与当前门店价
* 为什么:供货比例变更后关系表已更新,价格不一致时要让医生确认是否按最新价计算
* xk-api POST /doctor-reception-wx/compare-chinese-drug-prices
*/
export function compareChineseDrugPricesApi(data) {
return req.request({
url: '/newApi/doctor-reception-wx/compare-chinese-drug-prices',
method: 'POST',
data
})
}
/** 中药全局快捷克数(与药品无关,每次打开中药抽屉拉取) */
export function getChineseMedicineQuickGramsApi() {
return req.request({
@@ -622,7 +635,7 @@ export function aiGeneratePrescriptionApi(data) {
/**
* 语音转写(multipart:字段名 audio)
* @param {{ filePath: string, duration_ms?: number }} data
* @param {{ filePath: string, duration_ms?: number, audio_format?: string }} data
*/
export function aiVoiceAsrApi(data) {
return req.request({
@@ -632,7 +645,9 @@ export function aiVoiceAsrApi(data) {
filePath: data.filePath,
name: 'audio',
formData: {
duration_ms: String(data.duration_ms || 0)
duration_ms: String(data.duration_ms || 0),
// 显式声明:服务端优先采信,避免 MIME/扩展名误杀
audio_format: String(data.audio_format || 'wav')
}
})
}

View File

@@ -204,6 +204,7 @@ const LEGACY_ADMIN_UPLOAD_URL = 'https://api.xiaokang88.com/open-api/upload/old-
/**
* 录入/管理端旧版图片上传(与 d-upload、select-avatar-image 一致)
* 注意:只认医生态 token;诊所管理员不要走这条
*/
export function uploadLegacyAdminImage(params) {
const { filePath, name = 'file', formData = { store_id: '11001' } } = params || {};
@@ -238,3 +239,45 @@ export function uploadLegacyAdminImage(params) {
});
});
}
/**
* 识图传方选图上传:按 loginMode 选接口
* 诊所管理员必须走 clinic-admin-upload(带 _clinicAdmin + clinic_admin_token),
* 不能用旧 Yii + 医生态 token,否则会一直「上传失败」
* @param {{ filePath: string }} params
* @returns {Promise<string>} 可访问的图片 URL
*/
export async function uploadOcrImageByLoginMode(params) {
const filePath = params && params.filePath;
if (!filePath) {
throw new Error('缺少文件路径');
}
const mode = uni.getStorageSync('loginMode');
if (mode === 'clinic_admin') {
const res = await req.request({
url: `${CLINIC_ADMIN_PREFIX}image`,
type: 'upload',
method: 'POST',
filePath,
name: 'file',
formData: {},
header: { _clinicAdmin: '1' },
});
let body = res;
if (typeof body === 'string') {
try {
body = JSON.parse(body);
} catch (e) {
body = null;
}
}
// xk-api:code=0,url 在 result
if (body && Number(body.code) === 0 && body.result && body.result.url) {
return String(body.result.url);
}
const viaExtract = extractUploadUrl(body);
if (viaExtract) return String(viaExtract);
throw new Error((body && body.message) || '上传失败');
}
return uploadLegacyAdminImage({ filePath });
}

View File

@@ -4,8 +4,8 @@
"^u-(.*)": "uview-ui/components/u-$1/u-$1.vue",
"^d-(.*)": "@/components/d-$1/d-$1.vue",
"^ai-generation-loading$": "@/components/ai-generation-loading/ai-generation-loading.vue",
"^proxy-pay-qrcode-modal$": "@/components/proxy-pay-qrcode-modal/proxy-pay-qrcode-modal.vue",
"^claim-qrcode-modal$": "@/components/claim-qrcode-modal/claim-qrcode-modal.vue"
"^proxy-pay-qrcode-modal$": "@/subPackages/sub_workbench/components/proxy-pay-qrcode-modal/proxy-pay-qrcode-modal.vue",
"^claim-qrcode-modal$": "@/subPackages/sub_workbench/components/claim-qrcode-modal/claim-qrcode-modal.vue"
},
"pages": [{
"path": "pages/login/index",
@@ -69,8 +69,8 @@
"path": "workbench_upInfo/index",
"style": {
"navigationBarTitleText": "上传资料",
"enablePullDownRefresh ": false, //此设置 是否开启当前页面下拉刷新。
"disableScroll": false //此设置 是否允许页面整体上下滚动。
"enablePullDownRefresh": false,
"disableScroll": false
}
},
@@ -141,6 +141,12 @@
}
}
},
{
"path": "image-transfer-prescription/index",
"style": {
"navigationBarTitleText": "识图传方"
}
},
{
"path": "workbench_code",
"style": {
@@ -512,6 +518,40 @@
"enablePullDownRefresh": false
}
}]
}, {
"root": "subPackages/sub_image_transfer",
"pages": [{
"path": "upload",
"style": {
"navigationBarTitleText": "识图传方",
"usingComponents": {
"image-ocr-drawer": "/subPackages/sub_workbench/prescription_v2/components/ImageOcrDrawer"
},
"componentPlaceholder": {
"image-ocr-drawer": "view"
}
}
}, {
"path": "order-list",
"style": {
"navigationBarTitleText": "传方订单",
"backgroundColor": "#E8F6F4",
"backgroundTextStyle": "dark"
}
}, {
"path": "order-detail",
"style": {
"navigationBarTitleText": "传方详情",
"usingComponents": {
"chinese-medicine-config": "/subPackages/sub_workbench/prescription_v2/components/ChineseMedicineConfig",
"chinese-medicine-modal": "/subPackages/sub_workbench/prescription_v2/components/modals/ChineseMedicineModal"
},
"componentPlaceholder": {
"chinese-medicine-config": "view",
"chinese-medicine-modal": "view"
}
}
}]
}
],
"tabBar": {

View File

@@ -8,7 +8,7 @@
</view>
<view class="header-wrap" :style="{ paddingTop: statusBarHeight + navbarHeight + 'px' }">
<view class="box">
<view class="store_" @click="openShow=true">
<view class="store_" @click="openStoreSelect">
{{doctorInfo.store.store.name || '默认门店'}}
<u-icon name="map-fill" color="#fff" size="28" margin-left="12"></u-icon>
</view>
@@ -164,6 +164,28 @@
></u-badge>
<u-icon name="arrow-right" color="#6acdbb" size="28"></u-icon>
</view>
<view
class="offline-entry-row flex-row flex-ali-center m-t-24"
@click="goReceptionEntry(imageTransferEntry.url)"
>
<image class="offline-entry-icon" :src="imageTransferEntry.icon"></image>
<view class="offline-entry-body flex-col flex-1">
<text class="offline-entry-title">{{ imageTransferEntry.name }}</text>
<text class="offline-entry-desc">拍照识方,按配置进接诊或提交传方</text>
</view>
<u-icon name="arrow-right" color="#6acdbb" size="28"></u-icon>
</view>
<view
class="offline-entry-row flex-row flex-ali-center m-t-24"
@click="goReceptionEntry(imageTransferOrderEntry.url)"
>
<image class="offline-entry-icon" :src="imageTransferOrderEntry.icon"></image>
<view class="offline-entry-body flex-col flex-1">
<text class="offline-entry-title">{{ imageTransferOrderEntry.name }}</text>
<text class="offline-entry-desc">查看自己上传的传方订单</text>
</view>
<u-icon name="arrow-right" color="#6acdbb" size="28"></u-icon>
</view>
</view>
<block>
<!-- 正在接诊模块 -->
@@ -392,8 +414,17 @@
</block>
</view>
<!-- 切换门店精简 -->
<u-popup v-model="openShow" mode="bottom" border-radius="24" length="65%" :safe-area-inset-bottom="true">
<!-- 切换门店:page-container 拦截返回;与预约抽屉/简报互斥(同页只能一个 page-container) -->
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="storePageGuard"
:show="openShow"
:overlay="false"
@leave="closeStoreSelect"
@afterleave="releaseStorePageGuard"
/>
<!-- #endif -->
<u-popup v-model="openShow" mode="bottom" border-radius="24" length="65%" :safe-area-inset-bottom="true" @close="closeStoreSelect">
<view class="content_popup">
<view class="top">
<view class="title">选择诊所</view>
@@ -413,7 +444,7 @@
</view>
</scroll-view>
<view class="confrim-btn">
<view class="u-btn qx" @click="openShow=false">取消</view>
<view class="u-btn qx" @click="closeStoreSelect">取消</view>
<view class="u-btn qd" @click="sure">确定</view>
</view>
</view>
@@ -489,7 +520,18 @@ const ONLINE_RECEPTION_ENTRY = {
icon: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-doctor-wx/static/image/xsfz.png',
url: '../../subPackages/sub_online_reception/pages/list',
};
const RECEPTION_GRID_ENTRIES = [OFFLINE_RECEPTION_ENTRY, ONLINE_RECEPTION_ENTRY];
/** 医生识图传方:上传处方图 → OCR → 待导入传方 → 接诊中药回填 */
const IMAGE_TRANSFER_ENTRY = {
name: '识图传方',
icon: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-doctor-wx/static/image/zxzx.png',
url: '../../subPackages/sub_workbench/image-transfer-prescription/index',
};
const IMAGE_TRANSFER_ORDER_ENTRY = {
name: '我的传方',
icon: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-doctor-wx/static/image/zxzx.png',
url: '../../subPackages/sub_image_transfer/order-list?scope=mine',
};
const RECEPTION_GRID_ENTRIES = [OFFLINE_RECEPTION_ENTRY, ONLINE_RECEPTION_ENTRY, IMAGE_TRANSFER_ENTRY, IMAGE_TRANSFER_ORDER_ENTRY];
const EMPTY_WORKBENCH_STATS = {
prescription_count: 0,
wait_accept: 0,
@@ -514,6 +556,8 @@ export default {
},
receptionGridEntries: RECEPTION_GRID_ENTRIES,
offlineReceptionEntry: OFFLINE_RECEPTION_ENTRY,
imageTransferEntry: IMAGE_TRANSFER_ENTRY,
imageTransferOrderEntry: IMAGE_TRANSFER_ORDER_ENTRY,
canUseOnlineConsultation: false,
onlineConsultationConfigReady: false,
states: {
@@ -543,6 +587,9 @@ export default {
page: 1,
totalPage: 1,
openShow: false,
/** 门店选择 page-container 挂载态,与 openShow 拆开 */
storePageGuard: false,
storeReleaseTimer: null,
keyword: "",
store: "",
storeList: [],
@@ -625,6 +672,7 @@ export default {
},
onUnload() {
this.stopRefreshTimer();
this.clearStoreReleaseTimer();
if (this.__doctorImRoomUpdateHandler) {
uni.$off('doctor-im-room-update', this.__doctorImRoomUpdateHandler);
this.__doctorImRoomUpdateHandler = null;
@@ -831,7 +879,7 @@ export default {
this.store_id = uni.getStorageSync('store_id')
}
this.openShow = false
this.closeStoreSelect()
this.onlineConsultationConfigReady = false
this.refreshWorkbenchData()
})
@@ -892,13 +940,76 @@ export default {
if (Number.isNaN(num)) return '0.000'
return num.toFixed(3)
},
/** AI 简报弹层开合:打开时隐藏自定义 tabbar,避免抽屉底部「知道了」被挡住 */
/** AI 简报弹层开合:打开时隐藏自定义 tabbar;与门店/预约抽屉互斥 */
onBriefPopupChange(show) {
this.briefPopupShow = !!show;
if (show) {
// 简报自带 page-container:开之前立刻卸门店拦截,不能等 350ms
this.openShow = false;
this.clearStoreReleaseTimer();
this.storePageGuard = false;
this.apptDrawerShow = false;
}
},
/** 打开未来7天预约明细抽屉;date 为空看全部,点某天卡格预筛选该天 */
/**
* 打开门店选择:先关预约抽屉/简报,再挂 page-container
* 为什么:微信同页只能一个 page-container,且门店弹层原先无返回拦截
*/
openStoreSelect() {
// 预约/简报关后仍留约 350ms pageGuard,需等卸完再挂门店,否则同页双 page-container
const waitMs = (this.apptDrawerShow || this.briefPopupShow) ? 360 : 0;
this.apptDrawerShow = false;
if (this.briefPopupShow && this.$refs.aiBrief && this.$refs.aiBrief.closePopup) {
this.$refs.aiBrief.closePopup();
}
const open = () => {
this.clearStoreReleaseTimer();
this.storePageGuard = true;
this.$nextTick(() => {
this.openShow = true;
});
};
if (waitMs > 0) {
setTimeout(open, waitMs);
} else {
open();
}
},
/** 关闭门店选择:先卸 show,再延迟卸挂载 */
closeStoreSelect() {
this.openShow = false;
this.scheduleStoreReleaseGuard();
},
releaseStorePageGuard() {
this.clearStoreReleaseTimer();
this.storePageGuard = false;
},
scheduleStoreReleaseGuard() {
this.clearStoreReleaseTimer();
this.storeReleaseTimer = setTimeout(() => {
this.storePageGuard = false;
this.storeReleaseTimer = null;
}, 350);
},
clearStoreReleaseTimer() {
if (this.storeReleaseTimer) {
clearTimeout(this.storeReleaseTimer);
this.storeReleaseTimer = null;
}
},
/** 打开预约抽屉:立刻卸门店 pageGuard;若简报开着则等其卸完再开 */
openApptDrawer(date) {
this.openShow = false;
this.clearStoreReleaseTimer();
this.storePageGuard = false;
this.apptDefaultDate = date || '';
if (this.briefPopupShow && this.$refs.aiBrief && this.$refs.aiBrief.closePopup) {
this.$refs.aiBrief.closePopup();
setTimeout(() => {
this.apptDrawerShow = true;
}, 360);
return;
}
this.apptDrawerShow = true;
},
/** 抽屉内改期成功:预约分布/待接诊数已变化,静默刷新看板(不打断用户继续操作抽屉) */

View File

@@ -1,13 +1,14 @@
<template>
<view>
<!-- 回访记录表单抽屉:使用 page-container 防止用户误退出页面;用 v-if 控制挂载/卸载 -->
<!-- 对齐切换账号:pageGuard 与 panelShow 拆开;visible 为父级 v-model -->
<page-container
v-if="visible"
:show="visible"
v-if="pageGuard"
:show="panelShow"
:overlay="true"
position="bottom"
:round="true"
@beforeleave="onClose"
@leave="onClose"
@afterleave="releasePageGuard"
@clickoverlay="onClose"
>
<view class="cl-drawer">
@@ -169,6 +170,9 @@ export default {
},
data() {
return {
pageGuard: false,
panelShow: false,
releaseTimer: null,
submitting: false,
form: {
phone: '',
@@ -255,18 +259,29 @@ export default {
},
watch: {
/**
* v-if 挂载时 visible 已是 true,用 immediate 同步初始化表单 + 加载 dict
* visible 打开:先挂 pageGuard 再 panelShow;关闭:先卸 show 再延迟卸挂载
*/
visible: {
immediate: true,
handler(val) {
if (val) {
this.clearReleaseTimer();
this.pageGuard = true;
this.$nextTick(() => {
this.panelShow = true;
});
this.initForm();
this.loadDict();
} else {
this.panelShow = false;
this.scheduleReleaseGuard();
}
},
},
},
beforeDestroy() {
this.clearReleaseTimer();
},
methods: {
/** 打开抽屉时根据 prefill 初始化表单 */
initForm() {
@@ -301,12 +316,31 @@ export default {
// 静默兜底,使用 FALLBACK 数据保证 UI 可用
}
},
/** 关闭抽屉 */
/** 关闭抽屉:父级靠 update:visible;保留 close 兼容就诊人详情页监听 */
onClose() {
if (this.submitting) return;
this.panelShow = false;
this.scheduleReleaseGuard();
this.$emit('update:visible', false);
this.$emit('close');
},
releasePageGuard() {
this.clearReleaseTimer();
this.pageGuard = false;
},
scheduleReleaseGuard() {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.pageGuard = false;
this.releaseTimer = null;
}, 350);
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
},
/** 判断标签是否已选中 */
isTagChecked(value) {
return this.form.tags.indexOf(value) >= 0;
@@ -425,6 +459,8 @@ export default {
}
uni.showToast({ title: '已记录', icon: 'success' });
this.$emit('success');
this.panelShow = false;
this.scheduleReleaseGuard();
this.$emit('update:visible', false);
this.$emit('close');
} catch (e) {

View File

@@ -1,10 +1,10 @@
<template>
<view>
<!-- 患者视角详情:page-container 抽屉;用 v-if 控制挂载 -->
<!-- 患者视角详情:pageGuard(v-if) / panelShow(:show) 拆开,对齐切换账号,避免二次侧滑失效 -->
<!-- 微信同一页只能有一个 page-container:打开回访表单前必须先卸掉本实例,再挂 CallLogDrawer -->
<page-container
v-if="visible && !patientSheetHiddenForCallLog"
:show="true"
v-if="pageGuard && !patientSheetHiddenForCallLog"
:show="panelShow"
:overlay="true"
position="bottom"
:round="true"
@@ -303,6 +303,11 @@ export default {
* 与 callLogDrawerVisible 配合两阶段互斥,保证页面上最多一个 page-container
*/
patientSheetHiddenForCallLog: false,
/** page-container 挂载态(v-if),与 panelShow 拆开 */
pageGuard: false,
/** page-container 可见态(:show) */
panelShow: false,
releaseTimer: null,
};
},
computed: {
@@ -339,18 +344,25 @@ export default {
},
watch: {
/**
* v-if 挂载时 visible 已是 true,必须 immediate,否则 loadDetail 永不触发
* visible 打开:先挂 pageGuard 再 panelShow;关闭:先卸 show 再延迟卸挂载
* immediate:父级 v-if 挂载时 visible 已是 true,否则 loadDetail 永不触发
*/
visible: {
immediate: true,
handler(val) {
if (val && this.upId) {
this.openAndLoad();
}
// 抽屉挂载时注册"拨号返回"监听;卸载时取消
if (val) {
this.clearReleaseTimer();
this.pageGuard = true;
this.$nextTick(() => {
this.panelShow = true;
});
if (this.upId) {
this.openAndLoad();
}
uni.$on(CALLBACK_RETURNED_EVENT, this.handleCallbackReturned);
} else {
this.panelShow = false;
this.scheduleReleaseGuard();
uni.$off(CALLBACK_RETURNED_EVENT, this.handleCallbackReturned);
}
},
@@ -365,11 +377,32 @@ export default {
/** 兜底:组件销毁前移除事件监听,避免重复触发 */
beforeDestroy() {
uni.$off(CALLBACK_RETURNED_EVENT, this.handleCallbackReturned);
this.clearReleaseTimer();
},
methods: {
/** 关抽屉:先卸 show,再延迟卸挂载,并通知父级 */
close() {
this.panelShow = false;
this.scheduleReleaseGuard();
this.$emit('close');
},
releasePageGuard() {
this.clearReleaseTimer();
this.pageGuard = false;
},
scheduleReleaseGuard() {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.pageGuard = false;
this.releaseTimer = null;
}, 350);
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
},
/**
* 遮罩点击:为打开回访而卸载本抽屉时勿关
*/
@@ -489,26 +522,37 @@ export default {
this.beginOpenCallLogDrawer();
},
/**
* 两阶段打开回访表单:先卸外层 page-container,nextTick 后再挂 CallLogDrawer
* 微信同一页只能存在一个 page-container 实例
* 两阶段打开回访表单:先同步卸外层 page-container,再挂 CallLogDrawer
* 必须立刻 pageGuard=false,否则 afterleave 晚到会把恢复后的抽屉又卸掉
*/
beginOpenCallLogDrawer() {
this.clearReleaseTimer();
this.panelShow = false;
this.pageGuard = false;
this.patientSheetHiddenForCallLog = true;
this.$nextTick(() => {
this.callLogDrawerVisible = true;
});
},
/**
* 关闭回访表单后恢复就诊人抽屉:先卸 CallLogDrawer,nextTick 再挂外层
* 关闭回访表单后恢复就诊人抽屉:先卸 CallLogDrawer,再挂外层并 nextTick 开 show
*/
restorePatientSheetAfterCallLog() {
if (this._restoringPatientSheet) return;
this._restoringPatientSheet = true;
this.callLogDrawerVisible = false;
this.$nextTick(() => {
this.clearReleaseTimer();
this.patientSheetHiddenForCallLog = false;
this.suppressPatientClose = false;
this.pageGuard = true;
this.$nextTick(() => {
this.panelShow = true;
this.suppressPatientClose = false;
this._restoringPatientSheet = false;
});
});
},
/** CallLogDrawer 通过 .sync 更新 visible */
/** CallLogDrawer 通过 .sync 更新 visible;关时与 @close 只走一次恢复 */
onCallLogDrawerVisibleChange(val) {
if (val) {
this.callLogDrawerVisible = true;
@@ -516,7 +560,7 @@ export default {
}
this.restorePatientSheetAfterCallLog();
},
/** 抽屉关闭 */
/** @close 与 update:visible=false 会连发,恢复逻辑已防重入 */
onCallLogDrawerClose() {
this.restorePatientSheetAfterCallLog();
},

View File

@@ -1,12 +1,14 @@
<template>
<!-- 小程序抽屉须用 page-container + v-if,避免 u-popup 在订单详情页底内联 -->
<!-- 对齐切换账号:pageGuard(v-if) 与 visible(:show) 拆开,关抽屉先离场再卸 -->
<view>
<page-container
v-if="show"
:show="show"
v-if="pageGuard"
:show="visible"
:overlay="true"
position="bottom"
:round="true"
@beforeleave="onClose"
@leave="onClose"
@afterleave="releasePageGuard"
@clickoverlay="onClose"
>
<view class="rx-drawer">
@@ -170,11 +172,13 @@
</view>
</view>
</page-container>
</view>
</template>
<script>
/**
* 处方详情抽屉:page-container 底部浮层(管理端看方)
* 返回拦截对齐切换账号:pageGuard / visible 拆分
*/
import { mapPrescriptionDetailView } from '@/subPackages/sub_business_shared/common/display.js'
import businessMixin from '@/subPackages/sub_business_shared/common/businessMixin.js'
@@ -185,11 +189,16 @@ export default {
name: 'PrescriptionDetailPopup',
data() {
return {
show: false,
pageGuard: false,
visible: false,
releaseTimer: null,
loading: false,
view: null,
}
},
beforeDestroy() {
this.clearReleaseTimer()
},
methods: {
/** 解析签名图片,自动兼容 base64 和 http 链接 */
checkImageUrl(url) {
@@ -198,7 +207,11 @@ export default {
},
open(prescriptionId) {
if (!prescriptionId) return
this.show = true
this.clearReleaseTimer()
this.pageGuard = true
this.$nextTick(() => {
this.visible = true
})
this.view = null
this.loading = true
this.bizApi.getPrescriptionDetail(prescriptionId).then(w => {
@@ -236,7 +249,25 @@ export default {
},
onClose() {
this.view = null
this.show = false
this.visible = false
this.scheduleReleaseGuard()
},
releasePageGuard() {
this.clearReleaseTimer()
this.pageGuard = false
},
scheduleReleaseGuard() {
this.clearReleaseTimer()
this.releaseTimer = setTimeout(() => {
this.pageGuard = false
this.releaseTimer = null
}, 350)
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer)
this.releaseTimer = null
}
},
},
}

View File

@@ -1,6 +1,11 @@
<template>
<business-page-layout title="申请提现" :show-back="true">
<view class="tip">可提现余额:{{ balanceText }}</view>
<view class="tip tip-row">
<text class="tip-balance">可提现余额:{{ balanceText }}</text>
<view class="rules-entry" hover-class="rules-entry-hover" @click="openRules">
<text class="rules-entry-txt">提现规则</text>
</view>
</view>
<view v-if="gapText" class="gap">
<text>{{ gapText }}</text>
<view
@@ -58,17 +63,19 @@
<view v-if="orderList.length === 0" class="empty">暂无可提订单</view>
</view>
<button class="submit" @click="submit" :loading="loading">提交申请</button>
<withdrawal-rules-drawer ref="rulesDrawer" :fetch-rules="fetchWithdrawalRules" />
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import WithdrawalRulesDrawer from '@/subPackages/sub_business_shared/withdrawal/components/WithdrawalRulesDrawer.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 },
components: { BusinessPageLayout, WithdrawalRulesDrawer },
data() {
return {
amount: '',
@@ -140,6 +147,17 @@ export default {
this.loadOrders()
},
methods: {
/** 打开提现规则抽屉 */
openRules() {
this.$refs.rulesDrawer && this.$refs.rulesDrawer.open()
},
/** 拉系统配置提现规则;失败由抽屉回落默认文案 */
fetchWithdrawalRules() {
if (!this.bizApi || !this.bizApi.getWithdrawalRules) {
return Promise.resolve({ ok: false, data: null })
}
return this.bizApi.getWithdrawalRules()
},
/**
* 切换提现方式:清已选和旧列表,避免按时间窄集残留到按订单导致漏提
* 按订单立刻重拉全量;按时间等用户点预览;自定义不拉单
@@ -267,6 +285,30 @@ export default {
padding: 24rpx;
border-radius: 16rpx;
}
.tip-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.tip-balance {
flex: 1;
min-width: 0;
}
.rules-entry {
flex-shrink: 0;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(238, 248, 246, 0.95);
border: 1rpx solid rgba(15, 118, 110, 0.22);
box-shadow: 0 0 12rpx rgba(15, 118, 110, 0.1);
}
.rules-entry-hover { opacity: 0.75; }
.rules-entry-txt {
font-size: 24rpx;
color: #0f766e;
font-weight: 500;
}
.gap { color: #B45309; background: #FFF7ED; }
.gap-order { display: block; margin-top: 12rpx; color: #0F766E; }
.label { display: block; font-size: 26rpx; color: #999; margin-bottom: 12rpx; }

View File

@@ -0,0 +1,81 @@
/**
* 提现规则默认文案(接口不存在 / 失败时兜底)
* 与后端 SystemConfigService::defaultWithdrawalRules 对齐
*/
export const DEFAULT_WITHDRAWAL_RULES = {
lead: '提现前请仔细阅读',
foot: '实际到账时间以银行处理进度为准',
flow_steps: [
{ key: 'apply', label: '提交申请' },
{ key: 'audit', label: '平台审核' },
{ key: 'pay', label: '发起打款' },
{ key: 'arrive', label: '到账' },
],
rules: [
{
key: 'amount',
title: '可提现额度',
desc: '以账户「可提现余额」为准,单笔最低 50 元。',
},
{
key: 'time',
title: '提现时间',
desc: '全天可提交申请,不限制时段。',
},
{
key: 'daily',
title: '每日次数',
desc: '不限制每日提现次数。',
},
{
key: 'arrive',
title: '到账时间',
desc: '工作日审核通过后约 10 分钟内到账;节假日顺延至次日到账。',
},
],
}
/** @deprecated 请用 DEFAULT_WITHDRAWAL_RULES */
export const WITHDRAWAL_RULES = DEFAULT_WITHDRAWAL_RULES.rules
/** @deprecated 请用 DEFAULT_WITHDRAWAL_RULES */
export const WITHDRAWAL_FLOW_STEPS = DEFAULT_WITHDRAWAL_RULES.flow_steps
/**
* 规范化接口结果;残缺则补默认
*/
export function normalizeWithdrawalRules(raw) {
const base = DEFAULT_WITHDRAWAL_RULES
if (!raw || typeof raw !== 'object') {
return {
lead: base.lead,
foot: base.foot,
flow_steps: base.flow_steps.slice(),
rules: base.rules.slice(),
}
}
const flow =
Array.isArray(raw.flow_steps) && raw.flow_steps.length > 0
? raw.flow_steps
.map((s, i) => ({
key: String((s && s.key) || `step_${i + 1}`),
label: String((s && s.label) || '').trim(),
}))
.filter((s) => s.label)
: base.flow_steps
const rules =
Array.isArray(raw.rules) && raw.rules.length > 0
? raw.rules
.map((r, i) => ({
key: String((r && r.key) || `rule_${i + 1}`),
title: String((r && r.title) || '').trim(),
desc: String((r && r.desc) || '').trim(),
}))
.filter((r) => r.title || r.desc)
: base.rules
return {
lead: String(raw.lead || '').trim() || base.lead,
foot: String(raw.foot || '').trim() || base.foot,
flow_steps: flow.length ? flow : base.flow_steps.slice(),
rules: rules.length ? rules : base.rules.slice(),
}
}

View File

@@ -0,0 +1,404 @@
<template>
<view>
<!-- pageGuard 与 visible 拆开,对齐 Page-Container-Drawer -->
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="pageGuard"
:show="visible"
:overlay="false"
@leave="close"
@afterleave="releasePageGuard"
/>
<!-- #endif -->
<u-popup
v-model="visible"
mode="bottom"
border-radius="28"
height="78%"
z-index="10080"
:safe-area-inset-bottom="true"
@close="close"
>
<view class="rules-sheet">
<view class="sheet-glow"></view>
<view class="sheet-handle"></view>
<view class="sheet-hd">
<view class="hd-text">
<text class="hd-title">提现规则</text>
<text class="hd-sub">{{ leadText }}</text>
</view>
<view class="hd-close" hover-class="hd-close-hover" @click="close">
<text class="hd-close-x">×</text>
</view>
</view>
<scroll-view scroll-y class="sheet-body">
<view class="glass-card flow-card">
<text class="card-label">审核流程</text>
<view class="flow-row">
<view
v-for="(step, idx) in flowSteps"
:key="step.key"
class="flow-item"
>
<view class="flow-dot">
<text class="flow-num">{{ idx + 1 }}</text>
</view>
<text class="flow-label">{{ step.label }}</text>
<view v-if="idx < flowSteps.length - 1" class="flow-line"></view>
</view>
</view>
</view>
<view
v-for="(rule, idx) in rules"
:key="rule.key"
class="glass-card rule-card"
>
<view class="rule-index">
<text class="rule-index-txt">{{ idx + 1 }}</text>
</view>
<view class="rule-main">
<text class="rule-title">{{ rule.title }}</text>
<text class="rule-desc">{{ rule.desc }}</text>
</view>
</view>
<view class="sheet-foot">
<text class="foot-tip">{{ footText }}</text>
</view>
</scroll-view>
</view>
</u-popup>
</view>
</template>
<script>
/**
* 提现规则抽屉(液态玻璃风格)
* 优先走 fetchRules 拉系统配置;失败/未上线用本地默认文案
*/
import {
normalizeWithdrawalRules,
} from '@/subPackages/sub_business_shared/withdrawal/common/withdrawalRules.js'
export default {
name: 'WithdrawalRulesDrawer',
props: {
/** 拉取规则的函数,返回 Promise<{ok,data}|data>;不传则只用默认文案 */
fetchRules: {
type: Function,
default: null,
},
},
data() {
const fallback = normalizeWithdrawalRules(null)
return {
visible: false,
pageGuard: false,
releaseTimer: null,
leadText: fallback.lead,
footText: fallback.foot,
rules: fallback.rules,
flowSteps: fallback.flow_steps,
}
},
beforeDestroy() {
this.clearReleaseTimer()
},
methods: {
applyPayload(raw) {
const payload = normalizeWithdrawalRules(raw)
this.leadText = payload.lead
this.footText = payload.foot
this.rules = payload.rules
this.flowSteps = payload.flow_steps
},
/** 打开前拉配置;接口失败保持默认,保证审核页有内容 */
loadRules() {
if (typeof this.fetchRules !== 'function') {
this.applyPayload(null)
return Promise.resolve()
}
return Promise.resolve()
.then(() => this.fetchRules())
.then((w) => {
// 兼容 {ok,data} 与直接 result
if (w && w.ok === false) {
this.applyPayload(null)
return
}
const data = w && w.data !== undefined ? w.data : w
this.applyPayload(data)
})
.catch(() => {
this.applyPayload(null)
})
},
/** 打开抽屉:先挂 page-container,再显示弹层 */
open() {
this.clearReleaseTimer()
this.loadRules()
this.pageGuard = true
this.$nextTick(() => {
this.visible = true
})
},
/** 关闭:先播离场,再卸 page-container */
close() {
if (!this.visible && !this.pageGuard) return
this.visible = false
this.scheduleReleaseGuard()
},
releasePageGuard() {
this.clearReleaseTimer()
this.pageGuard = false
},
scheduleReleaseGuard() {
this.clearReleaseTimer()
this.releaseTimer = setTimeout(() => {
this.pageGuard = false
this.releaseTimer = null
}, 350)
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer)
this.releaseTimer = null
}
},
},
}
</script>
<style lang="scss" scoped>
/* 液态玻璃:半透明 + 轻模糊 + 细边,避免紫霓虹 / 粗阴影 */
$glass-bg: rgba(255, 255, 255, 0.72);
$glass-border: rgba(255, 255, 255, 0.55);
$ink: #1d2129;
$muted: #86909c;
$accent: #0f766e;
.rules-sheet {
position: relative;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: linear-gradient(
180deg,
rgba(245, 250, 248, 0.96) 0%,
rgba(236, 244, 242, 0.98) 100%
);
border-radius: 28rpx 28rpx 0 0;
}
.sheet-glow {
position: absolute;
top: -80rpx;
left: 50%;
width: 420rpx;
height: 220rpx;
margin-left: -210rpx;
border-radius: 50%;
background: radial-gradient(
ellipse,
rgba(106, 205, 187, 0.28) 0%,
rgba(106, 205, 187, 0) 70%
);
pointer-events: none;
}
.sheet-handle {
width: 64rpx;
height: 8rpx;
margin: 16rpx auto 0;
border-radius: 8rpx;
background: rgba(29, 33, 41, 0.12);
}
.sheet-hd {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24rpx 36rpx 12rpx;
position: relative;
z-index: 1;
}
.hd-title {
display: block;
font-size: 36rpx;
font-weight: 600;
color: $ink;
letter-spacing: 1rpx;
}
.hd-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $muted;
}
.hd-close {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.55);
border: 1rpx solid rgba(255, 255, 255, 0.7);
/* #ifdef H5 || APP-PLUS */
backdrop-filter: blur(12px);
/* #endif */
}
.hd-close-hover {
opacity: 0.7;
}
.hd-close-x {
font-size: 40rpx;
line-height: 1;
color: $muted;
margin-top: -4rpx;
}
.sheet-body {
flex: 1;
height: 0;
padding: 8rpx 28rpx 40rpx;
box-sizing: border-box;
position: relative;
z-index: 1;
}
.glass-card {
position: relative;
margin-bottom: 20rpx;
padding: 28rpx;
border-radius: 24rpx;
background: $glass-bg;
border: 1rpx solid $glass-border;
box-shadow:
0 8rpx 28rpx rgba(15, 23, 32, 0.06),
inset 0 1rpx 0 rgba(255, 255, 255, 0.85);
/* #ifdef H5 || APP-PLUS */
backdrop-filter: blur(18px);
/* #endif */
}
.card-label {
display: block;
font-size: 24rpx;
color: $muted;
margin-bottom: 24rpx;
letter-spacing: 2rpx;
}
.flow-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.flow-item {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.flow-dot {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 118, 110, 0.1);
border: 1rpx solid rgba(15, 118, 110, 0.22);
box-shadow: 0 0 12rpx rgba(15, 118, 110, 0.12);
margin-bottom: 12rpx;
}
.flow-num {
font-size: 24rpx;
font-weight: 600;
color: $accent;
}
.flow-label {
font-size: 22rpx;
color: $ink;
text-align: center;
line-height: 1.35;
}
.flow-line {
position: absolute;
top: 24rpx;
left: calc(50% + 28rpx);
right: calc(-50% + 28rpx);
height: 2rpx;
background: linear-gradient(
90deg,
rgba(15, 118, 110, 0.35),
rgba(15, 118, 110, 0.08)
);
}
.rule-card {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.rule-index {
width: 44rpx;
height: 44rpx;
border-radius: 14rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 118, 110, 0.08);
border: 1rpx solid rgba(15, 118, 110, 0.16);
}
.rule-index-txt {
font-size: 24rpx;
font-weight: 600;
color: $accent;
}
.rule-main {
flex: 1;
min-width: 0;
}
.rule-title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: $ink;
margin-bottom: 8rpx;
}
.rule-desc {
display: block;
font-size: 26rpx;
color: #4e5969;
line-height: 1.55;
}
.sheet-foot {
padding: 12rpx 8rpx 24rpx;
text-align: center;
}
.foot-tip {
font-size: 22rpx;
color: $muted;
}
</style>

View File

@@ -59,6 +59,9 @@
<view class="list-section">
<view class="section-hd">
<text class="section-title">提现记录</text>
<view class="rules-entry" hover-class="rules-entry-hover" @click="openRules">
<text class="rules-entry-txt">提现规则</text>
</view>
</view>
<view class="record-list">
@@ -91,11 +94,13 @@
</view>
</view>
<withdrawal-rules-drawer ref="rulesDrawer" :fetch-rules="fetchWithdrawalRules" />
</business-page-layout>
</template>
<script>
import BusinessPageLayout from '@/subPackages/sub_business_shared/components/business-page-layout.vue'
import WithdrawalRulesDrawer from '@/subPackages/sub_business_shared/withdrawal/components/WithdrawalRulesDrawer.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'
@@ -105,7 +110,7 @@ import pullRefreshMixin from '@/subPackages/sub_business_shared/common/pullRefre
export default {
mixins: [businessMixin, pullRefreshMixin],
components: { BusinessPageLayout },
components: { BusinessPageLayout, WithdrawalRulesDrawer },
data() {
return {
cardTitle: '我的账户',
@@ -171,6 +176,17 @@ export default {
uni.navigateTo({ url: `${this.bizRoot}/withdrawal/account-change/index` });
}
},
/** 打开提现规则抽屉(微信审核要求页面内清晰展示) */
openRules() {
this.$refs.rulesDrawer && this.$refs.rulesDrawer.open()
},
/** 拉系统配置提现规则;抽屉内部失败会回落默认文案 */
fetchWithdrawalRules() {
if (!this.bizApi || !this.bizApi.getWithdrawalRules) {
return Promise.resolve({ ok: false, data: null })
}
return this.bizApi.getWithdrawalRules()
},
// 提现详情跳转
goWithdrawalDetail(item) {
@@ -436,6 +452,10 @@ $color-text-muted: #86909C;
.section-hd {
margin-bottom: 24rpx;
padding-left: 8rpx;
padding-right: 8rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
@@ -444,6 +464,24 @@ $color-text-muted: #86909C;
color: $color-text-main;
}
.rules-entry {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(15, 118, 110, 0.22);
box-shadow: 0 0 12rpx rgba(15, 118, 110, 0.1);
}
.rules-entry-hover {
opacity: 0.75;
}
.rules-entry-txt {
font-size: 24rpx;
color: #0f766e;
font-weight: 500;
}
.record-item {
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #F2F3F5;

View File

@@ -1,12 +1,14 @@
<template>
<!-- 小程序抽屉须用 page-container + v-if,避免 u-popup 在订单详情页底内联 -->
<!-- 对齐切换账号:pageGuard(v-if) 与 visible(:show) 拆开,关抽屉先离场再卸 -->
<view>
<page-container
v-if="show"
:show="show"
v-if="pageGuard"
:show="visible"
:overlay="true"
position="bottom"
:round="true"
@beforeleave="onClose"
@leave="onClose"
@afterleave="releasePageGuard"
@clickoverlay="onClose"
>
<view class="rx-drawer">
@@ -160,11 +162,13 @@
</view>
</view>
</page-container>
</view>
</template>
<script>
/**
* 处方详情抽屉:page-container 底部浮层(诊所管理端)
* 返回拦截对齐切换账号:pageGuard / visible 拆分
*/
import { getPrescriptionDetail } from '@/api/clinicAdmin.js'
import { mapPrescriptionDetailView } from '@/subPackages/sub_clinic_admin/common/display.js'
@@ -174,11 +178,16 @@ export default {
name: 'PrescriptionDetailPopup',
data() {
return {
show: false,
pageGuard: false,
visible: false,
releaseTimer: null,
loading: false,
view: null,
}
},
beforeDestroy() {
this.clearReleaseTimer()
},
methods: {
/** 解析签名图片,自动兼容 base64 和 http 链接 */
checkImageUrl(url) {
@@ -187,7 +196,11 @@ export default {
},
open(prescriptionId) {
if (!prescriptionId) return
this.show = true
this.clearReleaseTimer()
this.pageGuard = true
this.$nextTick(() => {
this.visible = true
})
this.view = null
this.loading = true
getPrescriptionDetail(prescriptionId).then(w => {
@@ -225,7 +238,25 @@ export default {
},
onClose() {
this.view = null
this.show = false
this.visible = false
this.scheduleReleaseGuard()
},
releasePageGuard() {
this.clearReleaseTimer()
this.pageGuard = false
},
scheduleReleaseGuard() {
this.clearReleaseTimer()
this.releaseTimer = setTimeout(() => {
this.pageGuard = false
this.releaseTimer = null
}, 350)
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer)
this.releaseTimer = null
}
},
},
}

View File

@@ -1,6 +1,11 @@
<template>
<clinic-page-layout title="申请提现" :show-back="true">
<view class="tip">可提现余额:{{ balanceText }}</view>
<view class="tip tip-row">
<text class="tip-balance">可提现余额:{{ balanceText }}</text>
<view class="rules-entry" hover-class="rules-entry-hover" @click="openRules">
<text class="rules-entry-txt">提现规则</text>
</view>
</view>
<view v-if="gapText" class="gap">
<text>{{ gapText }}</text>
<view
@@ -58,16 +63,18 @@
<view v-if="orderList.length === 0" class="empty">暂无可提订单</view>
</view>
<button class="submit" @click="submit" :loading="loading">提交申请</button>
<withdrawal-rules-drawer ref="rulesDrawer" :fetch-rules="fetchWithdrawalRules" />
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import WithdrawalRulesDrawer from '@/subPackages/sub_business_shared/withdrawal/components/WithdrawalRulesDrawer.vue'
import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { getClinicAdminBalance, getWithdrawableOrders, submitWithdrawal } from '@/api/clinicAdmin.js'
import { getClinicAdminBalance, getWithdrawableOrders, submitWithdrawal, getWithdrawalRules } from '@/api/clinicAdmin.js'
export default {
components: { ClinicPageLayout },
components: { ClinicPageLayout, WithdrawalRulesDrawer },
data() {
return {
amount: '',
@@ -138,6 +145,14 @@
this.loadOrders()
},
methods: {
/** 打开提现规则抽屉 */
openRules() {
this.$refs.rulesDrawer && this.$refs.rulesDrawer.open()
},
/** 拉系统配置提现规则;失败由抽屉回落默认文案 */
fetchWithdrawalRules() {
return getWithdrawalRules()
},
onStartDate(e) { this.startDate = e.detail.value },
onEndDate(e) { this.endDate = e.detail.value },
onCategory(e) {
@@ -251,6 +266,30 @@
padding: 24rpx;
border-radius: 16rpx;
}
.tip-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.tip-balance {
flex: 1;
min-width: 0;
}
.rules-entry {
flex-shrink: 0;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(238, 248, 246, 0.95);
border: 1rpx solid rgba(15, 118, 110, 0.22);
box-shadow: 0 0 12rpx rgba(15, 118, 110, 0.1);
}
.rules-entry-hover { opacity: 0.75; }
.rules-entry-txt {
font-size: 24rpx;
color: #0f766e;
font-weight: 500;
}
.gap { color: #B45309; background: #FFF7ED; }
.gap-order { display: block; margin-top: 12rpx; color: #0F766E; }
.label { display: block; font-size: 26rpx; color: #999; margin-bottom: 12rpx; }

View File

@@ -44,6 +44,9 @@
<view class="list-section">
<view class="section-hd">
<text class="section-title">提现记录</text>
<view class="rules-entry" hover-class="rules-entry-hover" @click="openRules">
<text class="rules-entry-txt">提现规则</text>
</view>
</view>
<view class="record-list">
@@ -76,12 +79,14 @@
</view>
</view>
<withdrawal-rules-drawer ref="rulesDrawer" :fetch-rules="fetchWithdrawalRules" />
</clinic-page-layout>
</template>
<script>
import ClinicPageLayout from '@/subPackages/sub_clinic_admin/components/clinic-page-layout.vue'
import { getClinicAdminCard, getClinicAdminBalance, getWithdrawalList } from '@/api/clinicAdmin.js'
import WithdrawalRulesDrawer from '@/subPackages/sub_business_shared/withdrawal/components/WithdrawalRulesDrawer.vue'
import { getClinicAdminCard, getClinicAdminBalance, getWithdrawalList, getWithdrawalRules } from '@/api/clinicAdmin.js'
import { withWxKey } from '@/utils/wxListKey.js'
import { formatMoney } from '@/subPackages/sub_clinic_admin/common/format.js'
import { withdrawalCheckStatusText, formatDateTime } from '@/subPackages/sub_clinic_admin/common/display.js'
@@ -89,7 +94,7 @@ import pullRefreshMixin from '@/subPackages/sub_business_shared/common/pullRefre
export default {
mixins: [pullRefreshMixin],
components: { ClinicPageLayout },
components: { ClinicPageLayout, WithdrawalRulesDrawer },
data() {
return {
cardTitle: '我的账户',
@@ -155,6 +160,14 @@ export default {
uni.navigateTo({ url: '/subPackages/sub_clinic_admin/withdrawal/account-change/index' });
}
},
/** 打开提现规则抽屉(微信审核要求页面内清晰展示) */
openRules() {
this.$refs.rulesDrawer && this.$refs.rulesDrawer.open()
},
/** 拉系统配置提现规则;失败由抽屉回落默认文案 */
fetchWithdrawalRules() {
return getWithdrawalRules()
},
// 提现详情跳转
goWithdrawalDetail(item) {
@@ -384,6 +397,10 @@ $color-text-muted: #86909C;
.section-hd {
margin-bottom: 24rpx;
padding-left: 8rpx;
padding-right: 8rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
@@ -392,6 +409,24 @@ $color-text-muted: #86909C;
color: $color-text-main;
}
.rules-entry {
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.72);
border: 1rpx solid rgba(15, 118, 110, 0.22);
box-shadow: 0 0 12rpx rgba(15, 118, 110, 0.1);
}
.rules-entry-hover {
opacity: 0.75;
}
.rules-entry-txt {
font-size: 24rpx;
color: #0f766e;
font-weight: 500;
}
.record-item {
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #F2F3F5;

View File

@@ -1,11 +1,13 @@
<template>
<view>
<!-- pageGuard / show 拆开,对齐 DrawerPageContainer -->
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="visible"
v-if="pageGuard"
:show="visible"
:overlay="false"
@leave="close"
@afterleave="releasePageGuard"
/>
<!-- #endif -->
<u-popup
@@ -65,13 +67,16 @@
import { getClinicSalespersonTransferPrescriptionDetail } from '@/api/clinicSalesperson.js'
/**
* 传方详情抽屉
* 传方详情抽屉(诊所业务员)
* 返回拦截:pageGuard 与 visible 拆开,leave 关抽屉,afterleave 再卸 page-container
*/
export default {
name: 'TransferDetailDrawer',
data() {
return {
visible: false,
pageGuard: false,
releaseTimer: null,
loading: false,
detail: null,
}
@@ -79,12 +84,30 @@ export default {
computed: {
statusType() {
const map = { 0: 'warning', 1: 'primary', 2: 'success', 3: 'error' }
return map[Number(this.detail?.status)] || 'info'
const st = this.detail ? Number(this.detail.status) : -1
return map[st] || 'info'
},
},
watch: {
visible(v) {
if (v) {
this.clearReleaseTimer()
this.pageGuard = true
} else {
this.scheduleReleaseGuard()
}
},
},
beforeDestroy() {
this.clearReleaseTimer()
},
methods: {
async open(id) {
this.visible = true
this.clearReleaseTimer()
this.pageGuard = true
this.$nextTick(() => {
this.visible = true
})
this.loading = true
this.detail = null
try {
@@ -99,8 +122,25 @@ export default {
close() {
this.visible = false
},
releasePageGuard() {
this.clearReleaseTimer()
this.pageGuard = false
},
scheduleReleaseGuard() {
this.clearReleaseTimer()
this.releaseTimer = setTimeout(() => {
this.pageGuard = false
this.releaseTimer = null
}, 350)
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer)
this.releaseTimer = null
}
},
preview(url) {
const urls = this.detail?.prescription_images || []
const urls = (this.detail && this.detail.prescription_images) || []
uni.previewImage({ urls, current: url })
},
},

View File

@@ -1,5 +1,6 @@
<template>
<clinic-salesperson-page-layout title="我的" :show-tabbar="true" :tab-index="1">
<!-- tab 顺序是工作台/功能/我的,我的必须是 2;写成 1 时点「功能」会被当成当前页直接 return -->
<clinic-salesperson-page-layout title="我的" :show-tabbar="true" :tab-index="2">
<!-- 页面主体统一收进 admin-my-panel(资料区 + 个人中心入口 + 菜单卡 + 退出) -->
<admin-my-panel
ref="myPanel"

View File

@@ -0,0 +1,50 @@
/**
* 传方订单状态展示口径(列表 / 详情共用)
*
* 状态枚举来自后端 app/Enum/imageTransfer/ImageTransferOrderStatusEnum.php:
* 1 待审 / 2 驳回 / 3 已传 / 4 推送失败
* 小程序端可编辑:status∈{1,2}(审核通过前可改后再交)
*
* 为什么单独抽一份:列表的状态药丸和详情的状态标签必须同色同文案,
* 两页各写一套迟早会漂移;后台 xk-admin 传方订单卡片也用同一套语义。
*/
/** 状态 → 展示元数据:color 用于文字与圆点,bg 用于药丸底色 */
const STATUS_META = {
1: { text: '待审核', color: '#FF9900', bg: '#FFF7E6' },
2: { text: '已驳回', color: '#FA3534', bg: '#FEF0F0' },
3: { text: '已传方', color: '#12B886', bg: '#E8F8F2' },
4: { text: '传方失败', color: '#E4572E', bg: '#FDF1EC' },
};
/** 未知状态兜底:后端新增状态时不应出现白字或错色 */
const STATUS_FALLBACK = { text: '未知状态', color: '#8A94A6', bg: '#F1F3F5' };
/**
* 取状态展示元数据
* @param {number|string} status 接口返回的 status(可能是字符串,统一 Number 化)
*/
export function transferStatusMeta(status) {
return STATUS_META[Number(status)] || STATUS_FALLBACK;
}
/**
* 状态色药丸的内联样式(背景 + 描边由状态色推导)
* @param {number|string} status
*/
export function transferPillStyle(status) {
const meta = transferStatusMeta(status);
return {
backgroundColor: meta.bg,
borderColor: meta.color + '33',
};
}
/**
* 状态文案:优先用后端的 status_text,缺省时回落本地文案
* @param {{status: number|string, status_text?: string}} row
*/
export function transferStatusText(row) {
const meta = transferStatusMeta(row && row.status);
return (row && row.status_text) || meta.text;
}

View File

@@ -0,0 +1,144 @@
/**
* 委托调剂三级级联(制剂 / 煎法 / 备注)
* 识图抽屉与传方详情共用,避免 pid / rule_id 分派逻辑复制两份
*/
import { unwrapXkApi } from '@/utils/api-response.js';
import { getProcessRuleList } from '@/api/reception.js';
/**
* 解包 process-rule-list 响应为数组
* 医生/诊所接口都走 jok,统一 unwrapXkApi;兼容偶发裸数组
*/
function unwrapRuleList(res) {
const { ok, payload } = unwrapXkApi(res);
if (ok && Array.isArray(payload)) return payload;
if (Array.isArray(payload)) return payload;
if (res && Array.isArray(res.result)) return res.result;
if (Array.isArray(res)) return res;
return [];
}
/**
* 按 pid / rule_id 拉一级列表并写回 target
* @param {Object} opts
* @param {Function} [opts.api] process-rule-list,默认医生 getProcessRuleList
* @param {number} [opts.pid=0] 父级 id;0 表示顶级制剂
* @param {number} [opts.ruleId=0] 有值时按 rule_id 拉备注列表
* @param {{processRuleList:Array,childProcessRuleList:Array,processRuleNoteList:Array}} opts.target 写回容器(通常是组件 this)
*/
export async function loadProcessRuleListInto(opts) {
const api = (opts && opts.api) || getProcessRuleList;
const pid = Number((opts && opts.pid) || 0);
const ruleId = Number((opts && opts.ruleId) || 0);
const target = (opts && opts.target) || {};
const data = ruleId === 0 ? { pid } : { rule_id: ruleId };
let list = [];
try {
const res = await api(data);
list = unwrapRuleList(res);
} catch (e) {
list = [];
throw e;
} finally {
if (ruleId !== 0) {
target.processRuleNoteList = list;
} else if (pid === 0) {
target.processRuleList = list;
} else {
target.childProcessRuleList = list;
}
}
return list;
}
/**
* 按 chineseConfig 中的 ID 依次拉齐三级列表(OCR / 详情回填后用)
* 为什么先拉顶级:子级接口依赖已选制剂 id,列表空时无法校验回填是否仍有效
* @param {Object} opts
* @param {Function} [opts.api]
* @param {{ruleType:number,processRuleId:*,childProcessRuleId:*}} opts.config
* @param {Object} opts.target
*/
export async function hydrateProcessRuleListsFromConfig(opts) {
const config = (opts && opts.config) || {};
const target = (opts && opts.target) || {};
const api = opts && opts.api;
if (Number(config.ruleType) !== 2) return;
if (!target.processRuleList || !target.processRuleList.length) {
await loadProcessRuleListInto({ api, pid: 0, ruleId: 0, target });
}
const prId = Number(config.processRuleId || 0);
if (prId > 0) {
await loadProcessRuleListInto({ api, pid: prId, ruleId: 0, target });
}
const childId = Number(config.childProcessRuleId || 0);
if (childId > 0) {
await loadProcessRuleListInto({ api, pid: 0, ruleId: childId, target });
}
}
/**
* 默认中药代煎配置:传方默认委托调剂(ruleType=2)
*/
export function defaultChineseConfig(overrides) {
return Object.assign(
{
ruleType: 2,
packageMethodId: null,
processRuleId: null,
childProcessRuleId: null,
processRuleNoteId: null,
dosage: 7,
dayDosage: 2,
},
overrides || {},
);
}
/**
* 传方代煎+用量必填校验(对齐开方 PrescriptionValidator)
* @param {Object} config chineseConfig
* @returns {{valid:boolean,keys:string[],message:string}}
*/
export function validateChineseConfigRequired(config) {
const cfg = config || {};
const keys = [];
const messages = [];
const ruleType = Number(cfg.ruleType || 0);
if (ruleType !== 1 && ruleType !== 2) {
keys.push('ruleType');
messages.push('请选择制剂方式');
}
if (ruleType === 1) {
if (!Number(cfg.packageMethodId)) {
keys.push('packageMethodId');
messages.push('请选择包法');
}
} else if (ruleType === 2) {
if (!Number(cfg.processRuleId)) {
keys.push('processRuleId');
messages.push('请选择制剂要求');
}
if (!Number(cfg.childProcessRuleId)) {
keys.push('childProcessRuleId');
messages.push('请选择煎法说明');
}
if (!Number(cfg.processRuleNoteId)) {
keys.push('processRuleNoteId');
messages.push('请选择附加备注');
}
}
if (!(Number(cfg.dosage) >= 1)) {
keys.push('dosage');
messages.push('请填写剂数');
}
if (!(Number(cfg.dayDosage) >= 1)) {
keys.push('dayDosage');
messages.push('请填写用药频次');
}
return {
valid: keys.length === 0,
keys,
message: messages[0] || '',
};
}

View File

@@ -0,0 +1,258 @@
<template>
<!-- 传方审方驳回:文本域录入原因;pageGuard / show 拆分防意外退出 -->
<view>
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="pageGuard"
:show="visible"
:overlay="true"
position="bottom"
:round="true"
@leave="onClose"
@afterleave="releasePageGuard"
@clickoverlay="onClose"
>
<view class="reject-drawer">
<view class="reject-head">
<text class="reject-title">驳回原因</text>
<text class="reject-close" @click="onClose">关闭</text>
</view>
<view class="reject-body">
<text class="reject-hint">请说明驳回原因,便于诊所修改后再交</text>
<textarea
class="reject-textarea"
:value="reason"
:maxlength="200"
:disabled="submitting"
:auto-height="false"
:show-confirm-bar="false"
placeholder="例如:药品克数有误、地址不完整、代煎未选…"
placeholder-class="reject-ph"
@input="onInput"
/>
<view class="reject-count-row">
<text class="reject-count">{{ reasonLen }}/200</text>
</view>
</view>
<view class="reject-foot">
<view
class="reject-btn reject-btn-ghost"
:class="{ 'is-disabled': submitting }"
hover-class="hover-dim"
@click="onClose"
>取消</view>
<view
class="reject-btn reject-btn-primary"
:class="{ 'is-disabled': submitting || !canSubmit }"
hover-class="hover-dim"
@click="onConfirm"
>
<u-loading v-if="submitting" mode="circle" color="#FFFFFF" size="28"></u-loading>
<text class="reject-btn-text">{{ submitting ? '提交中…' : '确认驳回' }}</text>
</view>
</view>
</view>
</page-container>
<!-- #endif -->
</view>
</template>
<script>
/**
* 传方订单驳回原因抽屉
* 替代 uni.showModal editable(仅单行且样式差),用文本域 + 品牌青绿按钮
*/
export default {
name: 'RejectReasonDrawer',
props: {
/** v-model 显隐 */
value: { type: Boolean, default: false },
/** 父页提交中:锁按钮防重复 */
submitting: { type: Boolean, default: false },
},
data() {
return {
pageGuard: false,
visible: false,
releaseTimer: null,
reason: '',
};
},
computed: {
reasonLen() {
return String(this.reason || '').length;
},
canSubmit() {
return String(this.reason || '').trim().length > 0;
},
},
watch: {
value(v) {
if (v) {
this.open();
} else {
// 父级强制关(驳回成功)即使 submitting 也要关,否则会卡在遮罩
this.forceClose();
}
},
},
beforeDestroy() {
this.clearReleaseTimer();
},
methods: {
onInput(e) {
const v = (e && e.detail && e.detail.value != null) ? e.detail.value : '';
this.reason = String(v);
},
open() {
this.clearReleaseTimer();
this.reason = '';
this.pageGuard = true;
this.$nextTick(() => {
this.visible = true;
});
},
/** 用户点取消/遮罩:提交中禁止关掉,避免半截请求 */
onClose() {
if (this.submitting) return;
this.forceClose();
},
/** 真正关抽屉(父级 v-model=false 或用户取消) */
forceClose() {
this.visible = false;
if (this.value) {
this.$emit('input', false);
}
this.scheduleReleaseGuard();
},
onConfirm() {
if (this.submitting) return;
const text = String(this.reason || '').trim();
if (!text) {
uni.showToast({ title: '请填写驳回原因', icon: 'none' });
return;
}
this.$emit('confirm', text);
},
releasePageGuard() {
this.clearReleaseTimer();
this.pageGuard = false;
},
scheduleReleaseGuard() {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.pageGuard = false;
this.releaseTimer = null;
}, 350);
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
},
},
};
</script>
<style lang="scss" scoped>
.hover-dim {
opacity: 0.86;
}
.reject-drawer {
background: #fff;
border-radius: 28rpx 28rpx 0 0;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
overflow: hidden;
}
.reject-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
}
.reject-title {
font-size: 34rpx;
font-weight: 700;
color: #1f2a33;
}
.reject-close {
font-size: 28rpx;
color: #00a88a;
padding: 8rpx 4rpx;
}
.reject-body {
padding: 8rpx 32rpx 12rpx;
}
.reject-hint {
display: block;
font-size: 24rpx;
color: #8a92a3;
line-height: 1.5;
margin-bottom: 16rpx;
}
.reject-textarea {
width: 100%;
min-height: 220rpx;
max-height: 360rpx;
box-sizing: border-box;
padding: 24rpx;
font-size: 28rpx;
line-height: 1.55;
color: #2a2e35;
background: #f7f8fa;
border-radius: 16rpx;
border: 1px solid rgba(106, 205, 187, 0.35);
box-shadow: 0 0 8rpx rgba(106, 205, 187, 0.12);
}
.reject-ph {
color: #b0b7c3;
font-size: 26rpx;
}
.reject-count-row {
display: flex;
justify-content: flex-end;
margin-top: 10rpx;
}
.reject-count {
font-size: 22rpx;
color: #9aa4ae;
}
.reject-foot {
display: flex;
gap: 20rpx;
padding: 20rpx 32rpx 8rpx;
}
.reject-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
}
.reject-btn-ghost {
background: #fff;
border: 1px solid #e2e8f0;
}
.reject-btn-ghost .reject-btn-text,
.reject-btn-ghost {
color: #5a6472;
font-size: 30rpx;
font-weight: 600;
}
.reject-btn-primary {
background: linear-gradient(135deg, #00bfa6 0%, #6acdbb 100%);
box-shadow: 0 10rpx 24rpx rgba(106, 205, 187, 0.32);
}
.reject-btn-text {
font-size: 30rpx;
font-weight: 700;
color: #fff;
margin-left: 8rpx;
}
.is-disabled {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<!--
开方医生选择:卡片(头像+姓名+科室/职称),盖在当前页上
不用 page-container:同页只能有一个,识图抽屉/详情驳回抽屉已占用
-->
<view v-if="value" class="tdp-mask" @click="onClose">
<view class="tdp-sheet" @click.stop>
<view class="tdp-head">
<text class="tdp-title">选择开方医生</text>
<text class="tdp-close" @click="onClose">关闭</text>
</view>
<scroll-view scroll-y class="tdp-scroll">
<view v-if="!list.length" class="tdp-empty">暂无本店医生</view>
<view
v-for="doc in list"
:key="doc.id"
class="tdp-card"
:class="{ on: selectedId == doc.id }"
@click="onPick(doc)"
>
<view
v-if="!doc.avatar"
class="tdp-avatar tdp-avatar-fallback"
>
<text class="tdp-avatar-char">{{ (doc.name || '医').slice(0, 1) }}</text>
</view>
<image
v-else
class="tdp-avatar"
:src="doc.avatar"
mode="aspectFill"
/>
<view class="tdp-main">
<view class="tdp-name-row">
<text class="tdp-name">{{ doc.name || doc.real_name || ('医生#' + doc.id) }}</text>
<text v-if="doc.title" class="tdp-title-tag">{{ doc.title }}</text>
</view>
<text class="tdp-dept">{{ doc.department || doc.depart || '未设置科室' }}</text>
</view>
<view class="tdp-check" :class="{ on: selectedId == doc.id }">
<u-icon
v-if="selectedId == doc.id"
name="checkmark"
size="26"
color="#FFFFFF"
></u-icon>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
/**
* 传方开方医生选择器(卡片)
* 列表项约定:id / name / avatar / department / title
*/
export default {
name: 'TransferDoctorPicker',
props: {
value: { type: Boolean, default: false },
list: { type: Array, default: () => [] },
selectedId: { type: [Number, String], default: 0 },
},
methods: {
onClose() {
this.$emit('input', false);
},
onPick(doc) {
if (!doc) return;
this.$emit('select', doc);
this.$emit('input', false);
},
},
};
</script>
<style lang="scss" scoped>
.tdp-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10100;
background: rgba(15, 23, 42, 0.45);
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.tdp-sheet {
max-height: 72vh;
background: #f5f7f8;
border-radius: 28rpx 28rpx 0 0;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
overflow: hidden;
}
.tdp-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 16rpx;
background: #fff;
border-bottom: 1rpx solid #eef0f3;
flex-shrink: 0;
}
.tdp-title {
font-size: 32rpx;
font-weight: 700;
color: #1f2a33;
}
.tdp-close {
font-size: 28rpx;
color: #00a88a;
padding: 8rpx 4rpx;
}
.tdp-scroll {
flex: 1;
height: 56vh;
padding: 20rpx 24rpx 24rpx;
box-sizing: border-box;
}
.tdp-empty {
padding: 100rpx 0;
text-align: center;
font-size: 26rpx;
color: #98a2b3;
}
.tdp-card {
display: flex;
align-items: center;
padding: 24rpx;
margin-bottom: 16rpx;
background: #fff;
border-radius: 20rpx;
border: 1px solid rgba(106, 205, 187, 0.22);
box-shadow: 0 0 8rpx rgba(106, 205, 187, 0.1);
}
.tdp-card.on {
border-color: rgba(0, 168, 138, 0.55);
box-shadow: 0 0 12rpx rgba(106, 205, 187, 0.28);
background: rgba(106, 205, 187, 0.08);
}
.tdp-avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
flex-shrink: 0;
background: #e8f6f4;
}
.tdp-avatar-fallback {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #00bfa6 0%, #6acdbb 100%);
}
.tdp-avatar-char {
font-size: 36rpx;
font-weight: 700;
color: #fff;
}
.tdp-main {
flex: 1;
margin-left: 20rpx;
min-width: 0;
}
.tdp-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.tdp-name {
font-size: 30rpx;
font-weight: 700;
color: #16302c;
margin-right: 12rpx;
}
.tdp-title-tag {
font-size: 22rpx;
color: #008771;
background: rgba(106, 205, 187, 0.16);
border: 1px solid rgba(106, 205, 187, 0.35);
border-radius: 8rpx;
padding: 2rpx 10rpx;
}
.tdp-dept {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #6b7785;
line-height: 1.4;
}
.tdp-check {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 2rpx solid #d0d5dd;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 12rpx;
}
.tdp-check.on {
border-color: #00a88a;
background: #00a88a;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<!-- 传方订单只读处方笺:pageGuard / show 拆分,对齐切换账号 -->
<view>
<!-- #ifdef MP-WEIXIN -->
<page-container
v-if="pageGuard"
:show="visible"
:overlay="true"
position="bottom"
:round="true"
@leave="onClose"
@afterleave="releasePageGuard"
@clickoverlay="onClose"
>
<view class="rx-drawer">
<view class="rx-drawer-head">
<text class="rx-drawer-title">处方笺</text>
<text class="rx-drawer-close" @click="onClose">关闭</text>
</view>
<scroll-view scroll-y class="popup-scroll">
<view v-if="view" class="container safe-area-inset-bottom">
<view class="head">
<view class="head_record">
<image
:src="view.sealImage || defaultSeal"
mode="aspectFit"
></image>
<view class="record_yard">
<view class="yard">{{ view.storeName }}</view>
<view class="state">处方笺</view>
</view>
</view>
<view v-if="view.createdAt" class="head_record_time">
<text>开具日期:{{ 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>
<view v-if="view.diagnose" class="info">
<view class="info_item">
<view class="names">诊断:<text>{{ view.diagnose }}</text></view>
</view>
</view>
</view>
<view class="bg">
<view class="my_medical">
<view class="title">Rp</view>
<view class="item">
<view class="name">
<view v-for="(drug, dIdx) in view.drugs" :key="dIdx" class="_name">
<view class="text">
<text>{{ drug.name }}</text>
<text v-if="drug.usage" class="_abbr">[{{ drug.usage }}]</text>
</view>
<text class="name_num">{{ drug.qty }}</text>
</view>
</view>
<view v-if="view.process || view.dosageText" class="details">
<view class="text">
<text v-if="view.dosageText">用法:{{ view.dosageText }}</text>
<text v-if="view.process" style="margin-left: 10rpx;">代煎:{{ view.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>
</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.doctorSignSrc"
class="sign-img"
:src="view.doctorSignSrc"
mode="aspectFit"
></image>
<text v-else class="name">{{ view.doctorName || '' }}</text>
</view>
</view>
</view>
</view>
<view v-else class="popup-loading">暂无数据</view>
</scroll-view>
</view>
</page-container>
<!-- #endif -->
</view>
</template>
<script>
/**
* 传方订单处方笺抽屉(只读)
* 从 order 对象拼装视图,样式对齐诊所端 PrescriptionDetailPopup 处方笺核心区
*/
export default {
name: 'TransferPrescriptionPad',
props: {
/** v-model 显隐 */
value: { type: Boolean, default: false },
/** 传方订单详情对象 */
order: { type: Object, default: null },
},
data() {
return {
pageGuard: false,
visible: false,
releaseTimer: null,
/** 无公章时的默认水印图 */
defaultSeal: 'https://xiaokang88.oss-cn-hangzhou.aliyuncs.com/xk-doctor-wx/static/group/xkyard.png',
};
},
computed: {
/** 把传方订单字段映射成处方笺展示结构 */
view() {
const o = this.order || {};
if (!o || !o.id) return null;
const drugsRaw = Array.isArray(o.drugs)
? o.drugs
: (typeof o.drugs_json === 'string'
? this.parseDrugsJson(o.drugs_json)
: (Array.isArray(o.drugs_json) ? o.drugs_json : []));
const drugs = [];
for (let i = 0; i < drugsRaw.length; i++) {
const d = drugsRaw[i] || {};
const name = String(d.name || d.drug_name || '').trim();
const num = d.number != null ? d.number : (d.quantity != null ? d.quantity : '');
const unit = d.unit || 'g';
drugs.push({
name: name || '未命名',
qty: num !== '' ? (String(num) + unit) : '',
usage: d.usage || '',
});
}
const sexMap = { 1: '男', 2: '女' };
const sexNum = Number(o.sex || o.patient_sex || 0);
const dosage = Number(o.dosage || 0);
const dayDosage = Number(o.day_dosage || 0);
let dosageText = '';
if (dosage > 0 || dayDosage > 0) {
dosageText = (dosage > 0 ? dosage + '剂' : '') + (dayDosage > 0 ? ' 每日' + dayDosage + '次' : '');
}
// 抬头必须是上传诊所名(后端 store_name),禁止「传方处方」占位
const storeName = String(o.store_name || o.storeName || '').trim();
return {
storeName: storeName || '未知诊所',
sealImage: this.normalizeSignUrl(o.store_seal || o.storeSeal || o.offical_seal || ''),
createdAt: o.created_at || '',
patientName: o.patient_name || '—',
patientSex: sexMap[sexNum] || '未知',
patientAge: o.age != null && o.age !== '' ? (o.age + '岁') : '—',
diagnose: o.clinical_diagnose || '',
doctorOrder: o.doctor_order || o.medical_advice || '',
process: o.process_rule_label || '',
doctorName: o.doctor_name || '',
// 优先电子签名图;无图才回落文字姓名
doctorSignSrc: this.normalizeSignUrl(
o.doctor_sign_image
|| (o.doctor_info && o.doctor_info.identity_info && o.doctor_info.identity_info.sign_image)
|| ''
),
dosageText,
drugs,
};
},
},
watch: {
value(v) {
if (v) {
this.open();
} else {
this.onClose();
}
},
},
beforeDestroy() {
this.clearReleaseTimer();
},
methods: {
/**
* 签名/公章图:兼容 http(s) 与纯 base64(对齐处方详情 checkImageUrl)
*/
normalizeSignUrl(url) {
const raw = String(url || '').trim();
if (!raw) return '';
// 含 http 视为可直链(含 https、协议相对 //cdn)
if (raw.indexOf('http') !== -1 || raw.indexOf('data:') === 0) {
if (raw.indexOf('//') === 0) return 'https:' + raw;
return raw;
}
return 'data:image/jpeg;base64,' + raw;
},
/** 解析 drugs_json 字符串,失败返回空数组 */
parseDrugsJson(raw) {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
},
/** 打开处方笺:先挂 pageGuard 再下一帧 show */
open() {
this.clearReleaseTimer();
this.pageGuard = true;
this.$nextTick(() => {
this.visible = true;
});
},
/** 关闭:先离场再卸挂载 */
onClose() {
this.visible = false;
this.$emit('input', false);
this.scheduleReleaseGuard();
},
releasePageGuard() {
this.clearReleaseTimer();
this.pageGuard = false;
},
scheduleReleaseGuard() {
this.clearReleaseTimer();
this.releaseTimer = setTimeout(() => {
this.pageGuard = false;
this.releaseTimer = null;
}, 350);
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer);
this.releaseTimer = null;
}
},
},
};
</script>
<style lang="scss" scoped>
.rx-drawer {
height: 85vh;
background: #fff;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
border-radius: 24rpx 24rpx 0 0;
overflow: hidden;
}
.rx-drawer-head {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 32rpx;
border-bottom: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.rx-drawer-title {
font-size: 32rpx;
font-weight: 600;
color: #1d2129;
}
.rx-drawer-close {
font-size: 28rpx;
color: #6acdbb;
}
.popup-scroll {
flex: 1;
height: 0;
}
.popup-loading {
text-align: center;
color: #999;
padding: 80rpx;
}
.container {
width: 100%;
padding: 0 20rpx 40rpx;
box-sizing: border-box;
.bg {
min-height: 400rpx;
}
.head {
.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-weight: 400;
color: #31353d;
}
.state {
height: 44rpx;
font-size: 32rpx;
color: #31353d;
line-height: 38rpx;
}
}
}
.head_record_time {
text-align: right;
margin-top: 20rpx;
width: 100%;
font-size: 24rpx;
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;
padding: 4rpx;
.info {
width: 100%;
display: flex;
align-items: center;
flex-wrap: wrap;
.info_item {
.names,
.name {
display: flex;
align-items: center;
font-size: 28rpx;
color: #6c7380;
text {
flex: 1;
display: block;
height: 40rpx;
font-size: 28rpx;
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-weight: 600;
color: #31353d;
margin-bottom: 2rpx;
}
.item {
width: 100%;
padding: 1rpx 0;
.name {
font-size: 28rpx;
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;
color: #6c7380;
display: flex;
flex-direction: column;
}
}
}
.doctor_order {
width: 100%;
margin: 2rpx auto;
padding: 0 10rpx;
box-sizing: border-box;
.yz {
display: flex;
width: 100%;
color: #6c7380;
.order_info_left {
margin-right: 3rpx;
}
.order_info {
font-size: 28rpx;
line-height: 40rpx;
margin-bottom: 2rpx;
}
}
.titles {
width: 100%;
font-size: 24rpx;
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;
flex-wrap: wrap;
.info {
min-width: 222rpx;
margin: 5rpx 0 8rpx;
text {
font-size: 24rpx;
color: #31353d;
}
.name {
display: inline-block;
width: 106rpx;
font-size: 32rpx;
color: #31353d;
line-height: 38rpx;
border-bottom: 2rpx solid #000000;
margin-left: 16rpx;
text-align: center;
min-height: 38rpx;
}
.sign-img {
width: 82rpx;
height: 62rpx;
vertical-align: middle;
margin-left: 10rpx;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,847 @@
<template>
<view class="page">
<!-- 顶部:数量 + 识图入口(液态玻璃条) -->
<view class="topbar glass">
<view class="topbar-info">
<text class="topbar-num">{{ items.length }}</text>
<text class="topbar-desc">{{ topbarDesc }}</text>
</view>
<view
v-if="scope !== 'all'"
class="upload-btn"
hover-class="hover-dim"
@click="goUpload"
>
<u-icon name="camera-fill" size="28" color="#FFFFFF"></u-icon>
<text class="upload-text">识图传方</text>
</view>
</view>
<!-- 状态 Tab:点击 + 下方 swiper 左右滑双向同步,本地记忆上次选中 -->
<view class="tabs-wrap glass">
<scroll-view
class="tabs-scroll"
scroll-x
:scroll-into-view="activeTabSid"
:show-scrollbar="false"
scroll-with-animation
>
<view class="tabs-row">
<view
v-for="(tab, idx) in tabs"
:id="tab.sid"
:key="tab.value"
class="tab"
:class="{ on: statusIndex === idx }"
@click="onTabClick(idx)"
>{{ tab.label }}</view>
</view>
</scroll-view>
</view>
<!-- 左右滑切换状态;内层 scroll-view 负责下拉刷新 -->
<swiper
class="list-swiper"
:current="statusIndex"
:disable-touch="tabs.length < 2"
@change="onSwiperChange"
>
<swiper-item v-for="(tab, idx) in tabs" :key="tab.value">
<scroll-view
scroll-y
class="list-scroll"
refresher-enabled
:refresher-triggered="isRefreshing"
@refresherrefresh="onRefresherRefresh"
>
<block v-if="statusIndex === idx">
<!-- 骨架:首屏加载且无数据时避免空态闪一下 -->
<view v-if="loading && !items.length" class="list-pad">
<view v-for="n in 3" :key="n" class="sk-card glass">
<view class="sk-avatar"></view>
<view class="sk-body">
<view class="sk-line sk-60"></view>
<view class="sk-line sk-90"></view>
</view>
</view>
</view>
<view v-else-if="!items.length" class="empty">
<d-empty text="暂无传方订单" />
<view
v-if="scope !== 'all'"
class="empty-btn"
hover-class="hover-dim"
@click="goUpload"
>去识图传方</view>
</view>
<view v-else class="list-pad">
<view
v-for="row in items"
:key="row.id"
class="order-card glass"
hover-class="card-press"
@click="openDetail(row)"
>
<view class="card-head">
<view class="avatar">
<text class="avatar-text">{{ avatarChar(row) }}</text>
</view>
<view class="head-main">
<view class="title-row">
<text class="title">{{ row.patient_name || '未填姓名' }}</text>
<view class="st-pill" :style="pillStyle(row.status)">
<view class="st-dot" :style="{ backgroundColor: statusMeta(row.status).color }"></view>
<text class="st-text" :style="{ color: statusMeta(row.status).color }">{{ statusText(row) }}</text>
</view>
</view>
<text class="meta">{{ metaText(row) }}</text>
</view>
</view>
<view v-if="row.reject_reason" class="alert">
<u-icon name="close-circle-fill" size="28" color="#FA3534"></u-icon>
<text class="alert-text">{{ row.reject_reason }}</text>
</view>
<view v-if="drugPreview(row).length" class="chips">
<view v-for="(name, di) in drugPreview(row)" :key="di" class="chip">{{ name }}</view>
<text v-if="drugRest(row)" class="chip-more">共 {{ row.drug_count }} 味</text>
</view>
<text v-else-if="row.drug_count" class="drug-count">共 {{ row.drug_count }} 味药</text>
<view v-if="row.mes_message" class="mes">
<u-icon name="info-circle" size="26" color="#9AA4AE"></u-icon>
<text class="mes-text">{{ row.mes_message }}</text>
</view>
<view class="card-foot">
<view class="foot-left">
<text class="time">{{ row.created_at }}</text>
<text v-if="showRowTotal(row)" class="foot-price">合计 ¥{{ row.total_price }}</text>
</view>
<view
v-for="(act, ai) in actionsOf(row)"
:key="ai"
class="act-btn"
:class="act.type === 'primary' ? 'act-primary' : 'act-ghost'"
hover-class="hover-dim"
@click.stop="onAction(act, row)"
>{{ act.text }}</view>
</view>
</view>
</view>
</block>
<view v-else class="swiper-placeholder"></view>
</scroll-view>
</swiper-item>
</swiper>
<!-- 平台审方驳回:文本域抽屉 -->
<RejectReasonDrawer
v-model="showRejectDrawer"
:submitting="actionLock"
@confirm="submitReject"
/>
</view>
</template>
<script>
/**
* 传方订单列表
* scope=mine 医生只看自己;scope=store 诊所管理员看本店;scope=all 平台审方全量
* Tab:点击 / 左右滑双向同步,并按 scope 本地记忆上次选中状态
* 卡片:苹果液态玻璃风格,对齐识图抽屉 ocr-glass 视觉语言
*/
import { unwrapXkApi } from '@/utils/api-response.js';
import {
listMyImageTransferOrders,
listStoreImageTransferOrders,
listPlatformImageTransferOrders,
approvePlatformImageTransferOrder,
rejectPlatformImageTransferOrder,
retryPlatformImageTransferOrder,
} from '@/api/imageTransferOrder.js';
import pullRefreshMixin from '@/subPackages/sub_business_shared/common/pullRefreshMixin.js';
import { transferStatusMeta, transferPillStyle, transferStatusText } from './common/display.js';
import RejectReasonDrawer from './components/RejectReasonDrawer.vue';
/** 本地 Tab 记忆前缀:mine / store / all 分开记,避免各端互相覆盖 */
const TAB_STORAGE_PREFIX = 'image_transfer_order_status_tab_';
export default {
mixins: [pullRefreshMixin],
components: { RejectReasonDrawer },
data() {
return {
scope: 'mine',
statusIndex: 0,
items: [],
loading: true,
/** 防止 tabs 与 swiper 互相触发形成回路(对齐商品订单列表) */
tabChanging: false,
/** 平台审方操作防重复 */
actionLock: false,
showRejectDrawer: false,
/** 当前待驳回的订单 id */
rejectTargetId: 0,
tabs: [
{ label: '全部', value: 0, sid: 'tab0' },
{ label: '待审', value: 1, sid: 'tab1' },
{ label: '驳回', value: 2, sid: 'tab2' },
{ label: '已传', value: 3, sid: 'tab3' },
{ label: '失败', value: 4, sid: 'tab4' },
],
};
},
computed: {
/** 当前选中状态值:接口 status 筛选用 */
status() {
const hit = this.tabs[this.statusIndex];
return hit ? hit.value : 0;
},
/** 当前 Tab 的 scroll-into-view 锚点(微信模板禁止 'tab-'+idx 拼接) */
activeTabSid() {
const hit = this.tabs[this.statusIndex];
return hit ? hit.sid : 'tab0';
},
topbarDesc() {
const hit = this.tabs[this.statusIndex];
return `条${hit && hit.value !== 0 ? hit.label : '记录'}`;
},
tabStorageKey() {
return TAB_STORAGE_PREFIX + (this.scope || 'mine');
},
},
onLoad(query) {
// store=本店,all=平台全量,其余默认医生本人
if (query && query.scope === 'store') {
this.scope = 'store';
} else if (query && query.scope === 'all') {
this.scope = 'all';
} else {
this.scope = 'mine';
}
this.statusIndex = this.readStoredIndex();
},
onShow() {
this.load();
},
methods: {
/**
* 同步读本地 Tab 下标:避免先渲染「全部」再跳回,把下拉刷新冲掉
* 存的是 status value,再反查下标,避免 tabs 顺序调整后错位
*/
readStoredIndex() {
const stored = Number(uni.getStorageSync(this.tabStorageKey));
const idx = this.tabs.findIndex((tab) => tab.value === stored);
return idx >= 0 ? idx : 0;
},
/** 记住当前 Tab,下次进页停在同一状态 */
persistTab(idx) {
const hit = this.tabs[idx];
if (!hit) return;
uni.setStorageSync(this.tabStorageKey, hit.value);
},
statusMeta(status) {
return transferStatusMeta(status);
},
pillStyle(status) {
return transferPillStyle(status);
},
statusText(row) {
return transferStatusText(row);
},
avatarChar(row) {
const name = (row && row.patient_name) || '';
return name ? name.slice(0, 1) : '患';
},
metaText(row) {
const doctor = row.doctor_name ? ('医生·' + row.doctor_name) : '';
return [row.patient_mobile, doctor, row.uploader_role_text].filter(Boolean).join(' · ');
},
drugPreview(row) {
return Array.isArray(row.drug_preview) ? row.drug_preview.slice(0, 4) : [];
},
drugRest(row) {
const shown = this.drugPreview(row).length;
return Number(row.drug_count || 0) > shown ? Number(row.drug_count) : 0;
},
/**
* 列表合计:白名单门店 can_view_price=1 且有 total_price 才展示,缺字段不崩
*/
showRowTotal(row) {
if (!row) return false;
if (Number(row.can_view_price) !== 1) return false;
return row.total_price != null && row.total_price !== '';
},
/**
* 卡片主操作:
* - 平台 scope=all:待审通过/驳回,失败可重试,其余看详情
* - 医生/诊所:待审或驳回可改(直达详情编辑态),其余只看详情
*/
actionsOf(row) {
const s = Number(row.status);
if (this.scope === 'all') {
if (s === 1) {
return [
{ key: 'approve', text: '通过', type: 'primary' },
{ key: 'reject', text: '驳回', type: 'ghost' },
];
}
if (s === 4) {
return [
{ key: 'retry', text: '重试', type: 'primary' },
{ key: 'detail', text: '查看详情', type: 'ghost' },
];
}
return [{ key: 'detail', text: '查看详情', type: 'ghost' }];
}
if (s === 1 || s === 2) {
return [{ key: 'resubmit', text: s === 2 ? '修改后再交' : '修改', type: 'primary' }];
}
return [{ key: 'detail', text: '查看详情', type: 'ghost' }];
},
onAction(act, row) {
if (!act || !row) return;
if (act.key === 'approve') {
this.confirmApprove(row);
return;
}
if (act.key === 'reject') {
this.confirmReject(row);
return;
}
if (act.key === 'retry') {
this.confirmRetry(row);
return;
}
this.openDetail(row, act.key === 'resubmit');
},
/** 平台审方通过:二次确认后调 approve */
confirmApprove(row) {
if (this.actionLock) return;
uni.showModal({
title: '确认通过',
content: '通过后将推送传方,是否继续?',
success: async (r) => {
if (!r.confirm) return;
this.actionLock = true;
uni.showLoading({ title: '处理中' });
try {
const res = await approvePlatformImageTransferOrder({ id: row.id });
const parsed = unwrapXkApi(res);
if (!parsed.ok) {
uni.showToast({ title: parsed.message || '操作失败', icon: 'none' });
return;
}
uni.showToast({ title: '已通过', icon: 'none' });
this.load();
} catch (e) {
uni.showToast({ title: (e && e.message) || '操作失败', icon: 'none' });
} finally {
uni.hideLoading();
this.actionLock = false;
}
},
});
},
/** 平台审方驳回:打开文本域抽屉 */
confirmReject(row) {
if (this.actionLock || !row || !row.id) return;
this.rejectTargetId = Number(row.id);
this.showRejectDrawer = true;
},
/**
* 抽屉确认驳回
*/
async submitReject(reason) {
const text = String(reason || '').trim();
const id = Number(this.rejectTargetId || 0);
if (!text || !id || this.actionLock) return;
this.actionLock = true;
try {
const res = await rejectPlatformImageTransferOrder({ id, reason: text });
const parsed = unwrapXkApi(res);
if (!parsed.ok) {
uni.showToast({ title: parsed.message || '操作失败', icon: 'none' });
return;
}
this.showRejectDrawer = false;
this.rejectTargetId = 0;
uni.showToast({ title: '已驳回', icon: 'none' });
this.load();
} catch (e) {
uni.showToast({ title: (e && e.message) || '操作失败', icon: 'none' });
} finally {
this.actionLock = false;
}
},
/** 平台推送失败重试 */
confirmRetry(row) {
if (this.actionLock) return;
uni.showModal({
title: '确认重试',
content: '将重新推送该传方,是否继续?',
success: async (r) => {
if (!r.confirm) return;
this.actionLock = true;
uni.showLoading({ title: '处理中' });
try {
const res = await retryPlatformImageTransferOrder({ id: row.id });
const parsed = unwrapXkApi(res);
if (!parsed.ok) {
uni.showToast({ title: parsed.message || '操作失败', icon: 'none' });
return;
}
uni.showToast({ title: '已重试', icon: 'none' });
this.load();
} catch (e) {
uni.showToast({ title: (e && e.message) || '操作失败', icon: 'none' });
} finally {
uni.hideLoading();
this.actionLock = false;
}
},
});
},
/** Tab 点击:同步 swiper + 记本地 + 重拉 */
onTabClick(idx) {
if (this.tabChanging) return;
this.tabChanging = true;
const next = Number(idx);
if (!Number.isNaN(next) && next !== this.statusIndex) {
this.statusIndex = next;
this.persistTab(next);
this.load();
}
this.$nextTick(() => {
this.tabChanging = false;
});
},
/** 左右滑切换状态:与 Tab 双向同步 */
onSwiperChange(e) {
if (this.tabChanging) return;
this.tabChanging = true;
const idx = Number((e.detail && e.detail.current) || 0);
if (idx !== this.statusIndex) {
this.statusIndex = idx;
this.persistTab(idx);
this.load();
}
this.$nextTick(() => {
this.tabChanging = false;
});
},
reload() {
return this.load();
},
async load() {
let api = listMyImageTransferOrders;
if (this.scope === 'all') {
api = listPlatformImageTransferOrders;
} else if (this.scope === 'store') {
api = listStoreImageTransferOrders;
}
this.loading = true;
try {
const res = await api({ page: 1, page_size: 30, status: this.status });
const parsed = unwrapXkApi(res);
const data = parsed.payload || {};
this.items = parsed.ok && data.items ? data.items : [];
} catch (e) {
this.items = [];
} finally {
this.loading = false;
this.finishRefresh();
}
},
openDetail(row, edit) {
uni.navigateTo({
url:
'/subPackages/sub_image_transfer/order-detail?id=' +
row.id +
'&scope=' +
this.scope +
(edit ? '&edit=1' : ''),
});
},
/** 识图入口:平台审方列表不提供上传 */
goUpload() {
if (this.scope === 'all') {
uni.showToast({ title: '平台审方请在列表操作', icon: 'none' });
return;
}
const url = this.scope === 'store'
? '/subPackages/sub_image_transfer/upload'
: '/subPackages/sub_workbench/image-transfer-prescription/index';
uni.navigateTo({ url });
},
},
};
</script>
<style lang="scss" scoped>
/* 液态玻璃:半透明白 + 青绿描边微光 + blur,对齐识图抽屉 ocr-glass */
$glass-bg: rgba(255, 255, 255, 0.55);
$glass-border: rgba(106, 205, 187, 0.28);
$glass-glow: 0 0 16rpx rgba(106, 205, 187, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.7);
$primary: #6acdbb;
.page {
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
/* 青绿到浅灰渐变垫底,玻璃层才看得出通透感 */
background: linear-gradient(180deg, #e8f6f4 0%, #f4f6f8 42%, #eef2f5 100%);
}
.hover-dim {
opacity: 0.84;
}
.glass {
background: $glass-bg;
border: 1px solid $glass-border;
box-shadow: $glass-glow;
backdrop-filter: blur(22px);
}
/* ===== 概览条 ===== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin: 16rpx 24rpx 0;
padding: 20rpx 28rpx;
border-radius: 28rpx;
}
.topbar-info {
display: flex;
align-items: baseline;
}
.topbar-num {
font-size: 44rpx;
font-weight: 700;
color: #16302c;
line-height: 1;
}
.topbar-desc {
margin-left: 10rpx;
font-size: 24rpx;
color: #7a8794;
}
.upload-btn {
display: flex;
align-items: center;
padding: 14rpx 28rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #5ec8b4 0%, #6acdbb 100%);
box-shadow: 0 8rpx 18rpx rgba(106, 205, 187, 0.32);
}
.upload-text {
margin-left: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #ffffff;
}
/* ===== Tab ===== */
.tabs-wrap {
margin: 16rpx 24rpx 0;
padding: 12rpx 8rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.tabs-scroll {
width: 100%;
white-space: nowrap;
}
.tabs-row {
display: inline-flex;
align-items: center;
padding: 0 8rpx;
}
.tab {
display: inline-block;
padding: 12rpx 28rpx;
margin: 0 4rpx;
font-size: 26rpx;
color: #667085;
border-radius: 999rpx;
transition: all 0.18s;
}
.tab.on {
color: #ffffff;
font-weight: 600;
background: linear-gradient(135deg, #5ec8b4 0%, #6acdbb 100%);
box-shadow: 0 6rpx 14rpx rgba(106, 205, 187, 0.32);
}
/* ===== Swiper 列表区 ===== */
.list-swiper {
flex: 1;
height: 0;
min-height: 0;
width: 100%;
margin-top: 8rpx;
}
.list-scroll {
height: 100%;
box-sizing: border-box;
}
.list-pad {
padding: 12rpx 24rpx calc(40rpx + env(safe-area-inset-bottom));
}
.swiper-placeholder {
min-height: 200rpx;
}
/* ===== 订单玻璃卡 ===== */
.order-card {
padding: 28rpx 24rpx 22rpx;
margin-bottom: 20rpx;
border-radius: 28rpx;
}
.card-press {
opacity: 0.92;
transform: translateY(2rpx);
}
.card-head {
display: flex;
align-items: flex-start;
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 50%;
flex-shrink: 0;
background: rgba(106, 205, 187, 0.18);
border: 1px solid rgba(106, 205, 187, 0.35);
}
.avatar-text {
font-size: 32rpx;
font-weight: 700;
color: #008771;
}
.head-main {
flex: 1;
min-width: 0;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
flex: 1;
min-width: 0;
font-size: 30rpx;
font-weight: 700;
color: #16302c;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.meta {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #7a8794;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.st-pill {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: 12rpx;
padding: 4rpx 16rpx;
border: 1rpx solid transparent;
border-radius: 999rpx;
}
.st-dot {
width: 10rpx;
height: 10rpx;
margin-right: 8rpx;
border-radius: 50%;
flex-shrink: 0;
}
.st-text {
font-size: 22rpx;
font-weight: 600;
}
.alert {
display: flex;
align-items: flex-start;
margin-top: 16rpx;
padding: 14rpx 18rpx;
border-radius: 16rpx;
background: rgba(254, 240, 240, 0.85);
border: 1px solid rgba(250, 53, 52, 0.18);
}
.alert-text {
flex: 1;
margin-left: 8rpx;
font-size: 24rpx;
line-height: 1.5;
color: #d92d20;
}
.chips {
display: flex;
flex-wrap: wrap;
margin-top: 14rpx;
}
.chip {
padding: 4rpx 16rpx;
margin: 0 10rpx 10rpx 0;
font-size: 22rpx;
color: #3faf9c;
background: rgba(240, 249, 247, 0.9);
border: 1px solid rgba(106, 205, 187, 0.28);
border-radius: 999rpx;
box-shadow: 0 0 6rpx rgba(106, 205, 187, 0.12);
}
.chip-more {
font-size: 22rpx;
line-height: 1.8;
color: #98a2b3;
}
.drug-count {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: #667085;
}
.mes {
display: flex;
align-items: flex-start;
margin-top: 10rpx;
}
.mes-text {
flex: 1;
margin-left: 6rpx;
font-size: 24rpx;
line-height: 1.5;
color: #98a2b3;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1px solid rgba(106, 205, 187, 0.16);
}
.foot-left {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.time {
font-size: 22rpx;
color: #98a2b3;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.foot-price {
margin-top: 4rpx;
font-size: 24rpx;
font-weight: 600;
color: #008771;
}
.act-btn {
flex-shrink: 0;
margin-left: 12rpx;
padding: 10rpx 28rpx;
font-size: 24rpx;
font-weight: 600;
border-radius: 999rpx;
}
.act-primary {
color: #ffffff;
background: linear-gradient(135deg, #5ec8b4 0%, #6acdbb 100%);
box-shadow: 0 6rpx 14rpx rgba(106, 205, 187, 0.28);
}
.act-ghost {
color: #008771;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(106, 205, 187, 0.35);
}
/* ===== 骨架 ===== */
.sk-card {
display: flex;
align-items: flex-start;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
border-radius: 28rpx;
}
.sk-avatar {
width: 80rpx;
height: 80rpx;
margin-right: 20rpx;
border-radius: 50%;
flex-shrink: 0;
}
.sk-body {
flex: 1;
padding-top: 6rpx;
}
.sk-line {
height: 24rpx;
margin-bottom: 18rpx;
border-radius: 12rpx;
}
.sk-60 {
width: 55%;
}
.sk-90 {
width: 85%;
}
.sk-avatar,
.sk-line {
background: linear-gradient(90deg, rgba(238, 241, 243, 0.8) 25%, rgba(247, 249, 250, 0.95) 37%, rgba(238, 241, 243, 0.8) 63%);
background-size: 400% 100%;
animation: sk-shine 1.4s ease infinite;
}
@keyframes sk-shine {
0% {
background-position: 100% 50%;
}
100% {
background-position: 0 50%;
}
}
/* ===== 空态 ===== */
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0 0;
}
.empty-btn {
margin-top: 8rpx;
padding: 20rpx 56rpx;
font-size: 28rpx;
font-weight: 600;
color: #ffffff;
border-radius: 999rpx;
background: linear-gradient(135deg, #5ec8b4 0%, #6acdbb 100%);
box-shadow: 0 8rpx 18rpx rgba(106, 205, 187, 0.32);
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<view class="page">
<!-- 入口态:onReady 会立刻拉起识图抽屉,这一屏只在唤起间隙与抽屉开启失败时可见,
所以做成品牌化的落地页而不是一行「正在打开…」 -->
<view v-if="!showOcr" class="gate">
<view class="gate-badge">
<u-icon name="camera-fill" size="76" color="#FFFFFF"></u-icon>
</view>
<text class="gate-title">识图传方</text>
<text class="gate-desc">拍摄或上传处方图片,自动识别患者与药味,核对无误后即可提交传方</text>
<view class="gate-btn" hover-class="hover-dim" @click="openOcr">
<text class="gate-btn-text">上传处方图识别</text>
<u-icon name="arrow-right" size="28" color="#FFFFFF"></u-icon>
</view>
<view class="gate-steps">
<view v-for="(step, idx) in steps" :key="idx" class="step">
<view class="step-idx">
<text class="step-idx-text">{{ idx + 1 }}</text>
</view>
<text class="step-text">{{ step }}</text>
</view>
</view>
</view>
<ImageOcrDrawer
v-model="showOcr"
mode="transfer"
chrome-mode="embedded"
:store-id="storeId"
:ocr-api="ocrApi"
:search-api="searchApi"
:history-list-api="historyListApi"
:history-detail-api="historyDetailApi"
:match-patient-api="matchPatientApi"
:quote-api="quoteApi"
:process-rule-list-api="processRuleListApi"
:require-doctor-select="true"
:doctor-list-api="doctorListApi"
@confirm-transfer="onConfirmTransfer"
@closed="flushPendingNav"
/>
</view>
</template>
<script>
/**
* 诊所管理员识图传方上传
* 和医生共用识别抽屉,接口按诊所管理员身份
*/
import ImageOcrDrawer from '@/subPackages/sub_workbench/prescription_v2/components/ImageOcrDrawer.vue';
import {
clinicConfirmImageTransfer,
clinicOcrPrescriptionImage,
clinicSearchTransferDrugs,
clinicAiListGenerations,
clinicAiGenerationDetail,
clinicMatchTransferPatient,
clinicProcessRuleList,
clinicTransferDoctorOption,
quoteStoreImageTransferOrder,
} from '@/api/imageTransferOrder.js';
import { unwrapXkApi } from '@/utils/api-response.js';
export default {
name: 'ImageTransferUpload',
components: { ImageOcrDrawer },
data() {
return {
showOcr: false,
storeId: 0,
ocrApi: clinicOcrPrescriptionImage,
searchApi: clinicSearchTransferDrugs,
historyListApi: clinicAiListGenerations,
historyDetailApi: clinicAiGenerationDetail,
/** 诊所端就诊人匹配,注入抽屉后走 clinic 接口而非医生默认 */
matchPatientApi: clinicMatchTransferPatient,
/** 诊所报价 + 代煎级联 */
quoteApi: quoteStoreImageTransferOrder,
processRuleListApi: clinicProcessRuleList,
/** 本店医生选项:诊所识图传方必须指定开方医生 */
doctorListApi: clinicTransferDoctorOption,
pendingNav: null,
/** 入口页三步提示:让用户在抽屉出来前就知道流程,减少中途放弃 */
steps: ['上传或拍摄处方原图', '核对识别出的患者与药味', '提交后进入审方传方'],
};
},
onLoad() {
const user = uni.getStorageSync('clinic_admin_user') || {};
this.storeId = Number(user.store_id || uni.getStorageSync('store_id') || 0);
},
onReady() {
// 进页直接开抽屉,避免中间页卡片主题与识图层不一致
this.openOcr();
},
methods: {
openOcr() {
this.showOcr = true;
},
flushPendingNav() {
const fn = this.pendingNav;
this.pendingNav = null;
if (typeof fn === 'function') {
fn();
return;
}
// 用户关抽屉且无跳转:退回上一页,避免留空白入口页
uni.navigateBack({ fail: () => {} });
},
/**
* 解包跨分包原生组件 emit 的参数。
* ImageOcrDrawer 在 sub_workbench,本页跨分包引用时微信会把 $emit 包成原生事件,
* 业务数据落在 detail.__args__[0];不解包会导致 ...payload 把事件对象当业务字段提交,
* 顶层无 patient_name,后端报「请确认患者姓名和手机号」。
*/
unwrapComponentEventPayload(payload) {
if (payload && payload.detail && Array.isArray(payload.detail.__args__)) {
return payload.detail.__args__[0];
}
if (payload && Array.isArray(payload.__args__)) {
return payload.__args__[0];
}
return payload;
},
/** 确认识图传方:先解包再显式组字段,禁止 spread 整包事件 */
async onConfirmTransfer(e) {
const data = this.unwrapComponentEventPayload(e) || {};
uni.showLoading({ title: '提交中' });
try {
// patient_mobile 优先 _txt:拦截器解密后可能写出明文,避免密文/脱敏串被再次加密
const res = await clinicConfirmImageTransfer({
patient_name: data.patient_name,
patient_mobile: data.patient_mobile_txt || data.patient_mobile,
patient_address: data.patient_address,
sex: data.sex,
age: data.age,
user_patient_id: data.user_patient_id,
doctor_id: data.doctor_id,
clinical_diagnose: data.clinical_diagnose,
medical_advice: data.medical_advice,
doctor_order: data.doctor_order || data.medical_advice,
dosage: data.dosage,
day_dosage: data.day_dosage,
image_urls: data.image_urls,
drugs: data.drugs,
rule_type: data.rule_type,
process_rule_id: data.process_rule_id,
child_process_rule_id: data.child_process_rule_id,
process_rule_note_id: data.process_rule_note_id,
package_method_id: data.package_method_id,
store_id: this.storeId || undefined,
});
const parsed = unwrapXkApi(res);
if (!parsed.ok || !parsed.payload) {
uni.showToast({ title: parsed.message || '失败', icon: 'none' });
return;
}
const result = parsed.payload;
if (result.next === 'reception') {
uni.showToast({ title: '当前配置需要医生进接诊', icon: 'none' });
return;
}
uni.showToast({ title: result.status_text || '已提交', icon: 'none' });
this.pendingNav = () => {
uni.redirectTo({ url: '/subPackages/sub_image_transfer/order-list?scope=store' });
};
this.showOcr = false;
} catch (err) {
uni.showToast({ title: (err && err.message) || '失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
},
};
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
/* 顶部浅青绿渐变落到灰底:与识图抽屉的青绿主题衔接,避免切层闪白 */
background: linear-gradient(180deg, #eaf8f4 0%, #f6f8f9 46%);
}
.hover-dim {
opacity: 0.84;
}
.gate {
display: flex;
flex-direction: column;
align-items: center;
padding: 140rpx 56rpx 0;
}
.gate-badge {
display: flex;
align-items: center;
justify-content: center;
width: 168rpx;
height: 168rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, #00bfa6 0%, #6acdbb 100%);
box-shadow: 0 20rpx 44rpx rgba(106, 205, 187, 0.36);
}
.gate-title {
margin-top: 40rpx;
font-size: 44rpx;
font-weight: 700;
color: #1f2a33;
}
.gate-desc {
margin-top: 20rpx;
font-size: 27rpx;
line-height: 1.7;
color: #7a8794;
text-align: center;
}
.gate-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 92rpx;
margin-top: 64rpx;
border-radius: 20rpx;
background: linear-gradient(135deg, #00bfa6 0%, #6acdbb 100%);
box-shadow: 0 14rpx 30rpx rgba(106, 205, 187, 0.34);
}
.gate-btn-text {
margin-right: 6rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
}
.gate-steps {
width: 100%;
margin-top: 72rpx;
padding: 24rpx 32rpx;
background: #ffffff;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(17, 24, 28, 0.04);
}
.step {
display: flex;
align-items: center;
padding: 14rpx 0;
}
.step-idx {
display: flex;
align-items: center;
justify-content: center;
width: 36rpx;
height: 36rpx;
margin-right: 16rpx;
border-radius: 50%;
background: #f0f9f7;
flex-shrink: 0;
}
.step-idx-text {
font-size: 21rpx;
font-weight: 600;
color: #3faf9c;
}
.step-text {
font-size: 25rpx;
color: #667085;
}
</style>

View File

@@ -55,7 +55,12 @@ export function parseTransferPrescriptionContent(transferData) {
clinical_diagnose: content.clinical_diagnose || orig.clinical_diagnose || '',
doctor_order: content.doctor_order || orig.doctor_order || '',
drugList,
prescription_type: prescriptionType
prescription_type: prescriptionType,
dosage: Number(content.dosage || 0),
day_dosage: Number(content.day_dosage || 0),
process_rule_id: Number(content.process_rule_id || 0),
child_process_rule_id: Number(content.child_process_rule_id || 0),
process_rule_note_id: Number(content.process_rule_note_id || 0),
};
}
if (Array.isArray(content)) {

View File

@@ -102,7 +102,14 @@
</view>
<view class="m-t-16">
<d-text text="药品:"></d-text>
<u-tag v-for="(value,key) in item.p_drugs" v-if="key < 5" :text="value.toString()" style="margin: 10rpx; display: inline-block" type="info" />
<u-tag
v-for="(value, key) in item.p_drugs"
v-if="key < 5"
:key="key"
:text="value.toString()"
style="margin: 10rpx; display: inline-block"
type="info"
/>
<d-text v-if="item.drugs_count >= 5" style="margin: 10rpx; display: inline-block" :text="'等,共:' + item.drugs_count+' 个'"></d-text>
</view>
<view class="flex-row flex-jus-sp flex-ali-centerc m-t-16">

View File

@@ -1,5 +1,6 @@
<template>
<business-page-layout title="我的" :show-tabbar="true" :tab-index="1">
<!-- tab 顺序是工作台/功能/我的,我的必须是 2;写成 1 时点「功能」会被当成当前页直接 return -->
<business-page-layout title="我的" :show-tabbar="true" :tab-index="2">
<!-- 页面主体统一收进 admin-my-panel(资料区 + 个人中心入口 + 菜单卡 + 退出) -->
<admin-my-panel
ref="myPanel"

View File

@@ -1,5 +1,6 @@
<template>
<business-page-layout title="我的" :show-tabbar="true" :tab-index="1">
<!-- tab 顺序是工作台/功能/我的,我的必须是 2;写成 1 时点「功能」会被当成当前页直接 return -->
<business-page-layout title="我的" :show-tabbar="true" :tab-index="2">
<!-- 页面主体统一收进 admin-my-panel(资料区 + 个人中心入口 + 菜单卡 + 退出) -->
<admin-my-panel
ref="myPanel"

View File

@@ -1,9 +1,16 @@
<template>
<view>
<!-- #ifdef MP-WEIXIN -->
<page-container v-if="pageGuard" :show="show" :overlay="false" @leave="close" @afterleave="releasePageGuard" />
<!-- pageGuard(v-if) 与 panelShow(:show) 拆开:关弹窗先离场再卸,二次打开才能再拦返回 -->
<page-container
v-if="pageGuard"
:show="panelShow"
:overlay="false"
@leave="close"
@afterleave="releasePageGuard"
/>
<!-- #endif -->
<u-popup :value="show" mode="center" border-radius="24" :mask-close-able="true" @input="onPopupInput" @close="close">
<u-popup :value="panelShow" mode="center" border-radius="24" :mask-close-able="true" @input="onPopupInput" @close="close">
<view class="claim">
<view class="claim__header">
<text class="claim__title">就诊人认领码</text>
@@ -32,6 +39,7 @@
/**
* 就诊人认领码弹窗(医生创建未认领就诊人)
* 取值:xk-api unwrapXkApi
* 返回拦截对齐切换账号:pageGuard / panelShow 拆分 + 关后 350ms 再卸挂载
*/
import { getUserPatientClaimQrcodeApi } from '@/api/reception.js'
import { unwrapXkApi } from '@/utils/api-response.js'
@@ -50,43 +58,67 @@ export default {
patientName: '',
errorText: '',
pageGuard: false,
panelShow: false,
releaseTimer: null,
localPath: '',
}
},
computed: {
show: {
get() {
return this.value
},
set(v) {
this.$emit('input', v)
},
},
},
watch: {
value(val) {
if (val) {
this.pageGuard = true
this.load()
}
/**
* 打开:先挂 pageGuard,nextTick 再 panelShow;关闭:先卸 show,350ms 后再卸挂载
*/
value: {
immediate: true,
handler(val) {
if (val) {
this.clearReleaseTimer()
this.pageGuard = true
this.$nextTick(() => {
this.panelShow = true
})
this.load()
} else {
this.panelShow = false
this.scheduleReleaseGuard()
}
},
},
},
beforeDestroy() {
this.cleanupLocal()
if (this.releaseTimer) clearTimeout(this.releaseTimer)
this.clearReleaseTimer()
},
methods: {
onPopupInput(v) {
this.show = v
if (!v) this.close()
},
/** 关闭弹窗:先关 show,再延迟卸 pageGuard,并同步 v-model */
close() {
this.show = false
this.panelShow = false
this.scheduleReleaseGuard()
if (this.value) {
this.$emit('input', false)
}
this.cleanupLocal()
},
releasePageGuard() {
this.clearReleaseTimer()
this.pageGuard = false
},
/** 离场动画约 300ms,延迟卸挂载避免二次侧滑失效 */
scheduleReleaseGuard() {
this.clearReleaseTimer()
this.releaseTimer = setTimeout(() => {
this.pageGuard = false
this.releaseTimer = null
}, 350)
},
clearReleaseTimer() {
if (this.releaseTimer) {
clearTimeout(this.releaseTimer)
this.releaseTimer = null
}
},
cleanupLocal() {
if (this.localPath) {
unlinkLocalFile(this.localPath)

View File

@@ -53,8 +53,9 @@
* 用途:本次挂号处方存在待支付订单时,医生出示小程序码给患者扫码代付
* (复用 PC 接诊台同款后端 proxy-pay-qrcode,scene=ppay={orderId},患者端解析链路零改动)。
* 展示:接口返回 data URI,写入 USER_DATA_PATH 再绑 <image>,不上传 OSS。
* 必须放主包 components/:easycom 会把 <proxy-pay-qrcode-modal> 解析成 /components/proxy-pay-qrcode-modal,
* 源码若只在分包,发行产物对不上会报 ENOENT(找不到 wxml/wxss)。lazyCodeLoading 按页注入,不常驻主包。
* 现放在 sub_workbench 分包 components/,pages.json easycom 指向
* @/subPackages/sub_workbench/components/proxy-pay-qrcode-modal/...
* lazyCodeLoading 按页注入,不常驻主包。
* 读值:xk-api 形态,拦截器已返回 body → code / result / message(unwrapXkApi)
*/
import { getProxyPayQrcodeApi } from '@/api/reception.js'

View File

@@ -71,7 +71,7 @@
*/
import { createUserPatientApi } from '@/api/reception.js'
import { unwrapXkApi } from '@/utils/api-response.js'
import ClaimQrcodeModal from '@/components/claim-qrcode-modal/claim-qrcode-modal.vue'
import ClaimQrcodeModal from '@/subPackages/sub_workbench/components/claim-qrcode-modal/claim-qrcode-modal.vue'
function buildEmptyForm() {
return {

View File

@@ -41,7 +41,7 @@ import {
startOfflineReceptionByPatientApi,
} from '@/api/reception.js'
import { unwrapXkApi } from '@/utils/api-response.js'
import ClaimQrcodeModal from '@/components/claim-qrcode-modal/claim-qrcode-modal.vue'
import ClaimQrcodeModal from '@/subPackages/sub_workbench/components/claim-qrcode-modal/claim-qrcode-modal.vue'
const STATUS_MAP = {
0: '待支付',

View File

@@ -0,0 +1,170 @@
<template>
<view class="page">
<!-- 医生识图传方:进页直接开抽屉,主题与开方页统一 -->
<view v-if="!showOcr" class="gate">
<text class="gate-tip">正在打开识图传方…</text>
<u-button type="primary" :custom-style="btnStyle" @click="openOcr">上传处方图识别</u-button>
</view>
<ImageOcrDrawer
v-model="showOcr"
mode="transfer"
chrome-mode="embedded"
:store-id="storeId"
:quote-api="quoteApi"
:process-rule-list-api="processRuleListApi"
@confirm-transfer="onConfirmTransfer"
@closed="flushPendingNav"
/>
</view>
</template>
<script>
/**
* 医生识图传方页:全量 OCR → 确认 → 待导入传方单 → 跳接诊
* 返回拦截由 ImageOcrDrawer 内置 page-container 处理
*/
import ImageOcrDrawer from '../prescription_v2/components/ImageOcrDrawer.vue';
import { confirmTransferImageOcrApi } from '@/api/prescriptionImageOcr.js';
import { quoteMyImageTransferOrder } from '@/api/imageTransferOrder.js';
import { getProcessRuleList } from '@/api/reception.js';
import { unwrapXkApi } from '@/utils/api-response.js';
export default {
name: 'ImageTransferPrescription',
components: { ImageOcrDrawer },
data() {
return {
showOcr: false,
storeId: 0,
pendingNav: null,
/** 注入抽屉:医生报价 + 委托调剂级联 */
quoteApi: quoteMyImageTransferOrder,
processRuleListApi: getProcessRuleList,
btnStyle: {
height: '88rpx',
fontSize: '30rpx',
fontWeight: '600',
backgroundColor: '#6ACDBB',
borderColor: '#6ACDBB',
color: '#fff',
borderRadius: '16rpx',
},
};
},
onLoad() {
this.storeId = Number(uni.getStorageSync('store_id') || 0);
},
onReady() {
this.openOcr();
},
methods: {
openOcr() {
this.showOcr = true;
},
flushPendingNav() {
const fn = this.pendingNav;
this.pendingNav = null;
if (typeof fn === 'function') {
fn();
return;
}
uni.navigateBack({ fail: () => {} });
},
/**
* 解包跨组件原生 emit 参数。
* 微信小程序可能把 $emit('confirm-transfer', payload) 包成原生事件,
* 业务数据在 detail.__args__[0];不解包直接 ...payload 会丢 patient_name 等字段。
*/
unwrapComponentEventPayload(payload) {
if (payload && payload.detail && Array.isArray(payload.detail.__args__)) {
return payload.detail.__args__[0];
}
if (payload && Array.isArray(payload.__args__)) {
return payload.__args__[0];
}
return payload;
},
/** 确认识图传方:解包后显式组字段,禁止 spread 事件对象 */
async onConfirmTransfer(e) {
const data = this.unwrapComponentEventPayload(e) || {};
uni.showLoading({ title: '提交中' });
try {
// patient_mobile 优先 _txt:拦截器解密后可能写出明文,避免密文/脱敏串被再次加密
const res = await confirmTransferImageOcrApi({
patient_name: data.patient_name,
patient_mobile: data.patient_mobile_txt || data.patient_mobile,
patient_address: data.patient_address,
sex: data.sex,
age: data.age,
user_patient_id: data.user_patient_id,
clinical_diagnose: data.clinical_diagnose,
medical_advice: data.medical_advice,
doctor_order: data.doctor_order || data.medical_advice,
dosage: data.dosage,
day_dosage: data.day_dosage,
image_urls: data.image_urls,
drugs: data.drugs,
rule_type: data.rule_type,
process_rule_id: data.process_rule_id,
child_process_rule_id: data.child_process_rule_id,
process_rule_note_id: data.process_rule_note_id,
package_method_id: data.package_method_id,
store_id: this.storeId || undefined,
});
const { ok, payload: result, message } = unwrapXkApi(res);
if (!ok || !result) {
uni.showToast({ title: message || '失败', icon: 'none' });
return;
}
const transferId = Number(result.transfer_id || 0);
const registerId = Number(result.register_id || 0);
if (result.next === 'order' || Number(result.order_id || 0) > 0) {
uni.showToast({ title: result.status_text || '已提交传方订单', icon: 'none' });
this.pendingNav = () => {
uni.redirectTo({
url: '/subPackages/sub_image_transfer/order-list?scope=mine',
});
};
this.showOcr = false;
return;
}
if (!transferId || !registerId) {
uni.showToast({ title: '传方生成失败', icon: 'none' });
return;
}
uni.setStorageSync('doctor_image_transfer_import', JSON.stringify({
transfer_id: transferId,
register_id: registerId,
}));
uni.showToast({ title: '已生成,进入接诊', icon: 'none' });
this.pendingNav = () => {
uni.navigateTo({
url: `/subPackages/sub_workbench/prescription_v2/index?register_id=${registerId}&transfer_id=${transferId}&initial_category=1`,
});
};
this.showOcr = false;
} catch (err) {
uni.showToast({ title: (err && err.message) || '失败', icon: 'none' });
} finally {
uni.hideLoading();
}
},
},
};
</script>
<style scoped>
.page {
min-height: 100vh;
background: #f4f6f8;
}
.gate {
padding: 48rpx 32rpx;
}
.gate-tip {
display: block;
margin-bottom: 24rpx;
font-size: 28rpx;
color: #6b7280;
}
</style>

View File

@@ -118,13 +118,14 @@
<text class="fs-20 color-sub">{{ m.doseUnit }}{{ m.usageText }}</text>
<view class="flex-row flex-wrap m-t-8">
<view
v-for="c in m.candidates"
v-for="(c, ci) in m.candidates"
:key="c.drug_id"
class="ai-cand ai-cand--sm"
:class="{ 'ai-cand--on': m.selected_drug_id == c.drug_id }"
:data-mi="m.mi"
:data-drug-id="c.drug_id"
@click="onPickCandidateTap"
hover-class="ai-cand--hover"
@tap.stop="onPickCandidateTap"
>
<text class="fs-18">{{ c.drug_name }}</text>
</view>
@@ -149,13 +150,14 @@
</view>
<view class="flex-row flex-wrap m-t-8">
<view
v-for="c in m.candidates"
v-for="(c, ci) in m.candidates"
:key="c.drug_id"
class="ai-cand ai-cand--sm"
:class="{ 'ai-cand--on': m.selected_drug_id == c.drug_id }"
:data-mi="m.mi"
:data-drug-id="c.drug_id"
@click="onPickCandidateTap"
hover-class="ai-cand--hover"
@tap.stop="onPickCandidateTap"
>
<text class="fs-20">{{ c.drug_name }}</text>
</view>
@@ -315,22 +317,16 @@
<view class="ai-gen-scroll-pad" />
</scroll-view>
</view>
<!-- 二级:语音开方面板(录音转写 + A/B 卡片,不用第二个 page-container) -->
<!-- 二级:语音开方面板(内容可滚;按住说话固定底栏) -->
<view v-else-if="!consentVisible && voiceModalVisible" class="ai-gen-panel">
<view class="ai-gen-hd flex-row flex-jus-sp flex-ali-center">
<text class="fs-30 font-bold">语音开方</text>
<text class="ai-rx-sub" @click="closeVoiceModal">返回</text>
</view>
<scroll-view scroll-y class="ai-gen-scroll">
<view class="ai-voice-rec flex-row flex-ali-center">
<view
class="ai-btn ai-btn--ghost"
:class="{ 'ai-btn--recording': voiceRecording }"
@click="onToggleVoiceRecord"
>
{{ voiceAsrLoading ? '转写中…' : (voiceRecording ? '点击结束录音' : '点击开始录音') }}
</view>
<text class="fs-22 color-sub m-l-16">说完后点结束,可改字</text>
<view class="ai-voice-tip">
<u-icon name="mic" color="#6ACDBB" size="28"></u-icon>
<text class="ai-voice-tip__text">底部按住口述药方,松手后自动转写,可改字</text>
</view>
<view class="ai-gen-row ai-gen-row--full m-t-16">
<text class="ai-gen-label">转写文本(可改)</text>
@@ -389,7 +385,7 @@
</view>
</view>
</view>
<view class="ai-gen-scroll-pad" />
<view class="ai-gen-scroll-pad ai-gen-scroll-pad--voice" />
</scroll-view>
</view>
<view class="ai-rx-footer safe-area-inset-bottom">
@@ -399,10 +395,49 @@
{{ consentSubmitting ? '提交中…' : '我已知悉并同意' }}
</view>
</view>
<view v-else-if="voiceModalVisible" class="ai-actions">
<view class="ai-btn ai-btn--ghost" @click="closeVoiceModal">返回</view>
<view class="ai-btn ai-btn--primary" @click="onConfirmVoiceGenerate">
{{ generating ? '生成中…' : '确认生成' }}
<view v-else-if="voiceModalVisible" class="ai-voice-dock">
<view class="ai-actions">
<view class="ai-btn ai-btn--ghost" @click="closeVoiceModal">返回</view>
<view class="ai-btn ai-btn--primary" @click="onConfirmVoiceGenerate">
{{ generating ? '生成中…' : '确认生成' }}
</view>
</view>
<!-- 录音/转写时:波形动画贴在按住按钮上方,不挡中间内容 -->
<view v-if="voiceRecording || voiceAsrLoading" class="ai-voice-wave">
<view class="ai-voice-wave__bars">
<view
v-for="n in voiceWaveBars"
:key="n"
class="ai-voice-wave__bar"
:class="['ai-voice-wave__bar--' + n, voiceAsrLoading ? 'is-paused' : '']"
></view>
</view>
<text class="ai-voice-wave__hint">
{{ voiceAsrLoading ? '正在转写…' : '正在聆听,松开发送' }}
</text>
</view>
<view
class="ai-voice-hold"
:class="{
'ai-voice-hold--on': voiceRecording,
'ai-voice-hold--busy': voiceAsrLoading
}"
@touchstart.prevent="onVoiceTouchStart"
@touchend.prevent="onVoiceTouchEnd"
@touchcancel.prevent="onVoiceTouchCancel"
@touchmove.stop.prevent="onVoiceTouchMove"
>
<view v-if="voiceRecording" class="ai-voice-hold__pulse ai-voice-hold__pulse--1"></view>
<view v-if="voiceRecording" class="ai-voice-hold__pulse ai-voice-hold__pulse--2"></view>
<u-icon
:name="voiceAsrLoading ? 'reload' : 'mic'"
:color="voiceRecording ? '#EF4444' : '#6ACDBB'"
size="28"
class="ai-voice-hold__mic"
></u-icon>
<text class="ai-voice-hold__text">
{{ voiceAsrLoading ? '转写中…' : (voiceRecording ? '松开发送' : '按住说话') }}
</text>
</view>
</view>
<view v-else-if="!genModalVisible" class="ai-actions">
@@ -489,6 +524,8 @@ export default {
voiceAsrLoading: false,
voiceTranscript: '',
voiceTranscriptTrimmed: '',
/** 录音波形柱(固定 7 根,CSS 错峰动画) */
voiceWaveBars: [1, 2, 3, 4, 5, 6, 7],
/** dialectic | dictation;默认口述落方 */
voiceMode: 'dictation',
voiceAsrProvider: '',
@@ -609,6 +646,7 @@ export default {
* 重置语音开方状态并停止录音
*/
resetVoiceState() {
this._voiceFingerDown = false
this.stopVoiceRecording(true)
this.voiceAsrLoading = false
this.voiceTranscript = ''
@@ -1128,15 +1166,25 @@ export default {
}
},
/**
* 确保 RecorderManager 已初始化(mp3 16k,适配讯飞 lame)
* 确保 RecorderManager 已初始化(wav 16k,适配讯飞 encoding=raw)
* 为何不用 mp3:服务端按 1280B 切帧会破坏 MP3 帧 → 讯飞 10043
* 为何改用 wav 而非 PCM:开发者工具/部分机型对 PCM 不稳定,wav 有明确 RIFF 头,服务端会剥头
*/
ensureRecorder() {
if (this._voiceRecorder) return this._voiceRecorder
const rm = uni.getRecorderManager()
rm.onStop((res) => {
const discard = !!this._voiceDiscard
this._voiceDiscard = false
this.voiceRecording = false
if (discard) return
const path = (res && res.tempFilePath) || ''
const duration = Number((res && res.duration) || 0)
// 与微信/聊天页一致:不足 1 秒不调 ASR
if (duration > 0 && duration < 1000) {
uni.showToast({ title: '说话时间太短', icon: 'none' })
return
}
if (!path) {
uni.showToast({ title: '未录到有效音频', icon: 'none' })
return
@@ -1144,65 +1192,118 @@ export default {
this.handleVoiceAsr(path, duration)
})
rm.onError(() => {
this._voiceDiscard = false
this.voiceRecording = false
uni.showToast({ title: '录音失败', icon: 'none' })
})
this._voiceRecorder = rm
return rm
},
onToggleVoiceRecord() {
if (this.voiceAsrLoading) return
if (this.voiceRecording) {
this.stopVoiceRecording(false)
return
}
uni.authorize({
scope: 'scope.record',
success: () => this.startVoiceRecording(),
/**
* 按住说话:touchstart 开录(对齐 chat.vue)
* 用 _voiceFingerDown 避免授权弹窗回来时手指已松开仍开录
*/
onVoiceTouchStart() {
if (this.voiceAsrLoading || this.voiceRecording) return
this._voiceDiscard = false
this._voiceFingerDown = true
uni.getSetting({
success: (res) => {
if (!this._voiceFingerDown) return
const auth = res && res.authSetting ? res.authSetting['scope.record'] : false
if (auth) {
this.startVoiceRecording()
return
}
uni.authorize({
scope: 'scope.record',
success: () => {
if (!this._voiceFingerDown) return
this.startVoiceRecording()
},
fail: () => {
this._voiceFingerDown = false
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启麦克风权限后重试',
success: (r) => {
if (r.confirm) uni.openSetting({})
},
})
},
})
},
fail: () => {
uni.showModal({
title: '需要录音权限',
content: '请在设置中开启麦克风权限后重试',
success: (r) => {
if (r.confirm) uni.openSetting({})
if (!this._voiceFingerDown) return
uni.authorize({
scope: 'scope.record',
success: () => {
if (!this._voiceFingerDown) return
this.startVoiceRecording()
},
fail: () => {
this._voiceFingerDown = false
uni.showToast({ title: '需要录音权限', icon: 'none' })
},
})
},
})
},
/**
* 松手结束:触发 onStop → ASR
*/
onVoiceTouchEnd() {
this._voiceFingerDown = false
if (!this.voiceRecording) return
this.stopVoiceRecording(false)
},
/**
* 触摸取消:丢弃本次录音
*/
onVoiceTouchCancel() {
this._voiceFingerDown = false
if (!this.voiceRecording) return
this.stopVoiceRecording(true)
},
/**
* 按住时吞掉 move,避免 scroll-view 抢手势导致误停
*/
onVoiceTouchMove() {
/* prevent scroll steal */
},
startVoiceRecording() {
if (this.voiceAsrLoading || this.voiceRecording) return
const rm = this.ensureRecorder()
this._voiceDiscard = false
this.voiceRecording = true
// wav:16k 单声道;服务端剥 RIFF 后按 raw PCM 推讯飞(勿用 mp3)
rm.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3',
encodeBitRate: 48000,
format: 'wav',
})
},
/**
* @param {boolean} discard 丢弃本次录音(关闭面板时)
* @param {boolean} discard 丢弃本次录音(关闭面板 / touchcancel)
*/
stopVoiceRecording(discard) {
if (!this.voiceRecording && !this._voiceRecorder) return
if (discard && this._voiceRecorder) {
// 丢弃:先卸 onStop 再 stop,避免触发转写
try {
this._voiceRecorder.onStop(() => {})
} catch (e) {
/* ignore */
}
try {
this._voiceRecorder.stop()
} catch (e2) {
/* ignore */
if (discard) {
this._voiceDiscard = true
if (this._voiceRecorder) {
try {
this._voiceRecorder.stop()
} catch (e2) {
/* ignore */
}
}
this.voiceRecording = false
this._voiceRecorder = null
return
}
if (this.voiceRecording && this._voiceRecorder) {
this._voiceDiscard = false
this._voiceRecorder.stop()
}
},
@@ -1212,6 +1313,7 @@ export default {
const res = await aiVoiceAsrApi({
filePath,
duration_ms: durationMs || 0,
audio_format: 'wav',
})
// upload 拦截器可能直接返回字符串 JSON
let body = res
@@ -2004,8 +2106,133 @@ export default {
border-color: #ef4444 !important;
color: #ef4444 !important;
}
.ai-voice-rec {
padding: 8rpx 0;
.ai-voice-tip {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx 20rpx;
border-radius: 12rpx;
background: rgba(106, 205, 187, 0.08);
border: 1rpx solid rgba(106, 205, 187, 0.28);
}
.ai-voice-tip__text {
margin-left: 12rpx;
font-size: 24rpx;
color: #64748b;
line-height: 1.4;
flex: 1;
}
/* 语音底栏:确认行 + 波形 + 按住说话(贴底,不占中间) */
.ai-voice-dock {
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
}
.ai-voice-dock .ai-actions {
margin-bottom: 16rpx;
}
.ai-voice-wave {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12rpx;
min-height: 72rpx;
}
.ai-voice-wave__bars {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
height: 48rpx;
}
.ai-voice-wave__bar {
width: 8rpx;
margin: 0 5rpx;
border-radius: 8rpx;
background: #6acdbb;
height: 16rpx;
animation: ai-voice-bar 0.9s ease-in-out infinite;
transform-origin: center bottom;
}
.ai-voice-wave__bar.is-paused {
animation: none;
height: 14rpx !important;
opacity: 0.55;
background: #94a3b8;
}
.ai-voice-wave__bar--1 { animation-delay: 0s; }
.ai-voice-wave__bar--2 { animation-delay: 0.1s; }
.ai-voice-wave__bar--3 { animation-delay: 0.2s; }
.ai-voice-wave__bar--4 { animation-delay: 0.05s; }
.ai-voice-wave__bar--5 { animation-delay: 0.25s; }
.ai-voice-wave__bar--6 { animation-delay: 0.15s; }
.ai-voice-wave__bar--7 { animation-delay: 0.3s; }
@keyframes ai-voice-bar {
0%, 100% { height: 12rpx; opacity: 0.55; }
50% { height: 44rpx; opacity: 1; }
}
.ai-voice-wave__hint {
margin-top: 8rpx;
font-size: 22rpx;
color: #ef4444;
}
.ai-voice-hold {
position: relative;
height: 92rpx;
border-radius: 46rpx;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background: rgba(106, 205, 187, 0.12);
border: 1rpx solid rgba(106, 205, 187, 0.45);
}
.ai-voice-hold--on {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.5);
}
.ai-voice-hold--busy {
opacity: 0.65;
}
.ai-voice-hold__mic {
margin-right: 10rpx;
position: relative;
z-index: 1;
}
.ai-voice-hold__text {
position: relative;
z-index: 1;
font-size: 30rpx;
font-weight: 600;
color: #6acdbb;
}
.ai-voice-hold--on .ai-voice-hold__text {
color: #ef4444;
}
/* 录音时按钮内双圈脉冲 */
.ai-voice-hold__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 120rpx;
height: 120rpx;
margin-left: -60rpx;
margin-top: -60rpx;
border-radius: 50%;
border: 2rpx solid rgba(239, 68, 68, 0.35);
pointer-events: none;
animation: ai-voice-pulse 1.4s ease-out infinite;
}
.ai-voice-hold__pulse--2 {
animation-delay: 0.7s;
}
@keyframes ai-voice-pulse {
0% { transform: scale(0.55); opacity: 0.7; }
100% { transform: scale(2.1); opacity: 0; }
}
.ai-gen-scroll-pad--voice {
height: 48rpx;
}
.ai-match-name {
flex: 1;
@@ -2018,15 +2245,23 @@ export default {
.ai-cand {
border: 1px solid #ddd;
border-radius: 8rpx;
padding: 8rpx 12rpx;
padding: 12rpx 18rpx;
min-height: 56rpx;
box-sizing: border-box;
display: inline-flex;
align-items: center;
margin-right: 12rpx;
margin-bottom: 8rpx;
}
.ai-cand--sm {
padding: 4rpx 10rpx;
padding: 12rpx 16rpx;
min-height: 52rpx;
margin-right: 8rpx;
margin-bottom: 8rpx;
}
.ai-cand--hover {
opacity: 0.75;
}
.ai-cand--on {
border-color: #6acdbb;
background: #e8f8f4;

View File

@@ -1,151 +1,171 @@
<template>
<view class="chinese-medicine-config">
<!-- 制剂方式 -->
<view class="config-section m-b-32">
<view class="config-section m-b-32" :class="{ 'sec-error': errorMap.ruleType }">
<view class="section-title m-b-16">
<d-text text="制剂方式" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="section-content">
<view class="modern-pill-group">
<view
class="modern-pill"
:class="{ active: localConfig.ruleType === 1, disabled: disabled }"
@click="!disabled && handleRuleTypeChange(1)"
>
自制剂
</view>
class="modern-pill"
:class="{ active: Number(localConfig.ruleType) === 1, disabled: disabled }"
data-rule="1"
@click="onRuleTypeClick"
>自制剂</view>
<view
class="modern-pill"
:class="{ active: localConfig.ruleType === 2, disabled: disabled }"
@click="!disabled && handleRuleTypeChange(2)"
>
委托调剂
</view>
class="modern-pill"
:class="{ active: Number(localConfig.ruleType) === 2, disabled: disabled }"
data-rule="2"
@click="onRuleTypeClick"
>委托调剂</view>
</view>
</view>
<text v-if="errorMap.ruleType" class="err-tip">请选择制剂方式</text>
</view>
<!-- 自制剂配置 -->
<view class="config-section border-t-dashed pt-32 m-b-32" v-if="localConfig.ruleType === 1">
<!-- 自制剂:包法 -->
<view
v-if="Number(localConfig.ruleType) === 1"
class="config-section border-t-dashed pt-32 m-b-32"
:class="{ 'sec-error': errorMap.packageMethodId }"
>
<view class="section-title m-b-16">
<d-text text="包法选择" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="section-content">
<view class="modern-pill-group">
<view
v-for="item in packageMethodList"
:key="item.id"
class="modern-pill outline"
:class="{ active: localConfig.packageMethodId === item.id, disabled: disabled }"
@click="!disabled && handlePackageMethodTagClick(item)"
>
{{ item.name }}
</view>
v-for="item in packageMethodList"
:key="item.id"
class="modern-pill outline"
:class="{ active: Number(localConfig.packageMethodId) === Number(item.id), disabled: disabled }"
:data-id="item.id"
@click="onPackageMethodClick"
>{{ item.name }}</view>
</view>
</view>
<text v-if="errorMap.packageMethodId" class="err-tip">请选择包法</text>
</view>
<!-- 委托调剂配置 -->
<view class="config-section border-t-dashed pt-32" v-if="localConfig.ruleType === 2">
<view class="section-title m-b-16">
<d-text text="制剂要求" className="fs-30 font-bold color-title"></d-text>
</view>
<view class="section-content m-b-24">
<view class="modern-pill-group">
<view
<!-- 委托调剂 -->
<view v-if="Number(localConfig.ruleType) === 2" class="config-section border-t-dashed pt-32">
<view class="field-block" :class="{ 'sec-error': errorMap.processRuleId }">
<view class="section-title m-b-16">
<d-text text="制剂要求" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="section-content m-b-8">
<view class="modern-pill-group">
<view
v-for="item in processRuleList"
:key="item.id"
class="modern-pill outline"
:class="{ active: localConfig.processRuleId === item.id, disabled: disabled }"
@click="!disabled && handleProcessRuleTagClick(item)"
>
{{ item.name }}
:class="{ active: Number(localConfig.processRuleId) === Number(item.id), disabled: disabled }"
:data-id="item.id"
@click="onProcessRuleClick"
>{{ item.name }}</view>
</view>
</view>
<text v-if="errorMap.processRuleId" class="err-tip">请选择制剂要求</text>
</view>
<view class="section-title m-b-16 m-t-24">
<d-text text="煎法说明" className="fs-30 font-bold color-title"></d-text>
</view>
<view class="section-content m-b-24">
<view class="modern-pill-group">
<view
<view class="field-block m-t-24" :class="{ 'sec-error': errorMap.childProcessRuleId }">
<view class="section-title m-b-16">
<d-text text="煎法说明" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="section-content m-b-8">
<view class="modern-pill-group">
<view
v-for="item in childProcessRuleList"
:key="item.id"
class="modern-pill outline"
:class="{ active: localConfig.childProcessRuleId === item.id, disabled: disabled }"
@click="!disabled && handleChildProcessRuleTagClick(item)"
>
{{ item.name }}
:class="{ active: Number(localConfig.childProcessRuleId) === Number(item.id), disabled: disabled }"
:data-id="item.id"
@click="onChildProcessRuleClick"
>{{ item.name }}</view>
</view>
</view>
<text v-if="errorMap.childProcessRuleId" class="err-tip">请选择煎法说明</text>
</view>
<view class="section-title m-b-16 m-t-24">
<d-text text="附加备注" className="fs-30 font-bold color-title"></d-text>
</view>
<view class="section-content m-b-32">
<view class="modern-pill-group">
<view
<view class="field-block m-t-24 m-b-32" :class="{ 'sec-error': errorMap.processRuleNoteId }">
<view class="section-title m-b-16">
<d-text text="附加备注" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="section-content m-b-8">
<view class="modern-pill-group">
<view
v-for="item in processRuleNoteList"
:key="item.id"
class="modern-pill outline"
:class="{ active: localConfig.processRuleNoteId === item.id, disabled: disabled }"
@click="!disabled && handleProcessRuleNoteTagClick(item)"
>
{{ item.note || item.name }}
:class="{ active: Number(localConfig.processRuleNoteId) === Number(item.id), disabled: disabled }"
:data-id="item.id"
@click="onProcessRuleNoteClick"
>{{ item.note || item.name }}</view>
</view>
</view>
<text v-if="errorMap.processRuleNoteId" class="err-tip">请选择附加备注</text>
</view>
</view>
<!-- 用量和频次:与 WesternUsageModal 保持一致的表单布局 -->
<!-- 用量 -->
<view class="config-form border-t-dashed pt-20">
<view class="form-item flex-row flex-jus-sp flex-ali-center p-y-24">
<view class="item-label">
<view class="form-item flex-row flex-jus-sp flex-ali-center p-y-24" :class="{ 'sec-error': errorMap.dosage }">
<view class="item-label flex-row flex-ali-center">
<d-text text="剂数" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="item-content flex-row flex-ali-center">
<u-number-box
v-model="localConfig.dosage"
:disabled="disabled"
:min="1"
:input-width="80"
:input-height="60"
bg-color="#F7F8FA"
v-model="localConfig.dosage"
:disabled="disabled"
:min="1"
:input-width="80"
:input-height="60"
bg-color="#F7F8FA"
/>
<d-text text="剂(天)" className="fs-28 color-sub m-l-16"></d-text>
</view>
</view>
<text v-if="errorMap.dosage" class="err-tip err-tip-form">请填写剂数</text>
<view class="form-item flex-row flex-jus-sp flex-ali-center p-y-24">
<view class="item-label">
<view class="form-item flex-row flex-jus-sp flex-ali-center p-y-24" :class="{ 'sec-error': errorMap.dayDosage }">
<view class="item-label flex-row flex-ali-center">
<d-text text="用药频次" className="fs-30 font-bold color-title"></d-text>
<text class="req-star">*</text>
</view>
<view class="item-content flex-row flex-ali-center">
<u-number-box
v-model="localConfig.dayDosage"
:disabled="disabled"
:min="1"
:input-width="80"
:input-height="60"
bg-color="#F7F8FA"
v-model="localConfig.dayDosage"
:disabled="disabled"
:min="1"
:input-width="80"
:input-height="60"
bg-color="#F7F8FA"
/>
<d-text text="次/天" className="fs-28 color-sub m-l-16"></d-text>
</view>
</view>
<text v-if="errorMap.dayDosage" class="err-tip err-tip-form">请填写用药频次</text>
</view>
</view>
</template>
<script>
// 保留原有逻辑
/**
* 中药代煎/用量配置
* - 点击一律走 methods + dataset-id(微信模板勿写 !disabled && fn(item))
* - 跨分包时父页需解包 update:config(detail.__args__[0])
* - syncingFromParent:父 props 回写时不二次 emit,避免点选被冲掉
*/
export default {
name: 'ChineseMedicineConfig',
props: {
/**
* 中药配置
*/
config: {
type: Object,
default: () => ({
@@ -155,175 +175,134 @@ export default {
childProcessRuleId: null,
processRuleNoteId: null,
dosage: 7,
dayDosage: 2
})
dayDosage: 2,
}),
},
/**
* 制剂列表
*/
processRuleList: {
type: Array,
default: () => []
},
/**
* 煎法列表
*/
childProcessRuleList: {
type: Array,
default: () => []
},
/**
* 备注列表
*/
processRuleNoteList: {
type: Array,
default: () => []
},
/**
* 特色方锁定后禁止修改配置
*/
disabled: {
type: Boolean,
default: false
}
processRuleList: { type: Array, default: () => [] },
childProcessRuleList: { type: Array, default: () => [] },
processRuleNoteList: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
/** 未填字段名;父页提交失败传入,改选后由父清空 */
errorKeys: { type: Array, default: () => [] },
},
data() {
return {
localConfig: {},
localConfig: {
ruleType: 2,
packageMethodId: null,
processRuleId: null,
childProcessRuleId: null,
processRuleNoteId: null,
dosage: 7,
dayDosage: 2,
},
/** 正在被父 props 覆盖,跳过 emit,打断回写回路 */
syncingFromParent: false,
packageMethodList: [
{ id: 1, name: '味包' },
{ id: 2, name: '剂包' }
]
{ id: 2, name: '剂包' },
],
};
},
computed: {
errorMap() {
const map = {
ruleType: false,
packageMethodId: false,
processRuleId: false,
childProcessRuleId: false,
processRuleNoteId: false,
dosage: false,
dayDosage: false,
};
const keys = this.errorKeys || [];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (Object.prototype.hasOwnProperty.call(map, k)) map[k] = true;
}
return map;
},
},
watch: {
config: {
handler(newVal) {
this.localConfig = { ...newVal };
if (!newVal || typeof newVal !== 'object') return;
this.syncingFromParent = true;
this.localConfig = Object.assign({}, this.localConfig, newVal);
this.$nextTick(() => {
this.syncingFromParent = false;
});
},
immediate: true,
deep: true
deep: true,
},
localConfig: {
handler(newVal) {
this.$emit('update:config', { ...newVal });
if (this.syncingFromParent) return;
this.$emit('update:config', Object.assign({}, newVal));
},
deep: true
}
deep: true,
},
},
methods: {
/**
* 处理制剂方式改变
* @param {number} value - 制剂方式:1=自制剂,2=委托调剂
* 职责:更新制剂方式,触发配置更新
*/
handleRuleTypeChange(value) {
datasetNum(e, key) {
const ds = (e && e.currentTarget && e.currentTarget.dataset) || {};
return Number(ds[key] || 0);
},
onRuleTypeClick(e) {
if (this.disabled) return;
const value = this.datasetNum(e, 'rule');
if (value !== 1 && value !== 2) return;
this.localConfig.ruleType = value;
// 切换时清空相关配置
if (value === 1) {
// 自制剂:清空委托调剂相关配置,默认选择第一个包法
this.localConfig.processRuleId = null;
this.localConfig.childProcessRuleId = null;
this.localConfig.processRuleNoteId = null;
// 如果包法未选择,默认选择第一个
if (!this.localConfig.packageMethodId && this.packageMethodList.length > 0) {
if (!this.localConfig.packageMethodId && this.packageMethodList.length) {
this.localConfig.packageMethodId = this.packageMethodList[0].id;
}
} else {
// 委托调剂:清空自制剂相关配置
this.localConfig.packageMethodId = null;
}
},
/**
* 处理包法改变(标签点击)
* @param {Object} item - 包法项
* 职责:更新包法ID
*/
handlePackageMethodTagClick(item) {
this.localConfig.packageMethodId = item.id;
onPackageMethodClick(e) {
if (this.disabled) return;
const id = this.datasetNum(e, 'id');
if (!id) return;
this.localConfig.packageMethodId = id;
},
/**
* 处理制剂改变(标签点击)
* @param {Object} item - 制剂项
* 职责:更新制剂ID,触发加载煎法列表
*/
handleProcessRuleTagClick(item) {
// 清空煎法和备注ID(级联清空)
this.localConfig.processRuleId = item.id;
onProcessRuleClick(e) {
if (this.disabled) return;
const id = this.datasetNum(e, 'id');
if (!id) return;
this.localConfig.processRuleId = id;
this.localConfig.childProcessRuleId = null;
this.localConfig.processRuleNoteId = null;
// localConfig 的变化会通过 watch 自动触发 @update:config
},
/**
* 处理煎法改变(标签点击)
* @param {Object} item - 煎法项
* 职责:更新煎法ID,触发加载备注列表
*/
handleChildProcessRuleTagClick(item) {
// 清空备注ID(级联清空)
this.localConfig.childProcessRuleId = item.id;
onChildProcessRuleClick(e) {
if (this.disabled) return;
const id = this.datasetNum(e, 'id');
if (!id) return;
this.localConfig.childProcessRuleId = id;
this.localConfig.processRuleNoteId = null;
// localConfig 的变化会通过 watch 自动触发 @update:config
},
/**
* 处理备注改变(标签点击)
* @param {Object} item - 备注项
* 职责:更新备注ID
*/
handleProcessRuleNoteTagClick(item) {
this.localConfig.processRuleNoteId = item.id;
onProcessRuleNoteClick(e) {
if (this.disabled) return;
const id = this.datasetNum(e, 'id');
if (!id) return;
this.localConfig.processRuleNoteId = id;
},
/**
* 获取包法名称
* @returns {string} 包法名称
*/
getPackageMethodName() {
const item = this.packageMethodList.find(item => item.id === this.localConfig.packageMethodId);
return item?.name || '';
},
/**
* 获取制剂名称
* @returns {string} 制剂名称
*/
getProcessRuleName() {
const item = this.processRuleList.find(item => item.id === this.localConfig.processRuleId);
return item?.name || '';
},
/**
* 获取煎法名称
* @returns {string} 煎法名称
*/
getChildProcessRuleName() {
const item = this.childProcessRuleList.find(item => item.id === this.localConfig.childProcessRuleId);
return item?.name || '';
},
/**
* 获取备注名称
* @returns {string} 备注名称
*/
getProcessRuleNoteName() {
const item = this.processRuleNoteList.find(item => item.id === this.localConfig.processRuleNoteId);
return item?.name || '';
}
}
},
};
</script>
<style lang="scss" scoped>
/* 统一样式 */
.color-title { color: #2A2E35; }
.color-sub { color: #8A92A3; }
.fs-28 { font-size: 28rpx; }
.fs-30 { font-size: 30rpx; }
.font-bold { font-weight: bold; }
.m-b-8 { margin-bottom: 8rpx; }
.m-b-16 { margin-bottom: 16rpx; }
.m-b-24 { margin-bottom: 24rpx; }
.m-b-32 { margin-bottom: 32rpx; }
@@ -333,7 +312,6 @@ export default {
.pt-32 { padding-top: 32rpx; }
.p-y-24 { padding: 24rpx 0; }
.border-t-dashed { border-top: 1px dashed #E2E8F0; }
.flex-row { display: flex; flex-direction: row; }
.flex-jus-sp { justify-content: space-between; }
.flex-ali-center { align-items: center; }
@@ -342,13 +320,40 @@ export default {
display: flex;
flex-direction: column;
}
.section-title {
display: flex;
align-items: center;
}
.req-star {
margin-left: 6rpx;
font-size: 28rpx;
color: #fa3534;
line-height: 1;
}
.sec-error {
padding: 16rpx;
margin-left: -16rpx;
margin-right: -16rpx;
border-radius: 16rpx;
border: 1px solid rgba(250, 53, 52, 0.45);
box-shadow: 0 0 8rpx rgba(250, 53, 52, 0.18);
background: rgba(254, 240, 240, 0.55);
}
.err-tip {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #fa3534;
}
.err-tip-form {
margin-top: -8rpx;
margin-bottom: 8rpx;
}
.modern-pill-group {
display: flex;
gap: 16rpx;
flex-wrap: wrap;
}
.modern-pill {
padding: 12rpx 36rpx;
background-color: #F4F6F8;
@@ -356,31 +361,25 @@ export default {
border-radius: 40rpx;
font-size: 26rpx;
text-align: center;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s;
&.active {
background-color: #00A88A;
color: #fff;
border-color: #00A88A;
font-weight: bold;
}
&.outline {
background-color: #fff;
border: 1px solid #E2E8F0;
&.active {
background-color: #E8F6F4;
color: #00A88A;
border-color: #00A88A;
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,309 @@
<template>
<!--
识图传方:就诊人信息独立模块
本地 form + v-model,避免小程序 u-input :value/@input 回写失败;
父组件确认时通过 getValues() 强制取最新值
模板禁止 ?? ?. 与 :class 方法调用
-->
<view class="patient-panel">
<view class="panel-head">
<text class="sec-title">就诊人信息</text>
<view class="search-btn" :class="{ disabled: searching }" @click="onSearchClick">
{{ searching ? '搜索中…' : '搜索匹配' }}
</view>
</view>
<view class="field">
<text class="label">姓名</text>
<u-input v-model="formName" placeholder="患者姓名" @blur="onBlurSearch" />
</view>
<view class="field">
<text class="label">手机号</text>
<u-input v-model="formMobile" type="number" maxlength="11" placeholder="11位手机号" @blur="onBlurSearch" />
</view>
<view class="field-row">
<view class="field-col">
<text class="label">性别</text>
<u-radio-group v-model="formSex">
<u-radio :name="1">男</u-radio>
<u-radio :name="2">女</u-radio>
</u-radio-group>
</view>
<view class="field-col">
<text class="label">年龄</text>
<u-input v-model="formAge" type="number" placeholder="年龄" />
</view>
</view>
<view class="field">
<text class="label">地址</text>
<u-input v-model="formAddress" placeholder="可后补" />
</view>
<view class="match-flag" :class="matchFlagClass">
{{ matchFlagText }}
</view>
</view>
</template>
<script>
/**
* 识图传方就诊人面板:从 ImageOcrDrawer 拆出,负责编辑 + 主动匹配
* 用本地 form 承接输入(微信小程序 v-model 更稳),再 change 回写父组件;
* 确认提交时父组件调 getValues() 取最新值,不依赖每次 input 是否同步成功
*/
import { unwrapXkApi } from '@/utils/api-response.js';
import { matchTransferPatientApi } from '@/api/prescriptionImageOcr.js';
export default {
name: 'TransferPatientPanel',
props: {
patientName: { type: String, default: '' },
patientMobile: { type: String, default: '' },
patientAddress: { type: String, default: '' },
patientSex: { type: [Number, String], default: 0 },
patientAge: { type: [Number, String], default: '' },
userPatientId: { type: [Number, String], default: 0 },
patientMatched: { type: Boolean, default: false },
/** 诊所管理员等传入专用匹配接口;默认医生 match-transfer-patient */
matchApi: { type: Function, default: null },
},
data() {
return {
/** 本地表单:真正绑定输入,避免 :value 单向绑定回写失败 */
formName: '',
formMobile: '',
formAddress: '',
formSex: 0,
formAge: '',
/** 匹配态也落本地,getValues 时与表单一并返回 */
formUserPatientId: 0,
formMatched: false,
searching: false,
/** 防抖:失焦后短延迟再搜,避免连点 */
blurTimer: null,
/** 上次已搜过的指纹,避免重复请求 */
lastSearchKey: '',
/** props→form 同步中,跳过 form watch 的 emit,避免回环 */
syncingFromProps: false,
};
},
computed: {
matchFlagClass() {
if (this.formMatched) return 'text-primary';
return 'text-warn';
},
matchFlagText() {
if (this.formMatched) {
return '已匹配到已有就诊人,提交将复用';
}
// 进接诊配置下确认时会自动建档;落传方订单时按填写信息提交
return '未匹配到已有就诊人,提交时将自动创建或按填写信息落单';
},
},
watch: {
/** OCR/父组件回填时同步进本地 form */
patientName: { immediate: true, handler(v) { this.applyPropToForm('formName', String(v == null ? '' : v)); } },
patientMobile: { immediate: true, handler(v) { this.applyPropToForm('formMobile', String(v == null ? '' : v)); } },
patientAddress: { immediate: true, handler(v) { this.applyPropToForm('formAddress', String(v == null ? '' : v)); } },
patientSex: { immediate: true, handler(v) { this.applyPropToForm('formSex', Number(v || 0)); } },
patientAge: { immediate: true, handler(v) { this.applyPropToForm('formAge', v == null ? '' : String(v)); } },
userPatientId: { immediate: true, handler(v) { this.applyPropToForm('formUserPatientId', Number(v || 0)); } },
patientMatched: { immediate: true, handler(v) { this.applyPropToForm('formMatched', !!v); } },
/** 本地表单变更回写父组件;改姓名/手机时清匹配态 */
formName() { this.onLocalIdentityChange(); },
formMobile() { this.onLocalIdentityChange(); },
formAddress() { this.emitFormChange(); },
formSex() { this.emitFormChange(); },
formAge() { this.emitFormChange(); },
},
beforeDestroy() {
if (this.blurTimer) {
clearTimeout(this.blurTimer);
this.blurTimer = null;
}
},
methods: {
/**
* props 同步进本地字段;标记 syncingFromProps 避免立刻再 emit 回父组件
*/
applyPropToForm(key, value) {
if (this[key] === value) return;
this.syncingFromProps = true;
this[key] = value;
this.$nextTick(() => {
this.syncingFromProps = false;
});
},
/**
* 姓名/手机变更:清匹配并回写(换人后旧 user_patient_id 不能沿用)
*/
onLocalIdentityChange() {
if (this.syncingFromProps) return;
this.formMatched = false;
this.formUserPatientId = 0;
this.emitFormChange();
},
/** 把本地 form 整包 emit 给父组件 */
emitFormChange() {
if (this.syncingFromProps) return;
this.$emit('change', this.getValues());
},
/**
* 供父组件确认提交时强制取最新值
* 不依赖每次 input 是否成功同步到父组件 data
*/
getValues() {
return {
patientName: String(this.formName || ''),
patientMobile: String(this.formMobile || ''),
patientAddress: String(this.formAddress || ''),
patientSex: Number(this.formSex || 0),
patientAge: this.formAge == null ? '' : String(this.formAge),
userPatientId: Number(this.formUserPatientId || 0),
patientMatched: !!this.formMatched,
};
},
/** 失焦后若姓名或手机有变化再搜一次 */
onBlurSearch() {
if (this.blurTimer) clearTimeout(this.blurTimer);
this.blurTimer = setTimeout(() => {
this.blurTimer = null;
this.doSearch({ silent: true });
}, 280);
},
/** 点击「搜索匹配」:显式非静默,缺字段会 toast */
onSearchClick() {
this.doSearch({ silent: false });
},
/**
* 按本地姓名/手机查是否已有就诊人(不用 props,避免回写失败导致搜空)
* @param {{ silent?: boolean }} opts silent=true 时缺字段不 toast
*/
async doSearch(opts) {
const silent = !!(opts && opts.silent);
const name = String(this.formName || '').trim();
const mobile = String(this.formMobile || '').trim();
if (!name && !mobile) {
if (!silent) {
uni.showToast({ title: '请先填写姓名或手机号', icon: 'none' });
}
return;
}
const key = name + '|' + mobile;
if (key === this.lastSearchKey && this.formMatched) {
return;
}
if (this.searching) return;
this.searching = true;
try {
const requestMatch = this.matchApi || matchTransferPatientApi;
const res = await requestMatch({
patient_name: name,
patient_mobile: mobile,
});
const { ok, payload, message } = unwrapXkApi(res);
if (!ok || !payload) {
if (!silent) {
uni.showToast({ title: message || '搜索失败', icon: 'none' });
}
return;
}
this.lastSearchKey = key;
const matched = !!payload.matched;
// 先写本地 form,再 emit,保证 getValues 立刻可读到匹配结果
this.syncingFromProps = true;
this.formMatched = matched;
this.formUserPatientId = Number(payload.user_patient_id || 0);
if (matched) {
if (payload.name) this.formName = String(payload.name);
if (payload.mobile) this.formMobile = String(payload.mobile);
if (payload.sex) this.formSex = Number(payload.sex);
if (payload.age) this.formAge = String(payload.age);
if (payload.address) this.formAddress = String(payload.address);
}
this.$nextTick(() => {
this.syncingFromProps = false;
this.emitFormChange();
});
if (!silent) {
uni.showToast({
title: matched ? '已匹配到就诊人' : '未匹配,提交时将自动创建或按填写信息落单',
icon: 'none',
});
}
} catch (e) {
if (!silent) {
uni.showToast({ title: (e && e.message) || '搜索失败', icon: 'none' });
}
} finally {
this.searching = false;
}
},
/** 供父组件 OCR 回填后立刻触发一次匹配 */
searchAfterOcr() {
this.lastSearchKey = '';
return this.doSearch({ silent: true });
},
},
};
</script>
<style lang="scss" scoped>
.patient-panel {
margin-bottom: 8rpx;
}
.panel-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.sec-title {
display: block;
margin: 0;
font-size: 28rpx;
font-weight: 700;
color: #16302c;
}
.search-btn {
padding: 8rpx 20rpx;
font-size: 24rpx;
color: #008771;
border-radius: 12rpx;
border: 1px solid rgba(106, 205, 187, 0.45);
box-shadow: 0 0 6px rgba(106, 205, 187, 0.16);
background: rgba(106, 205, 187, 0.1);
}
.search-btn.disabled {
opacity: 0.55;
}
.field {
margin-bottom: 20rpx;
}
.label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
}
.field-row {
display: flex;
flex-direction: row;
}
.field-col {
flex: 1;
margin-right: 16rpx;
}
.field-col:last-child {
margin-right: 0;
}
.match-flag {
margin-bottom: 12rpx;
font-size: 24rpx;
}
.text-primary {
color: #008771;
}
.text-warn {
color: #c47a12;
}
</style>

View File

@@ -321,6 +321,19 @@ export default {
drugUseWayList: {
type: Array,
default: () => []
},
/**
* 可注入药库搜索(诊所传方传 clinicSearchTransferDrugs)
* 不传则走医生 getProductListDoctorReception / 推广员列表
*/
drugListApi: {
type: Function,
default: null
},
/** 取价门店;传方编辑页用订单 store_id,避免只靠本地缓存 */
storeId: {
type: [String, Number],
default: 0
}
},
data() {
@@ -529,6 +542,20 @@ export default {
* @param {string} keyword - 搜索关键词
* 职责:调用接口获取中药列表
*/
/**
* 从各端药库响应里抽出列表(xk-api result / Yii data / 直数组)
* 为什么单独抽:诊所注入 API 与医生默认接口返回壳不一致,不能写死一种
*/
unwrapDrugListPayload(res) {
if (!res) return [];
if (Array.isArray(res)) return res;
if (Array.isArray(res.result)) return res.result;
if (Array.isArray(res.data)) return res.data;
if (res.result && Array.isArray(res.result.items)) return res.result.items;
if (res.data && Array.isArray(res.data.items)) return res.data.items;
if (res.payload && Array.isArray(res.payload)) return res.payload;
return [];
},
async loadDrugList(keyword = '') {
const name = (keyword || '').trim();
if (!name) {
@@ -543,24 +570,29 @@ export default {
this.loading = true;
try {
let list = [];
if (this.salespersonTransferMode) {
const storeId = Number(this.storeId || 0) || uni.getStorageSync('store_id') || 0;
const params = {
store_id: storeId || undefined,
type: 1,
name: name,
page: this.page,
page_size: this.pageSize
};
if (this.registerId) {
params.register_id = this.registerId;
}
// 传方编辑等场景注入诊所/自定义搜药,优先走注入接口
if (typeof this.drugListApi === 'function') {
const res = await this.drugListApi(params);
list = this.unwrapDrugListPayload(res);
this.hasMore = list.length >= this.pageSize;
} else if (this.salespersonTransferMode) {
const wrap = await getClinicSalespersonChineseDrugList({ name });
if (wrap && wrap.ok) {
list = Array.isArray(wrap.data) ? wrap.data : [];
}
this.hasMore = false;
} else {
const storeId = uni.getStorageSync('store_id') || 11001;
const params = {
store_id: storeId,
type: 1,
name: name,
page: this.page,
page_size: this.pageSize
};
if (this.registerId) {
params.register_id = this.registerId;
}
const res = await getProductListDoctorReception(params);
if (res && (res.code === 0 || res.errcode === 0)) {
list = res.data || res.result || [];

View File

@@ -37,16 +37,16 @@
:lower-threshold="100"
>
<view class="scroll-body-pad">
<!-- 常用诊断:有搜索结果时隐藏,避免与搜索列表抢视线 -->
<!-- 常用诊断:限制高度可内滚,避免占满弹层首屏 -->
<view
class="section white radius-16 p-32 m-b-24 shadow-sm"
class="section white radius-16 p-24 m-b-24 shadow-sm common-dx-section"
v-if="doctorMyDiseaseList.length > 0 && allDiagnosisList.length === 0"
>
<view class="section-header flex-row flex-jus-sp flex-ali-center m-b-24">
<d-text text="常用诊断" className="fs-30 font-bold color-title"></d-text>
<d-text :text="`${doctorMyDiseaseList.length}项`" className="fs-26 color-sub"></d-text>
<view class="section-header flex-row flex-jus-sp flex-ali-center m-b-16">
<d-text text="常用诊断" className="fs-28 font-bold color-title"></d-text>
<d-text :text="`${doctorMyDiseaseList.length}项`" className="fs-24 color-sub"></d-text>
</view>
<view class="modern-tag-container">
<view class="modern-tag-container common-dx-tags">
<view
v-for="item in doctorMyDiseaseList"
:key="item.id"
@@ -539,6 +539,20 @@ export default {
margin-bottom: 32rpx;
}
/* 常用诊断:高度封顶,多标签时区内滚动,不把搜索区顶没 */
.common-dx-section {
max-height: 240rpx;
overflow: hidden;
display: flex;
flex-direction: column;
}
.common-dx-tags {
flex: 1;
min-height: 0;
max-height: 160rpx;
overflow-y: auto;
}
.modern-tag-container {
display: flex;
flex-wrap: wrap;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,143 @@
/**
* 中药复用 / 常用方填方前,与当前门店价对齐
* 为什么:保存快照不会跟着供货比例变,价格不一致时必须医生确认后再决定用最新价还是原价
*/
import { compareChineseDrugPricesApi } from '@/api/reception.js';
import { unwrapXkApi } from '@/utils/api-response.js';
/** 弹窗里展示的金额;空快照显示「无」 */
function formatMoney(value) {
if (value === null || value === undefined || value === '') {
return '无';
}
const num = Number(value);
if (Number.isNaN(num)) {
return '无';
}
return num.toFixed(2);
}
/** 拼确认文案,控制长度避免小程序弹窗被截断 */
function buildPriceChangeText(items, missing) {
const lines = ['以下药品与当前门店价不一致,是否按最新供货价和零售价计算?'];
const shown = items.slice(0, 5);
shown.forEach((item) => {
const parts = [];
if (Number(item.buy_price_changed) === 1) {
parts.push('供货价 ' + formatMoney(item.old_buy_price) + '→' + formatMoney(item.latest_buy_price));
}
if (Number(item.price_changed) === 1) {
parts.push('零售价 ' + formatMoney(item.old_price) + '→' + formatMoney(item.latest_price));
}
lines.push((item.drug_name || '药品') + ':' + parts.join(','));
});
if (items.length > shown.length) {
lines.push('等共 ' + items.length + ' 味');
}
if (missing && missing.length) {
const names = missing.slice(0, 3).map((item) => item.drug_name || '药品').join('、');
lines.push('未上架将保留原价:' + names + (missing.length > 3 ? '等' : ''));
}
return lines.join('\n');
}
/** 确认是否改用最新价;取消或关闭都保留原价 */
function confirmUseLatest(items, missing) {
return new Promise((resolve) => {
uni.showModal({
title: '药品价格已变化',
content: buildPriceChangeText(items, missing),
confirmText: '用最新价',
cancelText: '保留原价',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false),
});
});
}
/** 把选中的最新价写回药品行,并锁定为后续调价的基准 */
export function applyLatestPrices(drugs, items) {
const latestById = {};
items.forEach((item) => {
latestById[Number(item.drug_id)] = item;
});
drugs.forEach((drug) => {
const hit = latestById[Number(drug.id || drug.drug_id || 0)];
if (!hit) {
return;
}
drug.price = hit.latest_price;
drug.origin_price = hit.latest_price;
// 诊所不可查看毛利率时接口不回供货价,不能写成空,结算仍用原来的供货价
if (hit.latest_buy_price !== null && hit.latest_buy_price !== undefined && hit.latest_buy_price !== '') {
drug.buy_price = hit.latest_buy_price;
drug.origin_buy_price = hit.latest_buy_price;
}
});
}
/**
* 拉取与当前门店价不一致的中药行
* 为什么:接诊页要标出可更新的药,导入确认和批量按钮共用这一次对比
* @param {{registerId?: number, storeId?: number, drugs: Array, compareOrigin?: boolean, silent?: boolean}} options
*/
export async function fetchChinesePriceDiff(options) {
const drugs = (options && options.drugs) || [];
if (!drugs.length) {
return { items: [], missing: [] };
}
const compareOrigin = !!(options && options.compareOrigin);
let res;
try {
res = await compareChineseDrugPricesApi({
register_id: Number(options.registerId || 0),
store_id: Number(options.storeId || 0),
drugs: drugs.map((drug) => ({
id: Number(drug.id || drug.drug_id || 0),
drug_name: drug.drug_name || drug.name || '',
price: compareOrigin ? (drug.origin_price != null ? drug.origin_price : drug.price) : (drug.price != null ? drug.price : null),
buy_price: compareOrigin
? (drug.origin_buy_price != null ? drug.origin_buy_price : (drug.buy_price != null ? drug.buy_price : null))
: (drug.buy_price != null ? drug.buy_price : null),
})),
});
} catch (error) {
console.error('核对中药最新价格失败', error);
if (!options.silent) {
uni.showToast({ title: '价格核对失败,已按原价填入', icon: 'none' });
}
return { items: [], missing: [] };
}
const wrap = unwrapXkApi(res);
if (!wrap.ok) {
if (!options.silent) {
uni.showToast({ title: wrap.message || '价格核对失败,已按原价填入', icon: 'none' });
}
return { items: [], missing: [] };
}
const data = wrap.payload || {};
return {
items: data.changed && Array.isArray(data.items) ? data.items : [],
missing: Array.isArray(data.missing) ? data.missing : [],
// null 表示旧接口没带开关,调用方不要覆盖页面上已有的 seeRate
see_rate: data.see_rate == null ? null : (Number(data.see_rate) === 1 ? 1 : 0),
};
}
/**
* 就地决定药品行用快照价还是最新门店价
* 核对失败不阻断填方,按原价继续
*/
export async function alignChineseDrugsToLatestPrice(options) {
const drugs = (options && options.drugs) || [];
const diff = await fetchChinesePriceDiff(options);
const items = diff.items || [];
if (!items.length) {
return;
}
const useLatest = await confirmUseLatest(items, diff.missing || []);
if (!useLatest) {
return;
}
applyLatestPrices(drugs, items);
}

View File

@@ -1,9 +1,10 @@
<template>
<view class="lock-page" :class="cardToneClass">
<!-- #ifdef MP-WEIXIN -->
<!-- pageGuard(v-if) 与 panelShow(:show) 拆开:拦截返回时先关 show 再 nextTick 挂回,二次侧滑才继续生效 -->
<page-container
v-if="guardShow && !softMode"
:show="guardShow"
v-if="pageGuard && !softMode"
:show="panelShow"
:overlay="false"
@beforeleave="onLeave"
@leave="onLeave"
@@ -234,7 +235,10 @@ export default {
components: { LockWarningDecor },
data() {
return {
guardShow: true,
/** page-container 挂载态(v-if) */
pageGuard: true,
/** page-container 可见态(:show),与 pageGuard 拆开避免二次侧滑失效 */
panelShow: true,
step: 'hint',
payload: {},
dailyId: 0,
@@ -377,7 +381,8 @@ export default {
if (soft) {
this.softMode = true
this.allowLeave = true
this.guardShow = false
this.panelShow = false
this.pageGuard = false
}
},
onShow() {
@@ -439,7 +444,8 @@ export default {
if (live.locked === false || live.locked === 0) {
this.allowLeave = true
clearPublicAccountLock()
this.guardShow = false
this.panelShow = false
this.pageGuard = false
goHomeAfterPublicAccountUnlock()
return
}
@@ -551,10 +557,19 @@ export default {
onRemarkInput(e) {
this.payRemark = (e && e.detail && e.detail.value) || ''
},
/**
* 硬锁页拦截返回:先关 panelShow,再 nextTick 挂回,否则二次侧滑失效
* allowLeave 时真正放行(解锁/登出)
*/
onLeave() {
this.guardShow = false
if (this.allowLeave || this.softMode) {
this.panelShow = false
this.pageGuard = false
return
}
this.panelShow = false
this.$nextTick(() => {
this.guardShow = true
this.panelShow = true
})
},
/**
@@ -571,7 +586,8 @@ export default {
return
}
this.allowLeave = true
this.guardShow = false
this.panelShow = false
this.pageGuard = false
clearPublicAccountLock()
clearLoginStorage()
uni.reLaunch({ url: '/pages/login/index' })
@@ -809,7 +825,8 @@ export default {
uni.showToast({ title: '已解锁', icon: 'none' })
this.allowLeave = true
clearPublicAccountLock()
this.guardShow = false
this.panelShow = false
this.pageGuard = false
setTimeout(() => {
goHomeAfterPublicAccountUnlock()
}, 300)

View File

@@ -324,8 +324,8 @@ import {
hasVipPermission,
VIP_FEATURE,
} from '@/utils/vip.js'
import VipGate from '@/components/vip-gate/vip-gate.vue'
import ProxyPayQrcodeModal from '@/components/proxy-pay-qrcode-modal/proxy-pay-qrcode-modal.vue'
import VipGate from '@/subPackages/sub_workbench/components/vip-gate/vip-gate.vue'
import ProxyPayQrcodeModal from '@/subPackages/sub_workbench/components/proxy-pay-qrcode-modal/proxy-pay-qrcode-modal.vue'
function emptyHealthInquiry() {
return {

View File

@@ -0,0 +1,62 @@
/**
* 识图传方报价:请求侧增量组装
* 首次全量 drugs;同味数下只传 quote_token + drugs_patch(改了的下标),省流量
*/
/**
* 比较两味报价药品是否等价(用于算 patch)
* @param {Record<string, any>|null|undefined} a
* @param {Record<string, any>|null|undefined} b
*/
export function isSameQuoteDrug(a, b) {
if (!a || !b) return false
return (
Number(a.drug_id || 0) === Number(b.drug_id || 0) &&
Number(a.number || 0) === Number(b.number || 0) &&
Number(a.price || 0) === Number(b.price || 0) &&
String(a.name || '') === String(b.name || '') &&
Number(a.way_id || 0) === Number(b.way_id || 0)
)
}
/**
* 组装增量报价入参
* @param {{
* scalars: Record<string, any>,
* drugs: Array<Record<string, any>>,
* quoteToken?: string,
* lastQuoteDrugs?: Array<Record<string, any>>|null
* }} opts
* @returns {{ payload: Record<string, any>, nextLastDrugs: Array<Record<string, any>> }}
*/
export function buildIncrementalQuotePayload(opts) {
const scalars = opts.scalars || {}
const drugs = Array.isArray(opts.drugs) ? opts.drugs : []
const quoteToken = String(opts.quoteToken || '').trim()
const prev = Array.isArray(opts.lastQuoteDrugs) ? opts.lastQuoteDrugs : null
const payload = Object.assign({}, scalars)
if (quoteToken) {
payload.quote_token = quoteToken
}
// 无 token、味数变化、或上次快照缺失 → 全量
if (!quoteToken || !prev || prev.length !== drugs.length) {
payload.drugs = drugs
return { payload, nextLastDrugs: drugs }
}
const patches = []
for (let i = 0; i < drugs.length; i++) {
if (!isSameQuoteDrug(prev[i], drugs[i])) {
patches.push({ index: i, drug: drugs[i] })
}
}
if (patches.length === 0) {
// 只改了地址/剂数/煎法,不带 drugs
return { payload, nextLastDrugs: prev }
}
if (patches.length >= drugs.length) {
payload.drugs = drugs
} else {
payload.drugs_patch = patches
}
return { payload, nextLastDrugs: drugs }
}

View File

@@ -2,7 +2,7 @@
* VIP 权限判断(医生小程序全局方法)
* - 履约诊所 permissions 来自 get-current-store-type 的 vip.permissions
* - 业务里用 hasVipPermission(code, permissions) / fetchStoreVipPermissions
* - 模板显隐可用 components/vip-gate/vip-gate.vue
* - 模板显隐可用 subPackages/sub_workbench/components/vip-gate/vip-gate.vue
*/
import { getCurrentStoreTypeApi } from '@/api/reception.js'

62
xaa Normal file
View File

@@ -0,0 +1,62 @@
CURRENT:
feature/invoice
---STATUS---
## feature/invoice...origin/dev
M app/Enum/ai/AiGenerationSceneEnum.php
M app/Http/Controllers/DoctorWx/DoctorReceptionWxController.php
M app/Http/Controllers/admin/ai/AiGenerationController.php
M app/Http/Controllers/admin/business/doctor/DoctorReceptionController.php
M app/Http/Controllers/admin/finance/TreatmentFeeCashBackfillController.php
M app/Http/Controllers/admin/log/OldController.php
M app/Http/Controllers/admin/system/StoreController.php
M app/Jobs/ProductOrderPaidJob.php
M app/Service/DoctorWx/DoctorReceptionWxService.php
M app/Service/admin/ai/AiGenerationService.php
M app/Service/admin/business/doctor/DoctorReceptionService.php
M app/Service/admin/business/order/OrderTraceService.php
M app/Service/admin/finance/FundWaterService.php
M app/Service/admin/finance/PublicAccountDailyBillService.php
M app/Service/admin/finance/ReconciliationService.php
M app/Service/admin/finance/TreatmentFeeCashBackfillService.php
M app/Service/admin/log/OldLogService.php
M app/Service/admin/salesperson/SalespersonTransferPrescriptionService.php
M app/Service/admin/system/StoreService.php
M app/Service/callback/EplpayCallbackService.php
M app/Service/common/ai/AiAgentFactory.php
M app/Service/common/ai/AiMedicalAssistService.php
M app/Service/common/ai/DeepSeekAiAgent.php
M app/Service/common/http/WeChatApiClient.php
M app/Service/common/http/mes/MesFailoverClient.php
M app/Service/common/http/mes/MesHttpH001Client.php
M app/Service/common/http/mes/MesPrescriptionPayloadMapper.php
M app/Service/common/patient/UnclaimedPatientService.php
M app/Service/finance/AccountAbleChangeLogService.php
M app/Service/finance/LedgerService.php
M app/Support/ChineseDrugBuyPriceResolver.php
M config/ase.php
M config/xk.php
M routes/admin.php
M routes/doctor.php
M routes/open-api.php
?? app/Enum/imageTransfer/
?? app/Enum/store/
?? app/Http/Controllers/ClinicAdminWx/ImageTransferOrderWxController.php
?? app/Http/Controllers/DoctorWx/ImageTransferOrderWxController.php
?? app/Http/Controllers/admin/business/prescription/ImageTransferOrderController.php
?? app/Http/Controllers/admin/system/MesOpenAppController.php
?? app/Http/Controllers/common/MesOpenApiController.php
?? app/Http/Middleware/openApi/
?? app/Models/new/finance/OrderLedgerSplitModel.php
?? app/Models/new/finance/TreatmentFeeCashBackfillIgnoreModel.php
?? app/Models/new/imageTransfer/
?? app/Models/new/mes/
?? app/Service/DoctorWx/ImageTransferOrderWxService.php
?? app/Service/admin/system/MesOpenAppService.php
?? app/Service/common/ai/PrescriptionImageOcrService.php
?? app/Service/common/http/mes/MesPrescriptionPayloadValidator.php
?? app/Service/common/imageTransfer/
?? app/Service/common/mes/
?? "docs/MES\345\244\204\346\226\271\344\270\255\350\275\254-\347\254\254\344\270\211\346\226\271\345\257\271\346\216\245\350\257\264\346\230\216.md"
?? docs/mes-open-api.md
?? scripts/mes_open_api_sign_vector.php
---BRANCHES---