新增
This commit is contained in:
+13
-1
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDark, useThrottleFn, useWindowSize } from '@vueuse/core'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
@@ -11,12 +13,22 @@ import ChatNotifyToast from './components/chat-notify-toast/index.vue'
|
||||
const appStore = useAppStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
|
||||
/** 须先绑企微时 chat/notifications 会反复返回 code=10,无意义轮询且可能干扰 axios/路由;绑定页也不展示会话通知 */
|
||||
const showChatNotifyToast = computed(
|
||||
() =>
|
||||
Boolean(userStore.token) &&
|
||||
route.path !== '/bind-work-wechat' &&
|
||||
!userStore.userInfo?.need_bind_work_wechat
|
||||
)
|
||||
const elConfig = {
|
||||
zIndex: 2000,
|
||||
locale: zhCn
|
||||
}
|
||||
const isDark = useDark()
|
||||
onMounted(async () => {
|
||||
console.log('主题颜色',isDark.value)
|
||||
//设置主题色
|
||||
settingStore.setTheme(isDark.value)
|
||||
})
|
||||
@@ -45,7 +57,7 @@ watch(
|
||||
<template>
|
||||
<el-config-provider :locale="elConfig.locale" :z-index="elConfig.zIndex">
|
||||
<router-view />
|
||||
<ChatNotifyToast v-if="userStore.token" />
|
||||
<ChatNotifyToast v-if="showChatNotifyToast" />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 医生列表(从管理员表获取,role_id=1)
|
||||
// 医生列表(从管理员表获取,role_id=1;默认不含禁止登录账号)
|
||||
export function doctorLists(params: any) {
|
||||
return request.get({ url: '/auth.admin/lists', params: { ...params, role_id: 1 } })
|
||||
return request.get({
|
||||
url: '/auth.admin/lists',
|
||||
params: { ...params, role_id: 1, exclude_disabled: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
// 医生详情
|
||||
|
||||
@@ -20,7 +20,7 @@ export function orderTodayRevenue() {
|
||||
return request.get({ url: '/order.order/todayRevenue' })
|
||||
}
|
||||
|
||||
// 订单统计(0退款,1挂号费,2问诊费,3药品费用,4首付,5尾款,6其他)
|
||||
// 订单统计(-1全部已支付类型合计,0退款,1挂号费,2问诊费,3药品费用,4首付,5尾款,6其他)
|
||||
export function orderStats(params?: { order_type?: number; days?: number }) {
|
||||
return request.get({ url: '/order.order/orderStats', params })
|
||||
}
|
||||
|
||||
@@ -233,6 +233,57 @@ export function prescriptionAudit(params: { id: number; action: 'approve' | 'rej
|
||||
return request.post({ url: '/tcm.prescription/audit', params })
|
||||
}
|
||||
|
||||
// ========== 处方业务订单(履约单,非支付单 zyt_order) ==========
|
||||
|
||||
export function prescriptionOrderCreate(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/create', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderLists(params: any) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/lists', params })
|
||||
}
|
||||
|
||||
/** 诊单下可关联的已支付 zyt_order(已占用且未撤回的会排除;编辑时传 prescription_order_id 保留当前单已选) */
|
||||
export function prescriptionOrderPaidPayOrders(params: {
|
||||
diagnosis_id: number
|
||||
prescription_order_id?: number
|
||||
}) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/paidPayOrders', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderWithdraw(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/withdraw', params })
|
||||
}
|
||||
|
||||
/** 业务订单物流轨迹(快递100 + 顺丰/京东官网链接) */
|
||||
export function prescriptionOrderLogisticsTrace(params: { id: number; express_company?: string }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/logisticsTrace', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderDetail(params: { id: number }) {
|
||||
return request.get({ url: '/tcm.prescriptionOrder/detail', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/auditPrescription', params })
|
||||
}
|
||||
|
||||
export function prescriptionOrderAuditPayment(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/auditPayment', params })
|
||||
}
|
||||
|
||||
// ========== 处方库 ==========
|
||||
|
||||
// 处方库列表
|
||||
|
||||
@@ -234,15 +234,10 @@
|
||||
<canvas
|
||||
ref="signatureCanvasRef"
|
||||
class="signature-canvas"
|
||||
width="280"
|
||||
height="120"
|
||||
@mousedown="onSignatureStart"
|
||||
@mousemove="onSignatureMove"
|
||||
@mouseup="onSignatureEnd"
|
||||
@mouseleave="onSignatureEnd"
|
||||
@touchstart.prevent="onSignatureStart"
|
||||
@touchmove.prevent="onSignatureMove"
|
||||
@touchend.prevent="onSignatureEnd"
|
||||
@pointerdown.prevent="onSignaturePointerDown"
|
||||
@pointermove.prevent="onSignaturePointerMove"
|
||||
@pointerup.prevent="onSignaturePointerUp"
|
||||
@pointercancel.prevent="onSignaturePointerUp"
|
||||
/>
|
||||
<div class="signature-actions">
|
||||
<el-button size="small" @click="clearSignature">清空签名</el-button>
|
||||
@@ -399,7 +394,7 @@
|
||||
<div class="cr-item"><span class="cr-label">诊断类型</span>{{ getDictLabel(diagnosisTypeOptions, savedPrescription.case_record.diagnosis_type) || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">证型</span>{{ getDictLabel(syndromeTypeOptions, savedPrescription.case_record.syndrome_type) || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">糖尿病期数</span>{{ getDictLabel(diabetesTypeOptions, savedPrescription.case_record.diabetes_type) || '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">发现糖尿病患病史</span>{{ savedPrescription.case_record.diabetes_discovery_year != null ? savedPrescription.case_record.diabetes_discovery_year + ' 年' : '—' }}</div>
|
||||
<div class="cr-item"><span class="cr-label">发现糖尿病患病史</span>{{ formatDiabetesDiscoveryDisplay(savedPrescription.case_record?.diabetes_discovery_year) }}</div>
|
||||
<div class="cr-item cr-full"><span class="cr-label">当地医院诊断</span>{{ formatLocalDiagnosis(savedPrescription.case_record.local_hospital_diagnosis) }}</div>
|
||||
<div class="cr-item cr-full"><span class="cr-label">当地就诊医院</span>{{ savedPrescription.case_record.local_hospital_name || '—' }}</div>
|
||||
</div>
|
||||
@@ -562,6 +557,7 @@
|
||||
<script setup lang="ts">
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { formatDiabetesDiscoveryDisplay } from '@/utils/diabetes-discovery-display'
|
||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import html2canvas from 'html2canvas'
|
||||
@@ -618,7 +614,11 @@ const voiding = ref(false)
|
||||
const prescriptionPrintRef = ref<HTMLElement>()
|
||||
const caseRecordPrintRef = ref<HTMLElement>()
|
||||
const signatureCanvasRef = ref<HTMLCanvasElement>()
|
||||
const isDrawing = ref(false)
|
||||
/** 当前一笔的 pointerId(配合 setPointerCapture,移出画布仍跟手) */
|
||||
const signatureActivePointerId = ref<number | null>(null)
|
||||
/** 签名板 CSS 像素尺寸(内部分辨率按 DPR 放大,触摸更跟手) */
|
||||
const SIGNATURE_CSS_W = 560
|
||||
const SIGNATURE_CSS_H = 168
|
||||
const savedPrescription = ref<any>(null)
|
||||
|
||||
/** 已通过且未作废:与消费者处方「查看」一致,仅预览,不可作废/再开新方 */
|
||||
@@ -874,76 +874,90 @@ const handleClose = () => {
|
||||
savedPrescription.value = null
|
||||
}
|
||||
|
||||
const getSignatureCoords = (e: MouseEvent | TouchEvent) => {
|
||||
/**
|
||||
* 签名坐标:与 initSignatureCanvas 里 ctx.scale(dpr) 后的「逻辑像素」一致(0…SIGNATURE_CSS_W/H),
|
||||
* 不能用 canvas.width/rect.width(会把 dpr 乘两次,笔画和鼠标严重错位)。
|
||||
*/
|
||||
const getSignatureLocalPoint = (e: PointerEvent) => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (!canvas) return { x: 0, y: 0 }
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const scaleX = canvas.width / rect.width
|
||||
const scaleY = canvas.height / rect.height
|
||||
if (e instanceof TouchEvent) {
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
return {
|
||||
x: (touch.clientX - rect.left) * scaleX,
|
||||
y: (touch.clientY - rect.top) * scaleY
|
||||
}
|
||||
}
|
||||
if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 }
|
||||
const x = ((e.clientX - rect.left) / rect.width) * SIGNATURE_CSS_W
|
||||
const y = ((e.clientY - rect.top) / rect.height) * SIGNATURE_CSS_H
|
||||
return {
|
||||
x: (e.clientX - rect.left) * scaleX,
|
||||
y: (e.clientY - rect.top) * scaleY
|
||||
x: Math.max(0, Math.min(SIGNATURE_CSS_W, x)),
|
||||
y: Math.max(0, Math.min(SIGNATURE_CSS_H, y))
|
||||
}
|
||||
}
|
||||
|
||||
const onSignatureStart = (e: MouseEvent | TouchEvent) => {
|
||||
isDrawing.value = true
|
||||
const ctx = signatureCanvasRef.value?.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { x, y } = getSignatureCoords(e)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const onSignatureMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing.value) return
|
||||
const ctx = signatureCanvasRef.value?.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { x, y } = getSignatureCoords(e)
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const onSignatureEnd = () => {
|
||||
isDrawing.value = false
|
||||
const commitSignatureImage = () => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
formData.doctor_signature = canvas.toDataURL('image/png')
|
||||
}
|
||||
}
|
||||
|
||||
const clearSignature = () => {
|
||||
const onSignaturePointerDown = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return
|
||||
const canvas = signatureCanvasRef.value
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
canvas.setPointerCapture(e.pointerId)
|
||||
signatureActivePointerId.value = e.pointerId
|
||||
const { x, y } = getSignatureLocalPoint(e)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const onSignaturePointerMove = (e: PointerEvent) => {
|
||||
if (signatureActivePointerId.value !== e.pointerId) return
|
||||
const ctx = signatureCanvasRef.value?.getContext('2d')
|
||||
if (!ctx) return
|
||||
const { x, y } = getSignatureLocalPoint(e)
|
||||
ctx.lineTo(x, y)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, y)
|
||||
}
|
||||
|
||||
const onSignaturePointerUp = (e: PointerEvent) => {
|
||||
if (signatureActivePointerId.value !== e.pointerId) return
|
||||
signatureActivePointerId.value = null
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
try {
|
||||
canvas.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* 已释放或非当前捕获 */
|
||||
}
|
||||
formData.doctor_signature = ''
|
||||
}
|
||||
commitSignatureImage()
|
||||
}
|
||||
|
||||
const clearSignature = () => {
|
||||
initSignatureCanvas()
|
||||
formData.doctor_signature = ''
|
||||
}
|
||||
|
||||
const initSignatureCanvas = () => {
|
||||
const canvas = signatureCanvasRef.value
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
ctx.strokeStyle = '#333'
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
}
|
||||
if (!canvas) return
|
||||
const dpr = Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2.5)
|
||||
canvas.style.width = `${SIGNATURE_CSS_W}px`
|
||||
canvas.style.height = `${SIGNATURE_CSS_H}px`
|
||||
canvas.width = Math.round(SIGNATURE_CSS_W * dpr)
|
||||
canvas.height = Math.round(SIGNATURE_CSS_H * dpr)
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, SIGNATURE_CSS_W, SIGNATURE_CSS_H)
|
||||
ctx.strokeStyle = '#333'
|
||||
ctx.lineWidth = 2.5
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
}
|
||||
|
||||
const handleAddHerb = () => {
|
||||
@@ -1316,6 +1330,8 @@ defineExpose({
|
||||
}
|
||||
|
||||
.signature-pad-wrap {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const defaultSetting = {
|
||||
showCrumb: true, // 是否显示面包屑
|
||||
showLogo: true, // 是否显示logo
|
||||
isUniqueOpened: false, //只展开一个一级菜单
|
||||
sideWidth: 200, //侧边栏宽度
|
||||
sideTheme: 'light', //侧边栏主题
|
||||
showLogo: false, // 是否显示logo
|
||||
isUniqueOpened: true, //只展开一个一级菜单
|
||||
sideWidth: 183, //侧边栏宽度
|
||||
sideTheme: 'dark', //侧边栏主题
|
||||
sideDarkColor: '#1d2124', //侧边栏深色主题颜色
|
||||
openMultipleTabs: true, // 是否开启多标签tab栏
|
||||
theme: '#4A5DFF', //主题色
|
||||
@@ -13,4 +13,16 @@ const defaultSetting = {
|
||||
errorTheme: '#f56c6c', //错误主题色
|
||||
infoTheme: '#909399' //信息主题色
|
||||
}
|
||||
|
||||
/** 本地 setting 缓存结构版本。提升后仅对低于该版本的老缓存执行 SETTING_SCHEMA_MIGRATIONS */
|
||||
export const SETTING_SCHEMA_VERSION = 1
|
||||
|
||||
/**
|
||||
* 按版本写入 defaultSetting 中的键(老用户 localStorage 会长期盖住 config 默认值)。
|
||||
* 以后若要再推一批新默认值:把 SETTING_SCHEMA_VERSION +1,并为本版本追加一条迁移键列表。
|
||||
*/
|
||||
export const SETTING_SCHEMA_MIGRATIONS: Record<number, (keyof typeof defaultSetting)[]> = {
|
||||
1: ['sideTheme', 'sideDarkColor']
|
||||
}
|
||||
|
||||
export default defaultSetting
|
||||
|
||||
@@ -15,5 +15,7 @@ export enum RequestCodeEnum {
|
||||
LOGIN_FAILURE = -1,
|
||||
FAIL = 0,
|
||||
SUCCESS = 1,
|
||||
OPEN_NEW_PAGE = 2
|
||||
OPEN_NEW_PAGE = 2,
|
||||
/** 非超级管理员须先绑定企业微信 */
|
||||
NEED_BIND_WORK_WECHAT = 10
|
||||
}
|
||||
|
||||
@@ -20,3 +20,23 @@ useWatchRoute((route) => {
|
||||
getBreadcrumb(route)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-breadcrumb {
|
||||
:deep(.el-breadcrumb__item) {
|
||||
.el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&:last-child .el-breadcrumb__inner {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,6 +83,7 @@ const handleCommand = (command: any) => {
|
||||
padding: 0 15px !important;
|
||||
box-sizing: border-box;
|
||||
&.is-active {
|
||||
color: var(--el-text-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
&::before {
|
||||
content: '';
|
||||
|
||||
@@ -225,7 +225,9 @@ const resetTheme = () => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.icon-select {
|
||||
@apply absolute left-1/2 top-1/2;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,19 +14,9 @@ app.use(install)
|
||||
app.mount('#app')
|
||||
|
||||
getConfig().then((res) => {
|
||||
const likeadminArt = `
|
||||
_ _ _ _ ______ ____ ______ __ __ _ __ _
|
||||
| | | | | | / / | ____| / __ \\ | ___ \\ | \\ / | | | | \\ | |
|
||||
| | | | | |/ / | |____ / / \\ \\ | | | | | \\_/ | | | | \\ | |
|
||||
| | | | | | | ____| | |____| | | | | | | |\\ /| | | | | |\\ \\| |
|
||||
| |___ | | | |\\ \\ | |____ | ____ | | |___/ / | | | | | | | | | | \\ |
|
||||
|_____| |_| |_| \\_\\ |______| |_| |_| |______/ |_| |_| |_| |_| |_| \\__|
|
||||
`
|
||||
|
||||
console.log(
|
||||
`%c likeadmin %c v${res.version} `,
|
||||
'padding: 4px 1px; border-radius: 3px 0 0 3px; color: #fff; background: #bbb; font-weight: bold;',
|
||||
'padding: 4px 1px; border-radius: 0 3px 3px 0; color: #fff; background: #4A5DFF; font-weight: bold;'
|
||||
)
|
||||
console.log(`%c ${likeadminArt}`, 'color: #4A5DFF')
|
||||
})
|
||||
|
||||
+79
-3
@@ -2,6 +2,8 @@
|
||||
* 权限控制
|
||||
*/
|
||||
|
||||
import type { LocationQueryValue, RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
import config from './config'
|
||||
import { PageEnum } from './enums/pageEnum'
|
||||
import router, { findFirstValidRoute } from './router'
|
||||
@@ -11,6 +13,33 @@ import useUserStore from './stores/modules/user'
|
||||
import { clearAuthInfo } from './utils/auth'
|
||||
import { isExternal } from './utils/validate'
|
||||
|
||||
function firstQueryString(q: LocationQueryValue | LocationQueryValue[] | undefined): string {
|
||||
if (q === undefined || q === null) {
|
||||
return ''
|
||||
}
|
||||
if (Array.isArray(q)) {
|
||||
const v = q[0]
|
||||
return v == null ? '' : String(v).trim()
|
||||
}
|
||||
return String(q).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 企微扫码绑定 OAuth 回调:须放行当前页并保留 code,否则守卫会跳到 /bind-work-wechat 导致丢参、绑定失败。
|
||||
* Vue Router 的 query 值可能是 string | string[],仅用 typeof === 'string' 会误判,进而 next({ path }) 清掉整段 query。
|
||||
*/
|
||||
function isWorkWechatBindOAuthCallback(to: RouteLocationNormalized): boolean {
|
||||
const code = firstQueryString(to.query.code)
|
||||
if (!code) {
|
||||
return false
|
||||
}
|
||||
const state = firstQueryString(to.query.state)
|
||||
if (state === 'bind_wxwork' || state === 'admin_bind_wx') {
|
||||
return true
|
||||
}
|
||||
return firstQueryString(to.query.bind_wxwork) === '1'
|
||||
}
|
||||
|
||||
// 动态添加路由-使用递归进行调整-(fix: 修复之前超过3级菜单导致使用keep-alive功能无效问题
|
||||
const addRoutesRecursively = (routes: any, parentPath = '') => {
|
||||
try {
|
||||
@@ -50,13 +79,23 @@ const addRoutesRecursively = (routes: any, parentPath = '') => {
|
||||
const loginPath = PageEnum.LOGIN
|
||||
const defaultPath = PageEnum.INDEX
|
||||
const changePasswordPath = '/change-password'
|
||||
const bindWorkWechatPath = '/bind-work-wechat'
|
||||
// 免登录白名单
|
||||
const whiteList: string[] = [PageEnum.LOGIN, PageEnum.ERROR_403]
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
document.title = to.meta.title ?? config.title
|
||||
const userStore = useUserStore()
|
||||
const tabsStore = useTabsStore()
|
||||
|
||||
|
||||
if (to.path === bindWorkWechatPath) {
|
||||
if (!userStore.token) {
|
||||
next({ path: loginPath })
|
||||
return
|
||||
}
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
// 特殊处理:修改密码页面
|
||||
if (to.path === changePasswordPath) {
|
||||
if (!userStore.token) {
|
||||
@@ -82,7 +121,36 @@ router.beforeEach(async (to, from, next) => {
|
||||
next({ path: changePasswordPath })
|
||||
return
|
||||
}
|
||||
|
||||
if (userStore.userInfo?.need_bind_work_wechat) {
|
||||
if (to.path !== bindWorkWechatPath) {
|
||||
if (isWorkWechatBindOAuthCallback(to)) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
next({ path: bindWorkWechatPath })
|
||||
return
|
||||
}
|
||||
}
|
||||
// 须先绑企微时未 addRoute;绑定后须补注册
|
||||
if (
|
||||
!userStore.userInfo?.need_bind_work_wechat &&
|
||||
userStore.routes?.length > 0 &&
|
||||
!router.hasRoute(INDEX_ROUTE_NAME)
|
||||
) {
|
||||
const routeName = findFirstValidRoute(userStore.routes)
|
||||
if (!routeName) {
|
||||
clearAuthInfo()
|
||||
next(PageEnum.ERROR_403)
|
||||
return
|
||||
}
|
||||
tabsStore.setRouteName(routeName!)
|
||||
INDEX_ROUTE.redirect = { name: routeName! }
|
||||
router.addRoute(INDEX_ROUTE)
|
||||
addRoutesRecursively(userStore.routes)
|
||||
next({ ...to, replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === loginPath) {
|
||||
next({ path: defaultPath })
|
||||
} else {
|
||||
@@ -98,7 +166,15 @@ router.beforeEach(async (to, from, next) => {
|
||||
next({ path: changePasswordPath })
|
||||
return
|
||||
}
|
||||
|
||||
if (userStore.userInfo?.need_bind_work_wechat) {
|
||||
if (isWorkWechatBindOAuthCallback(to)) {
|
||||
next({ ...to, replace: true })
|
||||
return
|
||||
}
|
||||
next({ path: bindWorkWechatPath })
|
||||
return
|
||||
}
|
||||
|
||||
const routes = userStore.routes
|
||||
// 找到第一个有效路由
|
||||
const routeName = findFirstValidRoute(routes)
|
||||
|
||||
@@ -5,19 +5,25 @@ import useAppStore from '@/stores/modules/app'
|
||||
export default function createInitGuard(router: Router) {
|
||||
router.beforeEach(async () => {
|
||||
const appStore = useAppStore()
|
||||
if (Object.keys(appStore.config).length == 0) {
|
||||
// 获取配置
|
||||
if (appStore.configFetchAttempted) {
|
||||
return
|
||||
}
|
||||
appStore.$patch({ configFetchAttempted: true })
|
||||
try {
|
||||
const data: any = await appStore.getConfig()
|
||||
|
||||
// 设置网站logo
|
||||
let favicon: HTMLLinkElement = document.querySelector('link[rel="icon"]')!
|
||||
if (favicon) {
|
||||
if (favicon && data?.web_favicon) {
|
||||
favicon.href = data.web_favicon
|
||||
}
|
||||
favicon = document.createElement('link')
|
||||
favicon.rel = 'icon'
|
||||
favicon.href = data.web_favicon
|
||||
document.head.appendChild(favicon)
|
||||
if (data?.web_favicon) {
|
||||
favicon = document.createElement('link')
|
||||
favicon.rel = 'icon'
|
||||
favicon.href = data.web_favicon
|
||||
document.head.appendChild(favicon)
|
||||
}
|
||||
} catch {
|
||||
// getConfig 失败时不再重试,避免与路由守卫形成无限请求
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
path: '/change-password',
|
||||
component: () => import('@/views/account/change-password.vue')
|
||||
},
|
||||
{
|
||||
path: '/bind-work-wechat',
|
||||
component: () => import('@/views/account/bind-work-wechat.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: LAYOUT,
|
||||
@@ -53,6 +57,20 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/doctor',
|
||||
component: LAYOUT,
|
||||
children: [
|
||||
{
|
||||
path: 'progress',
|
||||
component: () => import('@/views/doctor/progress.vue'),
|
||||
name: 'doctorFaceProgress',
|
||||
meta: {
|
||||
title: '面诊进度'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/decoration/pc_details',
|
||||
component: () => import('@/views/decoration/pc_details.vue')
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getConfig } from '@/api/app'
|
||||
|
||||
interface AppSate {
|
||||
config: Record<string, any>
|
||||
/** 是否已尝试过拉取 getConfig(失败也置 true,避免路由守卫死循环) */
|
||||
configFetchAttempted: boolean
|
||||
isMobile: boolean
|
||||
isCollapsed: boolean
|
||||
isRouteShow: boolean
|
||||
@@ -14,6 +16,7 @@ const useAppStore = defineStore({
|
||||
state: (): AppSate => {
|
||||
return {
|
||||
config: {},
|
||||
configFetchAttempted: false,
|
||||
isMobile: true,
|
||||
isCollapsed: false,
|
||||
isRouteShow: true
|
||||
|
||||
@@ -30,6 +30,12 @@ const getHasTabIndex = (fullPath: string, tabList: TabItem[]) => {
|
||||
return tabList.findIndex((item) => item.fullPath == fullPath)
|
||||
}
|
||||
|
||||
/** 同一菜单页可能因 query、重定向等产生不同 fullPath,用 name + 解析后的 path 去重 */
|
||||
const getTabMergeKey = (item: Pick<TabItem, 'name' | 'path'>) => {
|
||||
if (item.name == null || item.name === '') return ''
|
||||
return `${String(item.name)}::${item.path}`
|
||||
}
|
||||
|
||||
const isCannotAddRoute = (route: RouteLocationNormalized, router: Router) => {
|
||||
const { path, meta, name } = route
|
||||
if (!path || isExternal(path)) return true
|
||||
@@ -109,14 +115,41 @@ const useTabsStore = defineStore({
|
||||
query,
|
||||
params
|
||||
}
|
||||
this.tasMap[fullPath] = tabItem
|
||||
if (meta?.keepAlive) {
|
||||
this.addCache(componentName)
|
||||
}
|
||||
if (hasTabIndex != -1) {
|
||||
|
||||
// 已存在相同 fullPath:刷新该项(标题/query 等可能变化)
|
||||
if (hasTabIndex !== -1) {
|
||||
this.tabList[hasTabIndex] = tabItem
|
||||
this.tasMap[fullPath!] = tabItem
|
||||
return
|
||||
}
|
||||
|
||||
const mergeKey = getTabMergeKey(tabItem)
|
||||
if (mergeKey) {
|
||||
const dupIndices: number[] = []
|
||||
this.tabList.forEach((item, i) => {
|
||||
if (getTabMergeKey(item) === mergeKey) dupIndices.push(i)
|
||||
})
|
||||
if (dupIndices.length > 0) {
|
||||
const keepIdx = dupIndices[0]
|
||||
for (let k = dupIndices.length - 1; k >= 1; k--) {
|
||||
const idx = dupIndices[k]
|
||||
delete this.tasMap[this.tabList[idx].fullPath]
|
||||
this.tabList.splice(idx, 1)
|
||||
}
|
||||
const prev = this.tabList[keepIdx]
|
||||
if (prev.fullPath !== fullPath) {
|
||||
delete this.tasMap[prev.fullPath]
|
||||
}
|
||||
this.tabList[keepIdx] = tabItem
|
||||
this.tasMap[fullPath!] = tabItem
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.tasMap[fullPath!] = tabItem
|
||||
this.tabList.push(tabItem)
|
||||
},
|
||||
removeTab(fullPath: string, router: Router) {
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
import { isObject } from '@vue/shared'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import defaultSetting from '@/config/setting'
|
||||
import defaultSetting, {
|
||||
SETTING_SCHEMA_MIGRATIONS,
|
||||
SETTING_SCHEMA_VERSION
|
||||
} from '@/config/setting'
|
||||
import { SETTING_KEY } from '@/enums/cacheEnums'
|
||||
import cache from '@/utils/cache'
|
||||
import { setTheme } from '@/utils/theme'
|
||||
|
||||
const storageSetting = cache.get(SETTING_KEY)
|
||||
|
||||
function applySettingSchemaMigrations(
|
||||
state: Record<string, any>,
|
||||
storedVersion: number
|
||||
) {
|
||||
if (storedVersion >= SETTING_SCHEMA_VERSION) return
|
||||
for (let ver = storedVersion + 1; ver <= SETTING_SCHEMA_VERSION; ver++) {
|
||||
const keys = SETTING_SCHEMA_MIGRATIONS[ver]
|
||||
if (!keys) continue
|
||||
for (const key of keys) {
|
||||
state[key] = defaultSetting[key]
|
||||
}
|
||||
}
|
||||
state._settingSchemaVersion = SETTING_SCHEMA_VERSION
|
||||
const { showDrawer, ...persist } = state
|
||||
cache.set(SETTING_KEY, persist)
|
||||
}
|
||||
|
||||
export const useSettingStore = defineStore({
|
||||
id: 'setting',
|
||||
state: () => {
|
||||
const state = {
|
||||
const state: Record<string, any> = {
|
||||
showDrawer: false,
|
||||
...defaultSetting
|
||||
}
|
||||
isObject(storageSetting) && Object.assign(state, storageSetting)
|
||||
let storedVersion = SETTING_SCHEMA_VERSION
|
||||
if (isObject(storageSetting)) {
|
||||
storedVersion =
|
||||
typeof (storageSetting as any)._settingSchemaVersion === 'number'
|
||||
? (storageSetting as any)._settingSchemaVersion
|
||||
: 0
|
||||
Object.assign(state, storageSetting)
|
||||
if (storedVersion < SETTING_SCHEMA_VERSION) {
|
||||
applySettingSchemaMigrations(state, storedVersion)
|
||||
}
|
||||
}
|
||||
state._settingSchemaVersion = SETTING_SCHEMA_VERSION
|
||||
return state
|
||||
},
|
||||
actions: {
|
||||
@@ -28,6 +59,7 @@ export const useSettingStore = defineStore({
|
||||
}
|
||||
const settings: any = Object.assign({}, this.$state)
|
||||
delete settings.showDrawer
|
||||
settings._settingSchemaVersion = SETTING_SCHEMA_VERSION
|
||||
cache.set(SETTING_KEY, settings)
|
||||
},
|
||||
// 设置主题色
|
||||
@@ -49,6 +81,7 @@ export const useSettingStore = defineStore({
|
||||
//@ts-ignore
|
||||
this[key] = defaultSetting[key]
|
||||
}
|
||||
this._settingSchemaVersion = SETTING_SCHEMA_VERSION
|
||||
cache.remove(SETTING_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 发现糖尿病患病史:纯数字展示时补「年」,含文字(如半年、1年)则原样 */
|
||||
export function formatDiabetesDiscoveryDisplay(v: unknown): string {
|
||||
if (v == null || v === '') return '—'
|
||||
const s = String(v).trim()
|
||||
if (s === '') return '—'
|
||||
return /^\d+$/.test(s) ? `${s}年` : s
|
||||
}
|
||||
|
||||
export function isDiabetesDiscoveryFilled(v: unknown): boolean {
|
||||
if (v == null) return false
|
||||
return String(v).trim() !== ''
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import { clearAuthInfo, getToken } from '../auth'
|
||||
import feedback from '../feedback'
|
||||
import { Axios } from './axios'
|
||||
import type { AxiosHooks } from './type'
|
||||
import { isBrowserOnWecomBindOAuthLanding } from '../wecomBindGuard'
|
||||
|
||||
const BIND_WORK_WECHAT_PATH = '/bind-work-wechat'
|
||||
let lastWorkWechatBindRedirectAt = 0
|
||||
|
||||
// 处理axios的钩子函数
|
||||
const axiosHooks: AxiosHooks = {
|
||||
@@ -65,6 +69,22 @@ const axiosHooks: AxiosHooks = {
|
||||
clearAuthInfo()
|
||||
router.push(PageEnum.LOGIN)
|
||||
return Promise.reject()
|
||||
case RequestCodeEnum.NEED_BIND_WORK_WECHAT:
|
||||
// 回跳瞬间 currentRoute 可能还是 /,误判后 replace 无 query,地址栏 code 被清 → 绑定永远不请求
|
||||
if (
|
||||
router.currentRoute.value.path === BIND_WORK_WECHAT_PATH ||
|
||||
isBrowserOnWecomBindOAuthLanding()
|
||||
) {
|
||||
return Promise.reject(data)
|
||||
}
|
||||
{
|
||||
const now = Date.now()
|
||||
if (now - lastWorkWechatBindRedirectAt > 400) {
|
||||
lastWorkWechatBindRedirectAt = now
|
||||
void router.replace(BIND_WORK_WECHAT_PATH).catch(() => {})
|
||||
}
|
||||
}
|
||||
return Promise.reject(data)
|
||||
case RequestCodeEnum.OPEN_NEW_PAGE:
|
||||
window.location.href = data.url
|
||||
return data
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 判断浏览器地址栏是否处于「企微绑定页 OAuth 回调」(带 code)。
|
||||
* 用于避免 axios 在 router.currentRoute 尚未同步完成时执行 router.replace('/bind-work-wechat') 把 query 整段清掉。
|
||||
*/
|
||||
export function isBrowserOnWecomBindOAuthLanding(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
const { pathname, search } = window.location
|
||||
if (!pathname.includes('bind-work-wechat')) return false
|
||||
return /(?:^|[?&])code=/.test(search)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 企微 WwLogin:iframe 内授权完成后通过 postMessage 把「完整回调 URL」交给父窗口,
|
||||
* 随后官方脚本会执行 window.location.href = url 触发整页刷新。
|
||||
* 部分环境下刷新后地址栏没有 code,Vue 不会走 URL 分支 → bindWorkWechat 从不发起。
|
||||
* 在 window 上以 capture 监听 message,抢先解析 code 并 stopImmediatePropagation,避免整页跳转。
|
||||
*/
|
||||
|
||||
const DEFAULT_STATE = 'bind_wxwork'
|
||||
|
||||
function isWecomPostMessageOrigin(origin: string): boolean {
|
||||
return origin.includes('work.weixin.qq.com') || origin.includes('tencent.com')
|
||||
}
|
||||
|
||||
function parseOAuthAccept(
|
||||
params: URLSearchParams,
|
||||
wxBindState: string
|
||||
): { code: string } | null {
|
||||
const wxCode = (params.get('code') || '').trim()
|
||||
if (!wxCode) return null
|
||||
const wxState = (params.get('state') ?? '').trim()
|
||||
const stateOk =
|
||||
wxState === wxBindState ||
|
||||
wxState === 'admin_bind_wx' ||
|
||||
wxState.toLowerCase() === wxBindState.toLowerCase()
|
||||
const bindFlagOk = params.get('bind_wxwork') === '1'
|
||||
const implicitOk = wxState === ''
|
||||
if (!(stateOk || bindFlagOk || implicitOk)) return null
|
||||
return { code: wxCode }
|
||||
}
|
||||
|
||||
export type WecomOauthCaptureOptions = {
|
||||
/** 回调 URL 的 pathname 须包含此片段,如 bind-work-wechat、user/setting */
|
||||
pathIncludes: string
|
||||
lockKey: string
|
||||
wxBindState?: string
|
||||
onCode: (code: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns 卸载时调用以 removeEventListener
|
||||
*/
|
||||
export function attachWecomOAuthMessageCapture(options: WecomOauthCaptureOptions): () => void {
|
||||
const wxBindState = options.wxBindState ?? DEFAULT_STATE
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (!isWecomPostMessageOrigin(event.origin)) return
|
||||
const data = event.data
|
||||
if (typeof data !== 'string' || !/^https?:\/\//i.test(data)) return
|
||||
let u: URL
|
||||
try {
|
||||
u = new URL(data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (u.host !== window.location.host) return
|
||||
if (!u.pathname.includes(options.pathIncludes)) return
|
||||
|
||||
const parsed = parseOAuthAccept(new URLSearchParams(u.search), wxBindState)
|
||||
if (!parsed) return
|
||||
if (sessionStorage.getItem(options.lockKey) === parsed.code) return
|
||||
|
||||
event.stopImmediatePropagation()
|
||||
sessionStorage.setItem(options.lockKey, parsed.code)
|
||||
void Promise.resolve(options.onCode(parsed.code)).finally(() => {
|
||||
sessionStorage.removeItem(options.lockKey)
|
||||
})
|
||||
}
|
||||
window.addEventListener('message', handler, true)
|
||||
return () => window.removeEventListener('message', handler, true)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="bind-wx flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="bind-wx-card bg-body rounded-md px-10 py-10 w-[480px]">
|
||||
<div class="text-center text-2xl font-medium mb-2">绑定企业微信</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-8">
|
||||
根据安全策略,需绑定企业微信账号后方可使用管理后台
|
||||
</div>
|
||||
|
||||
<div v-if="wxWorkAutoAuth" class="text-center py-10">
|
||||
<el-icon class="is-loading mb-4" :size="40" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="text-gray-500">企业微信授权中...</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="wxWorkLoading" class="text-center py-10">
|
||||
<el-icon class="is-loading" :size="32" color="var(--el-color-primary)">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<div class="mt-2 text-gray-400 text-sm">加载扫码...</div>
|
||||
</div>
|
||||
<div v-else id="wxwork_bind_qrcode_container" class="wxwork-qrcode mx-auto"></div>
|
||||
<div class="text-center text-sm text-gray-400 mt-4">
|
||||
请使用企业微信扫描二维码完成绑定
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mt-8 flex justify-center gap-4">
|
||||
<el-button @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<layout-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import LayoutFooter from '@/layout/components/footer.vue'
|
||||
import { bindWorkWechat, getWorkWechatConfig } from '@/api/user'
|
||||
import { PageEnum } from '@/enums/pageEnum'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { attachWecomOAuthMessageCapture } from '@/utils/wecomOauthPostMessage'
|
||||
|
||||
/** 与 user/setting.vue 一致,便于可信域名只配置一种回调 URL */
|
||||
const WX_BIND_STATE = 'bind_wxwork'
|
||||
|
||||
/** 企微 JSSDK 扫码后可能通过 postMessage 触发整页跳转;若在 await 前 replaceState 清掉 query,二次进入 onMounted 会拿不到 code,误走扫码初始化并取消 getConfig */
|
||||
const WX_BIND_OAUTH_LOCK = 'like_admin_wx_bind_oauth'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const wxWorkLoading = ref(false)
|
||||
const wxWorkAutoAuth = ref(false)
|
||||
const wxConfig = ref<{ corp_id: string; agent_id: string }>({ corp_id: '', agent_id: '' })
|
||||
|
||||
const isInWxWork = () => /wxwork/i.test(navigator.userAgent)
|
||||
|
||||
const getRedirectUri = () => window.location.origin + window.location.pathname + '?bind_wxwork=1'
|
||||
|
||||
/** 从 search 与 hash 中解析 OAuth 参数(少数网关/回跳会把参数放在 hash 后) */
|
||||
function getOAuthQuery(): URLSearchParams {
|
||||
const merged = new URLSearchParams(window.location.search)
|
||||
const hash = window.location.hash || ''
|
||||
const hashQuery = hash.includes('?') ? hash.split('?').slice(1).join('?') : ''
|
||||
if (hashQuery) {
|
||||
new URLSearchParams(hashQuery).forEach((v, k) => {
|
||||
if (!merged.has(k)) merged.set(k, v)
|
||||
})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
}
|
||||
|
||||
const afterBindSuccess = async () => {
|
||||
try {
|
||||
await userStore.getUserInfo()
|
||||
} catch {
|
||||
// 忽略:后续路由仍会拉取
|
||||
}
|
||||
if (userStore.isPaw === 0) {
|
||||
router.replace('/change-password')
|
||||
return
|
||||
}
|
||||
router.replace(PageEnum.INDEX)
|
||||
}
|
||||
|
||||
const submitBindCode = async (code: string) => {
|
||||
wxWorkAutoAuth.value = true
|
||||
try {
|
||||
await bindWorkWechat({ code })
|
||||
// 仅成功后再清 query;放 finally 会在失败时也清掉,用户一刷新 code 就没了、也无法 F5 重试
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
await afterBindSuccess()
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
ElMessage.error(e?.msg || e?.message || '绑定失败')
|
||||
wxWorkAutoAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let detachWecomOAuthCapture: (() => void) | null = null
|
||||
|
||||
const renderQr = async () => {
|
||||
if (!wxConfig.value.corp_id) return
|
||||
wxWorkLoading.value = true
|
||||
if (!(window as any).WwLogin) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://wwcdn.weixin.qq.com/node/wework/wwopen/js/wwLogin-1.2.7.js'
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error('加载企业微信 JS 失败'))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
await nextTick()
|
||||
wxWorkLoading.value = false
|
||||
await nextTick()
|
||||
const redirectUri = encodeURIComponent(getRedirectUri())
|
||||
new (window as any).WwLogin({
|
||||
id: 'wxwork_bind_qrcode_container',
|
||||
appid: wxConfig.value.corp_id,
|
||||
agentid: wxConfig.value.agent_id,
|
||||
redirect_uri: redirectUri,
|
||||
lang: 'zh',
|
||||
state: WX_BIND_STATE,
|
||||
href: '',
|
||||
self_redirect: false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 与 axios 拦截器竞态:首屏若有请求先返回 10,可能在 onMounted 执行前就 router.replace 清掉 query;同步快照尽量早读
|
||||
let oauthCodeSnapshot = ''
|
||||
try {
|
||||
oauthCodeSnapshot = (new URLSearchParams(window.location.search).get('code') || '').trim()
|
||||
} catch {
|
||||
oauthCodeSnapshot = ''
|
||||
}
|
||||
|
||||
if (!getToken()) {
|
||||
ElMessage.error('请先登录')
|
||||
router.push(PageEnum.LOGIN)
|
||||
return
|
||||
}
|
||||
|
||||
const urlParams = getOAuthQuery()
|
||||
const wxCode = oauthCodeSnapshot || (urlParams.get('code') || '').trim()
|
||||
const wxStateRaw = urlParams.get('state')
|
||||
const wxState = wxStateRaw != null ? String(wxStateRaw).trim() : ''
|
||||
const legacyStateOk = wxState === 'admin_bind_wx'
|
||||
const stateOk =
|
||||
wxState === WX_BIND_STATE ||
|
||||
legacyStateOk ||
|
||||
wxState.toLowerCase() === WX_BIND_STATE.toLowerCase()
|
||||
const bindFlagOk = urlParams.get('bind_wxwork') === '1'
|
||||
// 扫码组件回跳有时只带 code,不带 state / bind_wxwork;若仍严格校验则刷新后永远进不了绑定逻辑
|
||||
const bindPageImplicitOk =
|
||||
!!wxCode && wxState === '' && router.currentRoute.value.path === '/bind-work-wechat'
|
||||
|
||||
if (wxCode && (stateOk || bindFlagOk || bindPageImplicitOk)) {
|
||||
if (sessionStorage.getItem(WX_BIND_OAUTH_LOCK) === wxCode) {
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(WX_BIND_OAUTH_LOCK, wxCode)
|
||||
try {
|
||||
await submitBindCode(wxCode)
|
||||
} finally {
|
||||
sessionStorage.removeItem(WX_BIND_OAUTH_LOCK)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res: any = await getWorkWechatConfig()
|
||||
if (!res?.enabled || !res.corp_id) {
|
||||
ElMessage.error('企业微信未配置,请联系管理员')
|
||||
return
|
||||
}
|
||||
wxConfig.value = { corp_id: res.corp_id, agent_id: res.agent_id }
|
||||
|
||||
if (isInWxWork()) {
|
||||
wxWorkAutoAuth.value = true
|
||||
const redirectUri = encodeURIComponent(getRedirectUri())
|
||||
const authUrl =
|
||||
`https://open.weixin.qq.com/connect/oauth2/authorize` +
|
||||
`?appid=${res.corp_id}` +
|
||||
`&redirect_uri=${redirectUri}` +
|
||||
`&response_type=code` +
|
||||
`&scope=snsapi_privateinfo` +
|
||||
`&agentid=${res.agent_id}` +
|
||||
`&state=${WX_BIND_STATE}` +
|
||||
`#wechat_redirect`
|
||||
window.location.href = authUrl
|
||||
return
|
||||
}
|
||||
|
||||
detachWecomOAuthCapture?.()
|
||||
detachWecomOAuthCapture = attachWecomOAuthMessageCapture({
|
||||
pathIncludes: 'bind-work-wechat',
|
||||
lockKey: WX_BIND_OAUTH_LOCK,
|
||||
wxBindState: WX_BIND_STATE,
|
||||
onCode: (code) => submitBindCode(code)
|
||||
})
|
||||
await renderQr()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('获取企业微信配置失败')
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
detachWecomOAuthCapture?.()
|
||||
detachWecomOAuthCapture = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bind-wx {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
}
|
||||
|
||||
.wxwork-qrcode {
|
||||
width: 340px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
:deep(iframe) {
|
||||
width: 340px !important;
|
||||
height: 400px !important;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -145,13 +145,16 @@ const handleLogin = async () => {
|
||||
account: remAccount.value ? formData.account : ''
|
||||
})
|
||||
const result: any = await userStore.login(formData)
|
||||
|
||||
|
||||
// 检查是否需要修改密码
|
||||
if (result.is_paw === 0) {
|
||||
router.push('/change-password')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.need_bind_work_wechat) {
|
||||
router.push('/bind-work-wechat')
|
||||
return
|
||||
}
|
||||
redirectAfterLogin()
|
||||
}
|
||||
|
||||
@@ -168,13 +171,16 @@ const handleWxWorkCodeLogin = async (code: string) => {
|
||||
wxWorkAutoLogin.value = true
|
||||
try {
|
||||
const result: any = await userStore.workWechatLogin(code)
|
||||
|
||||
|
||||
// 检查是否需要修改密码
|
||||
if (result.is_paw === 0) {
|
||||
router.push('/change-password')
|
||||
return
|
||||
}
|
||||
|
||||
if (result.need_bind_work_wechat) {
|
||||
router.push('/bind-work-wechat')
|
||||
return
|
||||
}
|
||||
redirectAfterLogin()
|
||||
} catch (error: any) {
|
||||
console.error('企业微信登录失败:', error)
|
||||
|
||||
@@ -81,12 +81,20 @@
|
||||
<el-card v-loading="pager.loading" class="mt-3 table-card" shadow="never">
|
||||
<div class="pl-table-head">
|
||||
<span class="pl-table-head__title">处方列表</span>
|
||||
<el-button type="primary" @click="handleAdd" v-perms="['cf.prescription/add']">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增处方
|
||||
</el-button>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/lists']"
|
||||
@click="goBusinessOrderList"
|
||||
>
|
||||
业务订单
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd" v-perms="['cf.prescription/add']">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
新增处方
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
@@ -104,11 +112,31 @@
|
||||
|
||||
|
||||
|
||||
<el-table-column label="审核状态" min-width="100">
|
||||
<el-table-column label="审核状态" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="auditStatusTagType(row.audit_status)">
|
||||
{{ auditStatusLabel(row.audit_status) }}
|
||||
</el-tag>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<el-tag :type="listAuditStatusTagType(row)">
|
||||
{{ listAuditStatusLabel(row) }}
|
||||
</el-tag>
|
||||
<span
|
||||
v-if="Number(row.business_prescription_audit_rejected) === 1"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
(业务订单审核)
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="listRejectReasonLines(row).length">
|
||||
<div
|
||||
v-for="(line, idx) in listRejectReasonLines(row)"
|
||||
:key="idx"
|
||||
class="text-xs text-red-600 leading-snug line-clamp-3"
|
||||
:title="line"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作废" min-width="80">
|
||||
@@ -124,13 +152,22 @@
|
||||
{{ formatListCreateTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleView(row)" v-perms="['cf.prescription/read']">
|
||||
查看
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!isApprovedActivePrescription(row)"
|
||||
v-if="!Number(row.has_prescription_order)"
|
||||
type="success"
|
||||
link
|
||||
v-perms="['tcm.prescriptionOrder/create']"
|
||||
@click="openCreateOrderFromPrescription(row)"
|
||||
>
|
||||
创建订单
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!isApprovedActivePrescription(row) || Number(row.business_prescription_audit_rejected) === 1"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
@@ -183,6 +220,12 @@
|
||||
<h2 class="cf-slip-title">{{ SLIP_HOSPITAL_NAME }}处方笺</h2>
|
||||
<div class="cf-slip-type">
|
||||
<el-tag v-if="Number(slipView.void_status) === 1" type="danger" size="small">已作废</el-tag>
|
||||
<el-tag
|
||||
v-else-if="Number(slipView.business_prescription_audit_rejected) === 1"
|
||||
type="danger"
|
||||
size="small"
|
||||
class="mr-1"
|
||||
>已驳回</el-tag>
|
||||
<el-tag
|
||||
v-else-if="Number(slipView.audit_status) === 1"
|
||||
type="success"
|
||||
@@ -205,6 +248,24 @@
|
||||
<div v-if="Number(slipView.void_status) === 1" class="cf-slip-void">
|
||||
作废人:{{ slipView.void_by_name || '—' }},作废时间:{{ formatVoidTime(slipView.void_time) }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="Number(slipView.business_prescription_audit_rejected) === 1"
|
||||
class="rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
|
||||
>
|
||||
<div class="font-medium">审核状态:已驳回(业务订单·处方审核)</div>
|
||||
<div v-if="slipView.business_prescription_audit_remark" class="mt-1">
|
||||
驳回意见:{{ slipView.business_prescription_audit_remark }}
|
||||
</div>
|
||||
<div v-else class="mt-1 text-red-600/80">暂无驳回意见说明。</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="Number(slipView.audit_status) === 2"
|
||||
class="rounded border border-red-200 bg-red-50 text-red-800 text-sm px-3 py-2 mb-3"
|
||||
>
|
||||
<div class="font-medium">消费者处方审核:已驳回</div>
|
||||
<div v-if="slipView.audit_remark" class="mt-1">驳回意见:{{ slipView.audit_remark }}</div>
|
||||
<div v-else class="mt-1 text-red-600/80">暂无文字意见,请联系审核人员。</div>
|
||||
</div>
|
||||
<div class="cf-slip-info">
|
||||
<div class="cf-slip-row">
|
||||
<span class="cf-slip-item"><em>日期</em>{{ slipView.prescription_date || '—' }}</span>
|
||||
@@ -293,6 +354,42 @@
|
||||
class="mb-4"
|
||||
title="保存修改后,该处方将重新进入「待审核」,需审核人员再次通过后方可生效。"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="editMode === 'edit' && Number(editForm.audit_status) === 2"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>当前消费者处方为「已驳回」</template>
|
||||
<div class="text-sm mt-1">
|
||||
保存修改后将清除驳回并变为「待审核」。{{
|
||||
editForm.audit_remark ? `上次意见:${editForm.audit_remark}` : ''
|
||||
}}
|
||||
</div>
|
||||
</el-alert>
|
||||
<el-alert
|
||||
v-if="
|
||||
editMode === 'edit' &&
|
||||
Number(editForm.business_prescription_audit_rejected) === 1 &&
|
||||
Number(editForm.audit_status) === 1
|
||||
"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
>
|
||||
<template #title>业务订单侧「处方审核」已驳回</template>
|
||||
<div class="text-sm mt-1">
|
||||
消费者处方仍为「已通过」;您修改并保存后,将重置关联业务订单的处方/支付单审核为「待审」,请在「处方业务订单」中继续处理。
|
||||
</div>
|
||||
<div
|
||||
v-if="String(editForm.business_prescription_audit_remark || '').trim()"
|
||||
class="text-sm mt-2 text-gray-800 font-medium"
|
||||
>
|
||||
驳回意见:{{ editForm.business_prescription_audit_remark }}
|
||||
</div>
|
||||
</el-alert>
|
||||
<el-alert
|
||||
v-if="editMode === 'edit' && editReviveHint"
|
||||
type="warning"
|
||||
@@ -600,6 +697,183 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 从消费者处方创建业务订单(zyt_tcm_prescription_order,与支付单 zyt_order 分离) -->
|
||||
<el-dialog
|
||||
v-model="createOrderVisible"
|
||||
title="创建业务订单"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
@close="resetCreateOrderForm"
|
||||
>
|
||||
<div v-if="createOrderPrescription" class="mb-4 rounded bg-gray-50 px-3 py-2 text-sm text-gray-700">
|
||||
<div class="font-medium text-gray-900">关联消费者处方</div>
|
||||
<div>
|
||||
处方 ID {{ createOrderPrescription.id }}
|
||||
<span v-if="createOrderPrescription.sn"> · 编号 {{ createOrderPrescription.sn }}</span>
|
||||
· {{ createOrderPrescription.patient_name || '—' }}
|
||||
</div>
|
||||
<div class="text-gray-500 mt-1 line-clamp-2">
|
||||
{{ createOrderHerbSummary }}
|
||||
</div>
|
||||
</div>
|
||||
<el-form
|
||||
ref="createOrderFormRef"
|
||||
:model="createOrderForm"
|
||||
:rules="createOrderRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-divider content-position="left">患者与收货</el-divider>
|
||||
<el-form-item label="诊单患者" prop="patient_id">
|
||||
<el-select
|
||||
v-model="createOrderForm.patient_id"
|
||||
class="w-full"
|
||||
placeholder="搜索姓名/手机/身份证"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchPatientsForOrder"
|
||||
:loading="createOrderPatientLoading"
|
||||
@change="onCreateOrderPatientChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in createOrderPatientList"
|
||||
:key="item.id"
|
||||
:label="`${item.patient_name} (${item.phone || '无手机'})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="收货人" prop="recipient_name">
|
||||
<el-input v-model="createOrderForm.recipient_name" placeholder="可与诊单姓名一致" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货手机" prop="recipient_phone">
|
||||
<el-input v-model="createOrderForm.recipient_phone" placeholder="用于物流联系与单号匹配" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货地址" prop="shipping_address">
|
||||
<el-input
|
||||
v-model="createOrderForm.shipping_address"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="完整收货地址"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">服务信息</el-divider>
|
||||
<el-form-item label="是否复诊">
|
||||
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用药疗程(天)">
|
||||
<el-input-number
|
||||
v-model="createOrderForm.medication_days"
|
||||
:min="1"
|
||||
:max="999"
|
||||
:step="1"
|
||||
placeholder="天数"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上次医生/医助">
|
||||
<el-input v-model="createOrderForm.prev_staff" placeholder="可选" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务渠道">
|
||||
<el-input v-model="createOrderForm.service_channel" placeholder="可选" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务套餐">
|
||||
<el-input v-model="createOrderForm.service_package" placeholder="可选" maxlength="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">物流</el-divider>
|
||||
<el-form-item label="快递公司">
|
||||
<el-select v-model="createOrderForm.express_company" placeholder="查物流用" class="w-full">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号">
|
||||
<el-input v-model="createOrderForm.tracking_number" placeholder="可创建时填写或发货前补录" maxlength="80" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">关联收款</el-divider>
|
||||
<el-form-item label="已支付单">
|
||||
<el-select
|
||||
v-model="createOrderForm.pay_order_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="多选本诊单下已支付收款单"
|
||||
:loading="createOrderPaidOrdersLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in createOrderPaidOrders"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount}`"
|
||||
:value="o.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">
|
||||
与「订单管理」中已支付订单一致;主管或本诊单医助可见该诊单全部已支付单,否则仅本人创建的收款单。已被其他有效业务订单占用的支付单不会出现在此列表。业务订单撤回后,本处方可再次创建订单并重新关联支付单。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">金额</el-divider>
|
||||
<el-form-item label="费用类别" prop="fee_type">
|
||||
<el-select v-model="createOrderForm.fee_type" placeholder="请选择" class="w-full">
|
||||
<el-option label="挂号费" :value="1" />
|
||||
<el-option label="问诊费" :value="2" />
|
||||
<el-option label="药品费用" :value="3" />
|
||||
<el-option label="首付费用" :value="4" />
|
||||
<el-option label="尾款费用" :value="5" />
|
||||
<el-option label="其他费用" :value="6" />
|
||||
<el-option label="全部费用" :value="7" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单金额" prop="amount">
|
||||
<el-input-number
|
||||
v-model="createOrderForm.amount"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
placeholder="业务订单金额(元)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-perms="['finance.account_log/lists']"
|
||||
label="内部成本"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="createOrderForm.internal_cost"
|
||||
:min="0"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
placeholder="仅财务角色写入库表"
|
||||
/>
|
||||
<div class="text-xs text-gray-400 mt-1">写入业务订单表字段;无财务权限时后端不会保存该值。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注补充">
|
||||
<el-input
|
||||
v-model="createOrderForm.remark_extra"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="其他说明(可选)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createOrderVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createOrderSubmitLoading" @click="submitCreateOrderFromPrescription">
|
||||
创建业务订单
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="auditDialogVisible" title="处方审核" width="480px" destroy-on-close @closed="auditRemark = ''">
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
通过:处方保持有效。驳回:将同时作废此处方(与诊间「驳回/作废处方」一致)。
|
||||
@@ -633,15 +907,19 @@ import {
|
||||
prescriptionDelete,
|
||||
prescriptionAudit,
|
||||
prescriptionDetail,
|
||||
prescriptionOrderCreate,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
getDoctors
|
||||
} from '@/api/tcm'
|
||||
import { searchPatients as searchPatientsAPI } from '@/api/order'
|
||||
import { roleAll } from '@/api/perms/role'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
/** 与 server/config/project.php prescription_audit_roles 保持一致 */
|
||||
const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3]
|
||||
@@ -652,6 +930,16 @@ const SLIP_COMPANY_LINE = '成都双流甄养堂互联网医院有限公司 联
|
||||
const SLIP_ADDRESS_LINE = '地址:四川省成都市双流区黄甲街道黄龙大道二段280号'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
function goBusinessOrderList() {
|
||||
const hit = router.getRoutes().find((r) => r.meta?.perms === 'tcm.prescriptionOrder/lists')
|
||||
if (hit?.path) {
|
||||
router.push(hit.path)
|
||||
} else {
|
||||
feedback.msgWarning('未配置「处方业务订单」菜单,请在权限管理中执行菜单 SQL 或手动添加路由')
|
||||
}
|
||||
}
|
||||
|
||||
// 表单数据(与列表接口查询参数一致)
|
||||
const formData = reactive({
|
||||
@@ -684,6 +972,212 @@ const auditTargetId = ref(0)
|
||||
const auditRemark = ref('')
|
||||
const auditLoading = ref(false)
|
||||
|
||||
/** 从消费者处方创建订单 */
|
||||
const createOrderVisible = ref(false)
|
||||
const createOrderPrescription = ref<any>(null)
|
||||
const createOrderFormRef = ref<FormInstance>()
|
||||
const createOrderSubmitLoading = ref(false)
|
||||
const createOrderPatientLoading = ref(false)
|
||||
const createOrderPatientList = ref<any[]>([])
|
||||
const createOrderPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string }>>([])
|
||||
const createOrderPaidOrdersLoading = ref(false)
|
||||
|
||||
const createOrderForm = reactive({
|
||||
patient_id: '' as number | string,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
shipping_address: '',
|
||||
is_follow_up: 0,
|
||||
medication_days: undefined as number | undefined,
|
||||
prev_staff: '',
|
||||
service_channel: '',
|
||||
service_package: '',
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
internal_cost: undefined as number | undefined,
|
||||
remark_extra: '',
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
const createOrderRules: FormRules = {
|
||||
patient_id: [{ required: true, message: '请选择诊单患者', trigger: 'change' }],
|
||||
recipient_name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
|
||||
recipient_phone: [{ required: true, message: '请输入收货手机', trigger: 'blur' }],
|
||||
shipping_address: [{ required: true, message: '请输入收货地址', trigger: 'blur' }],
|
||||
fee_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
|
||||
amount: [{ required: true, message: '请输入订单金额', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const createOrderHerbSummary = computed(() => {
|
||||
const row = createOrderPrescription.value
|
||||
const herbs = normalizeSlipHerbs(row?.herbs)
|
||||
if (!herbs.length) return '暂无药材明细'
|
||||
const s = herbs.map((h) => `${h.name} ${h.dosage}g`).join('、')
|
||||
return s.length > 120 ? `${s.slice(0, 120)}…` : s
|
||||
})
|
||||
|
||||
function resetCreateOrderForm() {
|
||||
createOrderForm.patient_id = ''
|
||||
createOrderForm.recipient_name = ''
|
||||
createOrderForm.recipient_phone = ''
|
||||
createOrderForm.shipping_address = ''
|
||||
createOrderForm.is_follow_up = 0
|
||||
createOrderForm.medication_days = undefined
|
||||
createOrderForm.prev_staff = ''
|
||||
createOrderForm.service_channel = ''
|
||||
createOrderForm.service_package = ''
|
||||
createOrderForm.express_company = 'auto'
|
||||
createOrderForm.tracking_number = ''
|
||||
createOrderForm.fee_type = 3
|
||||
createOrderForm.amount = 0
|
||||
createOrderForm.internal_cost = undefined
|
||||
createOrderForm.remark_extra = ''
|
||||
createOrderForm.pay_order_ids = []
|
||||
createOrderPatientList.value = []
|
||||
createOrderPaidOrders.value = []
|
||||
createOrderPrescription.value = null
|
||||
createOrderFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function loadCreateOrderPaidOrders() {
|
||||
const did = Number(createOrderForm.patient_id)
|
||||
if (!did) {
|
||||
createOrderPaidOrders.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
createOrderPaidOrdersLoading.value = true
|
||||
const res: any = await prescriptionOrderPaidPayOrders({ diagnosis_id: did })
|
||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
||||
createOrderPaidOrders.value = Array.isArray(lists) ? lists : []
|
||||
} catch {
|
||||
createOrderPaidOrders.value = []
|
||||
} finally {
|
||||
createOrderPaidOrdersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => createOrderForm.patient_id,
|
||||
() => {
|
||||
createOrderForm.pay_order_ids = []
|
||||
void loadCreateOrderPaidOrders()
|
||||
}
|
||||
)
|
||||
|
||||
async function openCreateOrderFromPrescription(row: any) {
|
||||
resetCreateOrderForm()
|
||||
createOrderPrescription.value = row
|
||||
const did = Number(row.diagnosis_id) || 0
|
||||
if (did > 0) {
|
||||
createOrderForm.patient_id = did
|
||||
createOrderForm.recipient_name = row.patient_name || ''
|
||||
createOrderForm.recipient_phone = String(row.phone || '').trim()
|
||||
createOrderPatientList.value = [
|
||||
{
|
||||
id: did,
|
||||
patient_name: row.patient_name || '',
|
||||
phone: String(row.phone || '').trim()
|
||||
}
|
||||
]
|
||||
}
|
||||
const ud = Number(row.usage_days)
|
||||
if (ud > 0) {
|
||||
createOrderForm.medication_days = ud
|
||||
}
|
||||
createOrderVisible.value = true
|
||||
void loadCreateOrderPaidOrders()
|
||||
const rxId = row.id
|
||||
if (rxId && !createOrderForm.recipient_phone) {
|
||||
try {
|
||||
const d = (await prescriptionDetail({ id: rxId })) as Record<string, unknown> | null
|
||||
const phone = d && typeof d.phone === 'string' ? d.phone.trim() : ''
|
||||
if (phone && createOrderPrescription.value?.id === rxId) {
|
||||
createOrderForm.recipient_phone = phone
|
||||
const first = createOrderPatientList.value[0]
|
||||
if (first && Number(first.id) === did) {
|
||||
first.phone = phone
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 列表无电话时尽力补全,失败忽略 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function searchPatientsForOrder(query: string) {
|
||||
if (!query) {
|
||||
createOrderPatientList.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
createOrderPatientLoading.value = true
|
||||
const res: any = await searchPatientsAPI({
|
||||
keyword: query,
|
||||
page_no: 1,
|
||||
page_size: 10
|
||||
})
|
||||
createOrderPatientList.value = res?.lists || []
|
||||
} catch {
|
||||
createOrderPatientList.value = []
|
||||
} finally {
|
||||
createOrderPatientLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateOrderPatientChange(id: number | string) {
|
||||
const p = createOrderPatientList.value.find((x) => Number(x.id) === Number(id))
|
||||
if (p) {
|
||||
createOrderForm.recipient_name = p.patient_name || ''
|
||||
createOrderForm.recipient_phone = p.phone || ''
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreateOrderFromPrescription() {
|
||||
if (!createOrderFormRef.value || !createOrderPrescription.value) return
|
||||
try {
|
||||
await createOrderFormRef.value.validate()
|
||||
createOrderSubmitLoading.value = true
|
||||
const diagnosisId = Number(createOrderForm.patient_id)
|
||||
const payload: Record<string, unknown> = {
|
||||
prescription_id: createOrderPrescription.value.id,
|
||||
diagnosis_id: diagnosisId,
|
||||
recipient_name: createOrderForm.recipient_name,
|
||||
recipient_phone: createOrderForm.recipient_phone,
|
||||
shipping_address: createOrderForm.shipping_address,
|
||||
is_follow_up: createOrderForm.is_follow_up,
|
||||
prev_staff: createOrderForm.prev_staff || '',
|
||||
service_channel: createOrderForm.service_channel || '',
|
||||
service_package: createOrderForm.service_package || '',
|
||||
express_company: createOrderForm.express_company || 'auto',
|
||||
tracking_number: createOrderForm.tracking_number || '',
|
||||
fee_type: createOrderForm.fee_type,
|
||||
amount: createOrderForm.amount,
|
||||
remark_extra: createOrderForm.remark_extra || ''
|
||||
}
|
||||
if (createOrderForm.medication_days != null && createOrderForm.medication_days > 0) {
|
||||
payload.medication_days = createOrderForm.medication_days
|
||||
}
|
||||
if (createOrderForm.internal_cost != null && createOrderForm.internal_cost > 0) {
|
||||
payload.internal_cost = createOrderForm.internal_cost
|
||||
}
|
||||
if (createOrderForm.pay_order_ids.length) {
|
||||
payload.pay_order_ids = [...createOrderForm.pay_order_ids]
|
||||
}
|
||||
const res: any = await prescriptionOrderCreate(payload)
|
||||
const row = res?.data ?? res
|
||||
const orderNo = row?.order_no
|
||||
feedback.msgSuccess(orderNo ? `业务订单已创建,单号:${orderNo}` : '业务订单已创建')
|
||||
createOrderVisible.value = false
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
createOrderSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在编辑的这条是否原为已作废 / 已驳回(用于提示) */
|
||||
const editingWasVoid = ref(false)
|
||||
const editingWasRejected = ref(false)
|
||||
@@ -739,7 +1233,10 @@ const editForm = reactive({
|
||||
audit_time: null as number | null,
|
||||
audit_by_name: '',
|
||||
audit_remark: '',
|
||||
diagnosis_id: 0
|
||||
diagnosis_id: 0,
|
||||
/** 列表带入:业务订单「处方审核」驳回(消费者处方本身可能仍为已通过) */
|
||||
business_prescription_audit_rejected: 0,
|
||||
business_prescription_audit_remark: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
@@ -891,6 +1388,35 @@ function auditStatusTagType(v: number | undefined) {
|
||||
return 'success'
|
||||
}
|
||||
|
||||
/** 业务订单处方审核驳回时,列表「审核状态」主标签显示为已驳回 */
|
||||
function listAuditStatusLabel(row: { audit_status?: number; business_prescription_audit_rejected?: number }) {
|
||||
if (Number(row.business_prescription_audit_rejected) === 1) return '已驳回'
|
||||
return auditStatusLabel(row.audit_status)
|
||||
}
|
||||
|
||||
function listAuditStatusTagType(row: { audit_status?: number; business_prescription_audit_rejected?: number }) {
|
||||
if (Number(row.business_prescription_audit_rejected) === 1) return 'danger'
|
||||
return auditStatusTagType(row.audit_status)
|
||||
}
|
||||
|
||||
function listRejectReasonLines(row: {
|
||||
audit_status?: number
|
||||
audit_remark?: string
|
||||
business_prescription_audit_rejected?: number
|
||||
business_prescription_audit_remark?: string
|
||||
}): string[] {
|
||||
const out: string[] = []
|
||||
if (Number(row.business_prescription_audit_rejected) === 1) {
|
||||
const t = String(row.business_prescription_audit_remark || '').trim()
|
||||
out.push(t ? `业务订单审核驳回意见:${t}` : '业务订单审核驳回意见:—')
|
||||
}
|
||||
if (Number(row.audit_status) === 2) {
|
||||
const t = String(row.audit_remark || '').trim()
|
||||
out.push(t ? `消费者处方审核驳回意见:${t}` : '消费者处方审核驳回意见:—')
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 已通过审核且未作废:仅可查看,不可编辑/删除(与预约列表「查看」一致) */
|
||||
function isApprovedActivePrescription(row: { audit_status?: number; void_status?: number }) {
|
||||
return Number(row.audit_status) === 1 && Number(row.void_status) !== 1
|
||||
@@ -1043,6 +1569,8 @@ const resetForm = () => {
|
||||
editForm.audit_by_name = ''
|
||||
editForm.audit_remark = ''
|
||||
editForm.diagnosis_id = 0
|
||||
editForm.business_prescription_audit_rejected = 0
|
||||
editForm.business_prescription_audit_remark = ''
|
||||
}
|
||||
|
||||
// 新增处方
|
||||
@@ -1112,6 +1640,8 @@ const handleEdit = (row: any) => {
|
||||
editForm.audit_by_name = row.audit_by_name || ''
|
||||
editForm.audit_remark = row.audit_remark || ''
|
||||
editForm.diagnosis_id = row.diagnosis_id ?? 0
|
||||
editForm.business_prescription_audit_rejected = Number(row.business_prescription_audit_rejected) === 1 ? 1 : 0
|
||||
editForm.business_prescription_audit_remark = String(row.business_prescription_audit_remark || '')
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
<!-- 处方业务订单(zyt_tcm_prescription_order,非支付单) -->
|
||||
<template>
|
||||
<div class="prescription-order-list px-4 py-4">
|
||||
<el-card class="!border-none" shadow="never">
|
||||
<el-form class="ls-form" :model="queryParams" inline>
|
||||
<el-form-item class="w-[260px]" label="订单号">
|
||||
<el-input
|
||||
v-model="queryParams.order_no"
|
||||
placeholder="业务订单号"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[140px]" label="处方ID">
|
||||
<el-input
|
||||
v-model="queryParams.prescription_id"
|
||||
placeholder="处方ID"
|
||||
clearable
|
||||
@keyup.enter="resetPage"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item class="w-[180px]" label="履约状态">
|
||||
<el-select v-model="queryParams.fulfillment_status" placeholder="全部" clearable>
|
||||
<el-option label="待双审通过" :value="1" />
|
||||
<el-option label="履约中" :value="2" />
|
||||
<el-option label="已完成" :value="3" />
|
||||
<el-option label="已取消" :value="4" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="resetPage">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="pager.loading" class="!border-none mt-3" shadow="never">
|
||||
<div class="text-base font-medium mb-3">处方业务订单</div>
|
||||
<el-table :data="pager.lists" size="default" stripe>
|
||||
<el-table-column label="订单号" prop="order_no" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="处方ID" prop="prescription_id" width="86" />
|
||||
<el-table-column label="诊单ID" prop="diagnosis_id" width="86" />
|
||||
<el-table-column label="收货人" min-width="100">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.recipient_name || '—' }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.recipient_phone || '' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="费用类别" width="96">
|
||||
<template #default="{ row }">
|
||||
{{ feeTypeText(row.fee_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单金额" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="text-red-500 font-medium">¥{{ row.amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内部成本" width="92">
|
||||
<template #default="{ row }">
|
||||
{{ row.internal_cost != null && row.internal_cost !== '' ? `¥${row.internal_cost}` : '—' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="96">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="auditTagType(row.prescription_audit_status)" size="small">
|
||||
{{ auditStatusText(row.prescription_audit_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付单审核" width="102">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="auditTagType(row.payment_slip_audit_status)" size="small">
|
||||
{{ auditStatusText(row.payment_slip_audit_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="履约" width="96">
|
||||
<template #default="{ row }">
|
||||
{{ fulfillmentText(row.fulfillment_status) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联支付单" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="Number(row.linked_pay_order_count) > 0">
|
||||
{{ row.linked_pay_order_count }} 笔
|
||||
<span v-if="row.linked_pay_order_id" class="text-gray-400 text-xs">(#{{ row.linked_pay_order_id }}…)</span>
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="148">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/detail']"
|
||||
type="primary"
|
||||
link
|
||||
@click="openDetail(row.id)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canEditRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/edit']"
|
||||
type="primary"
|
||||
link
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canRxAudit(row)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
type="warning"
|
||||
link
|
||||
@click="openAudit(row, 'prescription')"
|
||||
>
|
||||
处方审核
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canPayAudit(row)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPayment']"
|
||||
type="warning"
|
||||
link
|
||||
@click="openAudit(row, 'payment')"
|
||||
>
|
||||
支付审核
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canWithdrawRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/withdraw']"
|
||||
type="danger"
|
||||
link
|
||||
@click="confirmWithdraw(row)"
|
||||
>
|
||||
撤回
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex justify-end mt-3">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情 -->
|
||||
<el-dialog v-model="detailVisible" title="业务订单详情" width="720px" destroy-on-close>
|
||||
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||
<el-descriptions v-else-if="detailData" :column="2" border>
|
||||
<el-descriptions-item label="订单号" :span="2">{{ detailData.order_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方ID">{{ detailData.prescription_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="诊单ID">{{ detailData.diagnosis_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detailData.recipient_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ detailData.recipient_phone }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址" :span="2">{{ detailData.shipping_address }}</el-descriptions-item>
|
||||
<el-descriptions-item label="复诊">{{ detailData.is_follow_up ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="疗程(天)">{{ detailData.medication_days ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上次医护" :span="2">{{ detailData.prev_staff || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务渠道">{{ detailData.service_channel || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务套餐">{{ detailData.service_package || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递单号" :span="2">{{ detailData.tracking_number || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="快递公司" :span="2">
|
||||
{{ expressCompanyLabel(detailData.express_company) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类别">{{ feeTypeText(detailData.fee_type) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<span class="text-red-500 font-medium">¥{{ detailData.amount }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailData.internal_cost != null && detailData.internal_cost !== ''" label="内部成本">
|
||||
¥{{ detailData.internal_cost }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ detailData.remark_extra || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核">
|
||||
{{ auditStatusText(detailData.prescription_audit_status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="处方审核意见" :span="2">
|
||||
{{ detailData.prescription_audit_remark || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付单审核">
|
||||
{{ auditStatusText(detailData.payment_slip_audit_status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付单审核意见" :span="2">
|
||||
{{ detailData.payment_slip_audit_remark || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="履约状态">{{ fulfillmentText(detailData.fulfillment_status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联支付单" :span="2">
|
||||
<div v-if="detailLinkedPayOrders.length" class="space-y-1">
|
||||
<div
|
||||
v-for="o in detailLinkedPayOrders"
|
||||
:key="o.id"
|
||||
class="text-sm border-b border-gray-100 pb-1 last:border-0"
|
||||
>
|
||||
<span class="font-medium">#{{ o.id }}</span>
|
||||
{{ o.order_no || '—' }} · ¥{{ o.amount }} · {{ orderStatusText(o.status) }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间" :span="2">{{ formatTime(detailData.create_time) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div
|
||||
v-if="detailData && String(detailData.tracking_number || '').trim()"
|
||||
class="mt-4 border-t border-gray-100 pt-4"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">物流轨迹</div>
|
||||
<p class="text-xs text-gray-500 mb-3">
|
||||
优先从数据库查询历史轨迹(快速响应),数据库无记录时调用快递100实时查询。支持顺丰、京东、极兔速递等快递公司。顺丰查询建议填写正确收货手机后四位(本单收货手机已自动传入)。
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<el-select v-model="detailLogisticsExpress" placeholder="承运商" style="width: 140px">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
<el-option label="极兔速递" value="jt" />
|
||||
</el-select>
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/logisticsTrace']"
|
||||
type="default"
|
||||
size="small"
|
||||
:loading="logisticsTraceLoading"
|
||||
@click="fetchLogisticsTrace"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</template>
|
||||
刷新轨迹
|
||||
</el-button>
|
||||
<el-link :href="detailOfficialUrls.sf" target="_blank" type="primary" class="text-sm">顺丰官网查件</el-link>
|
||||
<el-link :href="detailOfficialUrls.jd" target="_blank" type="primary" class="text-sm">京东物流查件</el-link>
|
||||
<el-link :href="detailOfficialUrls.jt" target="_blank" type="primary" class="text-sm">极兔速递查件</el-link>
|
||||
</div>
|
||||
<el-alert
|
||||
v-if="String(logisticsTracePayload?.hint || '').trim()"
|
||||
:title="String(logisticsTracePayload?.hint || '')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-3"
|
||||
show-icon
|
||||
/>
|
||||
<div v-if="logisticsTraceLoading && !logisticsTracePayload" class="text-sm text-gray-500 mb-2">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
正在加载物流轨迹...
|
||||
</div>
|
||||
<div v-if="logisticsTracePayload?.state_text" class="text-sm mb-2">
|
||||
<span class="text-gray-700">物流状态:{{ logisticsTracePayload.state_text }}</span>
|
||||
<span v-if="logisticsTracePayload?.carrier_label" class="text-gray-500">
|
||||
({{ logisticsTracePayload.carrier_label }})
|
||||
</span>
|
||||
<span v-if="logisticsTracePayload?.source" class="text-xs text-gray-400 ml-2">
|
||||
[{{ logisticsTracePayload.source === 'database' ? '数据库' : 'API实时' }}]
|
||||
</span>
|
||||
<span v-if="logisticsTracePayload?.last_query_time" class="text-xs text-gray-400 ml-2">
|
||||
最后更新:{{ logisticsTracePayload.last_query_time }}
|
||||
</span>
|
||||
</div>
|
||||
<el-timeline v-if="logisticsTraceList.length">
|
||||
<el-timeline-item
|
||||
v-for="(t, idx) in logisticsTraceList"
|
||||
:key="idx"
|
||||
:timestamp="t.time"
|
||||
placement="top"
|
||||
>
|
||||
{{ t.context }}
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑 -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
title="编辑业务订单"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@closed="editFormRef?.clearValidate()"
|
||||
>
|
||||
<el-form
|
||||
v-loading="editDialogLoading"
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="editRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="收货人" prop="recipient_name">
|
||||
<el-input v-model="editForm.recipient_name" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货手机" prop="recipient_phone">
|
||||
<el-input v-model="editForm.recipient_phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货地址" prop="shipping_address">
|
||||
<el-input v-model="editForm.shipping_address" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="是否复诊">
|
||||
<el-switch v-model="editForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用药疗程(天)">
|
||||
<el-input-number v-model="editForm.medication_days" :min="1" :max="999" :step="1" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上次医生/医助">
|
||||
<el-input v-model="editForm.prev_staff" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务渠道">
|
||||
<el-input v-model="editForm.service_channel" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务套餐">
|
||||
<el-input v-model="editForm.service_package" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="快递公司">
|
||||
<el-select v-model="editForm.express_company" placeholder="用于查物流" class="w-full">
|
||||
<el-option label="自动识别" value="auto" />
|
||||
<el-option label="顺丰速运" value="sf" />
|
||||
<el-option label="京东快递" value="jd" />
|
||||
<el-option label="极兔速递" value="jt" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号">
|
||||
<el-input v-model="editForm.tracking_number" maxlength="80" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类别" prop="fee_type">
|
||||
<el-select v-model="editForm.fee_type" class="w-full">
|
||||
<el-option label="挂号费" :value="1" />
|
||||
<el-option label="问诊费" :value="2" />
|
||||
<el-option label="药品费用" :value="3" />
|
||||
<el-option label="首付费用" :value="4" />
|
||||
<el-option label="尾款费用" :value="5" />
|
||||
<el-option label="其他费用" :value="6" />
|
||||
<el-option label="全部费用" :value="7" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单金额" prop="amount">
|
||||
<el-input-number v-model="editForm.amount" :min="0" :step="0.01" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联已支付单">
|
||||
<el-select
|
||||
v-model="editForm.pay_order_ids"
|
||||
class="w-full"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="多选本诊单已支付收款单"
|
||||
:loading="editPaidOrdersLoading"
|
||||
>
|
||||
<el-option
|
||||
v-for="o in editPaidOrders"
|
||||
:key="o.id"
|
||||
:label="`${o.order_no} · ¥${o.amount}`"
|
||||
:value="o.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="text-xs text-gray-400 mt-1">支付单审核时可对照此处关联的收款记录。</div>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-perms="['finance.account_log/lists']"
|
||||
label="内部成本"
|
||||
>
|
||||
<el-input-number v-model="editForm.internal_cost" :min="0" :step="0.01" :precision="2" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注补充">
|
||||
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSaving" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核 -->
|
||||
<el-dialog
|
||||
v-model="auditVisible"
|
||||
:title="auditTitle"
|
||||
width="480px"
|
||||
destroy-on-close
|
||||
@closed="auditRemark = ''"
|
||||
>
|
||||
<p class="text-sm text-gray-600 mb-2">驳回时必须填写意见;通过时意见选填。</p>
|
||||
<el-input v-model="auditRemark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="审核意见" />
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="auditSubmitting" @click="submitAudit('approve')">通过</el-button>
|
||||
<el-button type="danger" :loading="auditSubmitting" @click="submitAudit('reject')">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup name="prescriptionOrderList">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, Loading } from '@element-plus/icons-vue'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderEdit,
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
prescriptionOrderWithdraw,
|
||||
prescriptionOrderLogisticsTrace
|
||||
} from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
|
||||
const queryParams = reactive({
|
||||
order_no: '',
|
||||
prescription_id: '' as string | number,
|
||||
fulfillment_status: '' as number | ''
|
||||
})
|
||||
|
||||
async function fetchLists(params: Record<string, unknown>) {
|
||||
const p: Record<string, unknown> = { ...params }
|
||||
if (p.prescription_id === '' || p.prescription_id === undefined) {
|
||||
delete p.prescription_id
|
||||
} else {
|
||||
p.prescription_id = Number(p.prescription_id)
|
||||
}
|
||||
if (p.fulfillment_status === '' || p.fulfillment_status === undefined) {
|
||||
delete p.fulfillment_status
|
||||
}
|
||||
if (!p.order_no) delete p.order_no
|
||||
return prescriptionOrderLists(p)
|
||||
}
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: fetchLists,
|
||||
params: queryParams
|
||||
})
|
||||
|
||||
function handleReset() {
|
||||
queryParams.order_no = ''
|
||||
queryParams.prescription_id = ''
|
||||
queryParams.fulfillment_status = ''
|
||||
resetParams()
|
||||
}
|
||||
|
||||
function feeTypeText(t: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '挂号费',
|
||||
2: '问诊费',
|
||||
3: '药品费用',
|
||||
4: '首付费用',
|
||||
5: '尾款费用',
|
||||
6: '其他费用',
|
||||
7: '全部费用'
|
||||
}
|
||||
return m[Number(t)] ?? '—'
|
||||
}
|
||||
|
||||
function auditStatusText(s: number | undefined) {
|
||||
if (s === 1) return '已通过'
|
||||
if (s === 2) return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
|
||||
function auditTagType(s: number | undefined): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (s === 1) return 'success'
|
||||
if (s === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function fulfillmentText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待双审通过',
|
||||
2: '履约中',
|
||||
3: '已完成',
|
||||
4: '已取消'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
const d = new Date(v * 1000)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
function canEditRow(row: { fulfillment_status?: number }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
|
||||
function canRxAudit(row: { prescription_audit_status?: number; fulfillment_status?: number }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4) return false
|
||||
return Number(row.prescription_audit_status) === 0
|
||||
}
|
||||
|
||||
function canPayAudit(row: {
|
||||
prescription_audit_status?: number
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4) return false
|
||||
return Number(row.prescription_audit_status) === 1 && Number(row.payment_slip_audit_status) === 0
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
const m: Record<number, string> = {
|
||||
1: '待支付',
|
||||
2: '已支付',
|
||||
3: '已取消',
|
||||
4: '已退款'
|
||||
}
|
||||
return m[Number(s)] ?? '—'
|
||||
}
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<Record<string, any> | null>(null)
|
||||
|
||||
const detailLinkedPayOrders = computed(() => {
|
||||
const d = detailData.value
|
||||
if (!d) return []
|
||||
const arr = d.linked_pay_orders
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const detailLogisticsExpress = ref<string>('auto')
|
||||
const logisticsTraceLoading = ref(false)
|
||||
const logisticsTracePayload = ref<Record<string, any> | null>(null)
|
||||
|
||||
const detailOfficialUrls = computed(() => {
|
||||
const n = String(detailData.value?.tracking_number || '').trim()
|
||||
if (!n) {
|
||||
return { sf: 'javascript:void(0)', jd: 'javascript:void(0)', jt: 'javascript:void(0)' }
|
||||
}
|
||||
const enc = encodeURIComponent(n)
|
||||
return {
|
||||
// 顺丰速运官网查询(新版)
|
||||
sf: `https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/${enc}`,
|
||||
// 京东物流官网查询
|
||||
jd: `https://www.jdl.com/#/trackQuery?waybillCode=${enc}`,
|
||||
// 极兔速递官网查询
|
||||
jt: `https://www.jtexpress.com.cn/index/query/gzquery.html?bills=${enc}`
|
||||
}
|
||||
})
|
||||
|
||||
const logisticsTraceList = computed(() => {
|
||||
const p = logisticsTracePayload.value
|
||||
if (!p || !Array.isArray(p.traces)) return []
|
||||
return p.traces as Array<{ time: string; context: string }>
|
||||
})
|
||||
|
||||
function expressCompanyLabel(v: unknown) {
|
||||
const s = String(v || '').toLowerCase()
|
||||
if (s === 'sf') return '顺丰速运'
|
||||
if (s === 'jd') return '京东快递'
|
||||
if (s === 'jt' || s === 'jtexpress') return '极兔速递'
|
||||
return '自动识别'
|
||||
}
|
||||
|
||||
async function fetchLogisticsTrace() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
logisticsTraceLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderLogisticsTrace({
|
||||
id,
|
||||
express_company: detailLogisticsExpress.value
|
||||
})
|
||||
logisticsTracePayload.value = (res?.data ?? res) as Record<string, any>
|
||||
} catch {
|
||||
logisticsTracePayload.value = null
|
||||
} finally {
|
||||
logisticsTraceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(id: number) {
|
||||
detailVisible.value = true
|
||||
detailData.value = null
|
||||
logisticsTracePayload.value = null
|
||||
detailLogisticsExpress.value = 'auto'
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
|
||||
// 如果有快递单号,自动加载物流轨迹
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '加载失败')
|
||||
detailVisible.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editDialogLoading = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editFormRef = ref<FormInstance>()
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
diagnosis_id: 0,
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
shipping_address: '',
|
||||
is_follow_up: 0,
|
||||
medication_days: undefined as number | undefined,
|
||||
prev_staff: '',
|
||||
service_channel: '',
|
||||
service_package: '',
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
remark_extra: '',
|
||||
pay_order_ids: [] as number[],
|
||||
internal_cost: undefined as number | undefined
|
||||
})
|
||||
|
||||
const editPaidOrders = ref<Array<{ id: number; order_no?: string; amount?: number | string }>>([])
|
||||
const editPaidOrdersLoading = ref(false)
|
||||
|
||||
async function loadEditPaidOrders(diagnosisId: number, prescriptionOrderId?: number) {
|
||||
if (!diagnosisId) {
|
||||
editPaidOrders.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
editPaidOrdersLoading.value = true
|
||||
const res: any = await prescriptionOrderPaidPayOrders({
|
||||
diagnosis_id: diagnosisId,
|
||||
...(prescriptionOrderId ? { prescription_order_id: prescriptionOrderId } : {})
|
||||
})
|
||||
const lists = res?.lists ?? res?.data?.lists ?? []
|
||||
editPaidOrders.value = Array.isArray(lists) ? lists : []
|
||||
} catch {
|
||||
editPaidOrders.value = []
|
||||
} finally {
|
||||
editPaidOrdersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editRules: FormRules = {
|
||||
recipient_name: [{ required: true, message: '请输入收货人', trigger: 'blur' }],
|
||||
recipient_phone: [{ required: true, message: '请输入手机', trigger: 'blur' }],
|
||||
shipping_address: [{ required: true, message: '请输入地址', trigger: 'blur' }],
|
||||
fee_type: [{ required: true, message: '请选择费用类别', trigger: 'change' }],
|
||||
amount: [{ required: true, message: '请输入金额', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
async function openEdit(row: { id: number }) {
|
||||
editVisible.value = true
|
||||
editDialogLoading.value = true
|
||||
try {
|
||||
const res: any = await prescriptionOrderDetail({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
if (!d) {
|
||||
feedback.msgError('加载失败')
|
||||
editVisible.value = false
|
||||
return
|
||||
}
|
||||
editForm.id = d.id
|
||||
editForm.diagnosis_id = Number(d.diagnosis_id) || 0
|
||||
editForm.recipient_name = d.recipient_name || ''
|
||||
editForm.recipient_phone = d.recipient_phone || ''
|
||||
editForm.shipping_address = d.shipping_address || ''
|
||||
editForm.is_follow_up = d.is_follow_up ? 1 : 0
|
||||
editForm.medication_days = d.medication_days > 0 ? d.medication_days : undefined
|
||||
editForm.prev_staff = d.prev_staff || ''
|
||||
editForm.service_channel = d.service_channel || ''
|
||||
editForm.service_package = d.service_package || ''
|
||||
editForm.express_company = String(d.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = d.tracking_number || ''
|
||||
editForm.fee_type = Number(d.fee_type) || 3
|
||||
editForm.amount = Number(d.amount) || 0
|
||||
editForm.remark_extra = d.remark_extra || ''
|
||||
const pids = d.pay_order_ids
|
||||
editForm.pay_order_ids = Array.isArray(pids) ? pids.map((x: unknown) => Number(x)).filter((n) => !Number.isNaN(n)) : []
|
||||
editForm.internal_cost =
|
||||
d.internal_cost != null && d.internal_cost !== '' ? Number(d.internal_cost) : undefined
|
||||
await loadEditPaidOrders(editForm.diagnosis_id, editForm.id)
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '加载失败')
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editDialogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editFormRef.value) return
|
||||
await editFormRef.value.validate()
|
||||
editSaving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: editForm.id,
|
||||
recipient_name: editForm.recipient_name,
|
||||
recipient_phone: editForm.recipient_phone,
|
||||
shipping_address: editForm.shipping_address,
|
||||
is_follow_up: editForm.is_follow_up,
|
||||
prev_staff: editForm.prev_staff || '',
|
||||
service_channel: editForm.service_channel || '',
|
||||
service_package: editForm.service_package || '',
|
||||
express_company: editForm.express_company || 'auto',
|
||||
tracking_number: editForm.tracking_number || '',
|
||||
fee_type: editForm.fee_type,
|
||||
amount: editForm.amount,
|
||||
remark_extra: editForm.remark_extra || ''
|
||||
}
|
||||
if (editForm.medication_days != null && editForm.medication_days > 0) {
|
||||
payload.medication_days = editForm.medication_days
|
||||
}
|
||||
payload.pay_order_ids = [...editForm.pay_order_ids]
|
||||
if (editForm.internal_cost != null && editForm.internal_cost >= 0) {
|
||||
payload.internal_cost = editForm.internal_cost
|
||||
}
|
||||
await prescriptionOrderEdit(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
editVisible.value = false
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const auditVisible = ref(false)
|
||||
const auditKind = ref<'prescription' | 'payment'>('prescription')
|
||||
const auditTargetId = ref(0)
|
||||
const auditRemark = ref('')
|
||||
const auditSubmitting = ref(false)
|
||||
|
||||
const auditTitle = computed(() =>
|
||||
auditKind.value === 'prescription' ? '处方审核' : '关联支付单审核'
|
||||
)
|
||||
|
||||
function openAudit(row: { id: number }, kind: 'prescription' | 'payment') {
|
||||
auditKind.value = kind
|
||||
auditTargetId.value = row.id
|
||||
auditRemark.value = ''
|
||||
auditVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit(action: 'approve' | 'reject') {
|
||||
if (action === 'reject' && !auditRemark.value.trim()) {
|
||||
feedback.msgError('驳回时请填写审核意见')
|
||||
return
|
||||
}
|
||||
auditSubmitting.value = true
|
||||
try {
|
||||
const params = {
|
||||
id: auditTargetId.value,
|
||||
action,
|
||||
remark: auditRemark.value.trim()
|
||||
}
|
||||
if (auditKind.value === 'prescription') {
|
||||
await prescriptionOrderAuditPrescription(params)
|
||||
} else {
|
||||
await prescriptionOrderAuditPayment(params)
|
||||
}
|
||||
feedback.msgSuccess('已提交')
|
||||
auditVisible.value = false
|
||||
getLists()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
auditSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWithdraw(row: { id: number }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确认撤回该业务订单?撤回后状态为「已取消」,未双审通过或双审均驳回等「待双审通过」场景均可撤回。',
|
||||
'撤回业务订单',
|
||||
{ type: 'warning', confirmButtonText: '撤回', cancelButtonText: '取消' }
|
||||
)
|
||||
await prescriptionOrderWithdraw({ id: row.id })
|
||||
feedback.msgSuccess('已撤回')
|
||||
getLists()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel' && e !== 'close') {
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,990 @@
|
||||
<template>
|
||||
<div class="dp-page">
|
||||
|
||||
<section class="dp-glass dp-toolbar">
|
||||
<div class="dp-toolbar-row">
|
||||
<span class="dp-field-label">
|
||||
<el-icon><Calendar /></el-icon>
|
||||
预约日期
|
||||
</span>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="—"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
value-format="YYYY-MM-DD"
|
||||
unlink-panels
|
||||
class="dp-field-date"
|
||||
@change="() => loadAppointments()"
|
||||
/>
|
||||
<span class="dp-field-label">
|
||||
<el-icon><Filter /></el-icon>
|
||||
状态
|
||||
</span>
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
class="dp-field-select"
|
||||
@change="() => loadAppointments()"
|
||||
>
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="已预约(待就诊)" value="1" />
|
||||
<el-option label="已完成" value="3" />
|
||||
<el-option label="已过号" value="4" />
|
||||
</el-select>
|
||||
<span class="dp-field-label">
|
||||
<el-icon><Search /></el-icon>
|
||||
患者
|
||||
</span>
|
||||
<el-input
|
||||
v-model="filters.patient_name"
|
||||
placeholder="姓名"
|
||||
clearable
|
||||
class="dp-field-search"
|
||||
@keyup.enter="() => loadAppointments()"
|
||||
/>
|
||||
<div class="dp-toolbar-actions">
|
||||
<el-button type="primary" :loading="loading" :icon="Search" @click="() => loadAppointments()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button :icon="RefreshRight" @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="doctors.length" class="dp-glass dp-doc-section">
|
||||
<div class="dp-section-head">
|
||||
<el-icon class="dp-section-icon"><User /></el-icon>
|
||||
<span class="dp-section-title">选择医生</span>
|
||||
<span class="dp-section-hint">切换后自动加载该医生挂号</span>
|
||||
</div>
|
||||
<el-radio-group
|
||||
v-model="selectedDoctorId"
|
||||
class="dp-doc-pills"
|
||||
@change="() => loadAppointments()"
|
||||
>
|
||||
<el-radio-button
|
||||
v-for="d in doctors"
|
||||
:key="d.id"
|
||||
:label="Number(d.id)"
|
||||
class="dp-doc-pill"
|
||||
>
|
||||
{{ d.name || d.account || '—' }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</section>
|
||||
|
||||
<section class="dp-glass dp-table-wrap" v-loading="loading">
|
||||
<div class="dp-table-head">
|
||||
<div class="dp-table-head-left">
|
||||
<span class="dp-table-title">患者列表</span>
|
||||
<span v-if="dateRange?.length === 2" class="dp-table-range">{{ dateRange[0] }} ~ {{ dateRange[1] }}</span>
|
||||
</div>
|
||||
<div class="dp-table-badges">
|
||||
<span v-if="currentAccountLabel" class="dp-chip dp-chip--account">当前账号 {{ currentAccountLabel }}</span>
|
||||
<span v-if="currentDoctorName" class="dp-chip dp-chip--doc">{{ currentDoctorName }}</span>
|
||||
<span v-if="selectedDoctorId !== undefined && !loading" class="dp-chip dp-chip--count">{{ displayRows.length }} 人</span>
|
||||
<span v-if="hiddenCompletedCount > 0" class="dp-chip dp-chip--muted">已隐藏已完成 {{ hiddenCompletedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!doctors.length" description="暂无医生数据" />
|
||||
<el-empty v-else-if="selectedDoctorId === undefined" description="请选择医生" />
|
||||
<el-table
|
||||
v-else
|
||||
:data="displayRows"
|
||||
class="dp-table"
|
||||
size="default"
|
||||
:empty-text="tableEmptyText"
|
||||
>
|
||||
<el-table-column label="患者" min-width="200" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="dp-cell-user" :class="{ 'is-my-patients': isMyAssistantPatient(row) }">
|
||||
<template v-if="isMyAssistantPatient(row)">
|
||||
<div class="dp-my-patients-caption">我的患者</div>
|
||||
<div class="dp-my-patients-eta">{{ assistantVisitEtaText(row) }}</div>
|
||||
</template>
|
||||
<div class="dp-cell-user-body">
|
||||
<div class="dp-avatar">{{ patientInitial(row.patient_name) }}</div>
|
||||
<div class="dp-user-meta">
|
||||
<span class="dp-user-name">{{ row.patient_name || '—' }}</span>
|
||||
<span class="dp-user-phone">{{ row.patient_phone || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span class="dp-time">{{ formatApptTime(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="appointmentTagType(row.status)" effect="light" round size="small">
|
||||
{{ row.status_desc }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊进度" min-width="360">
|
||||
<template #default="{ row }">
|
||||
<div class="dp-steps">
|
||||
<template v-for="(s, i) in buildSteps(row)" :key="i">
|
||||
<div
|
||||
class="dp-step"
|
||||
:class="{ 'is-done': s.done, 'is-current': s.current && !s.done }"
|
||||
>
|
||||
<div class="dp-step-dot">{{ i + 1 }}</div>
|
||||
<div class="dp-step-text">
|
||||
<span class="dp-step-name">{{ s.label }}</span>
|
||||
<span v-if="s.hint" class="dp-step-hint">{{ s.hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="i < 3" class="dp-step-line" :class="{ 'is-done': s.done }" />
|
||||
</template>
|
||||
</div>
|
||||
<el-progress :percentage="progressPercent(row)" :stroke-width="8" class="dp-progress" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方" width="92" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.has_prescription" type="success" effect="dark" round size="small">已开</el-tag>
|
||||
<span v-else class="dp-muted">未开</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="96" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.diagnosis_id"
|
||||
type="primary"
|
||||
size="small"
|
||||
round
|
||||
plain
|
||||
@click="goDiagnosis(row.diagnosis_id)"
|
||||
>
|
||||
诊单
|
||||
</el-button>
|
||||
<span v-else class="dp-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="doctorProgress">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import dayjs from 'dayjs'
|
||||
import { Calendar, Filter, List, QuestionFilled, RefreshRight, Search, User } from '@element-plus/icons-vue'
|
||||
import { appointmentLists, doctorLists } from '@/api/doctor'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const doctors = ref<any[]>([])
|
||||
/** 当前选中医师(与排班页「选择医生」一致的横向单选) */
|
||||
const selectedDoctorId = ref<number | undefined>(undefined)
|
||||
const tableRows = ref<any[]>([])
|
||||
|
||||
const todayStr = () => dayjs().format('YYYY-MM-DD')
|
||||
const dateRange = ref<[string, string]>([todayStr(), todayStr()])
|
||||
|
||||
const filters = reactive({
|
||||
status: '' as string,
|
||||
patient_name: ''
|
||||
})
|
||||
|
||||
const currentDoctorName = computed(() => {
|
||||
const id = selectedDoctorId.value
|
||||
if (id === undefined) return ''
|
||||
const d = doctors.value.find((x) => Number(x.id) === id)
|
||||
return d ? String(d.name || d.account || '') : ''
|
||||
})
|
||||
|
||||
const currentAdminId = computed(() => {
|
||||
const id = userStore.userInfo?.id
|
||||
return id != null && id !== '' ? Number(id) : NaN
|
||||
})
|
||||
|
||||
const currentAccountLabel = computed(() => {
|
||||
const u = userStore.userInfo || {}
|
||||
return String(u.name || u.account || '').trim()
|
||||
})
|
||||
|
||||
/** 诊单上的医助与当前登录账号一致时显示「我的患者」(医助视角) */
|
||||
function isMyAssistantPatient(row: any) {
|
||||
const me = currentAdminId.value
|
||||
if (Number.isNaN(me)) return false
|
||||
const aid = row?.assistant_id
|
||||
if (aid == null || aid === '') return false
|
||||
return Number(aid) === me
|
||||
}
|
||||
|
||||
/** 粗估每位待就诊患者占用时长(分钟),用于候诊顺序预估 */
|
||||
const AVG_MINUTES_PER_VISIT = 15
|
||||
|
||||
/** 触发接诊预估文案随时间刷新(与列表静默刷新互补) */
|
||||
const etaClock = ref(0)
|
||||
let etaTickTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function apptTimeSortKey(row: any) {
|
||||
const d = String(row.appointment_date || '')
|
||||
let t = row.appointment_time || ''
|
||||
if (typeof t === 'string' && t.length > 5) {
|
||||
t = t.slice(0, 5)
|
||||
}
|
||||
return `${d} ${t}`.trim()
|
||||
}
|
||||
|
||||
function parseApptSlot(row: any) {
|
||||
const d = row.appointment_date
|
||||
if (!d) return null
|
||||
let t = row.appointment_time || '00:00'
|
||||
if (typeof t === 'string' && t.length > 5) {
|
||||
t = t.slice(0, 5)
|
||||
}
|
||||
const parsed = dayjs(`${d} ${t}`)
|
||||
return parsed.isValid() ? parsed : null
|
||||
}
|
||||
|
||||
function formatWaitMinutes(totalMin: number) {
|
||||
const m = Math.max(0, Math.round(totalMin))
|
||||
if (m === 0) return '不到 1 分钟'
|
||||
if (m < 60) return `${m} 分钟`
|
||||
const h = Math.floor(m / 60)
|
||||
const mm = m % 60
|
||||
if (mm === 0) return `${h} 小时`
|
||||
return `${h} 小时${mm} 分钟`
|
||||
}
|
||||
|
||||
/**
|
||||
* 医助「我的患者」接诊预估:医生在挂号上点「完成」即面诊结束(status=3)。
|
||||
* 待就诊:按当日同医生、待就诊列表的排序位次粗估;若预约时段未到会合并提示。
|
||||
*/
|
||||
function assistantVisitEtaText(row: any) {
|
||||
void etaClock.value
|
||||
if (!isMyAssistantPatient(row)) return ''
|
||||
const st = Number(row.status)
|
||||
if (st === 3) {
|
||||
return '面诊已完成(医生已点完成)'
|
||||
}
|
||||
if (st === 4) {
|
||||
return '已过号,无法按序预估'
|
||||
}
|
||||
if (st !== 1) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const slot = parseApptSlot(row)
|
||||
const slotAfterNow = slot ? slot.isAfter(now) : false
|
||||
const untilSlotMin = slotAfterNow && slot ? Math.max(0, slot.diff(now, 'minute')) : 0
|
||||
|
||||
const pendingSameDay = tableRows.value
|
||||
.filter((r) => r.appointment_date === row.appointment_date && Number(r.status) === 1)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const c = apptTimeSortKey(a).localeCompare(apptTimeSortKey(b))
|
||||
if (c !== 0) return c
|
||||
return Number(a.id) - Number(b.id)
|
||||
})
|
||||
|
||||
const idx = pendingSameDay.findIndex((r) => Number(r.id) === Number(row.id))
|
||||
const ahead = idx >= 0 ? idx : 0
|
||||
const queueMin = ahead * AVG_MINUTES_PER_VISIT
|
||||
|
||||
const parts: string[] = []
|
||||
if (slotAfterNow) {
|
||||
parts.push(`距预约时段还有约 ${formatWaitMinutes(untilSlotMin)}`)
|
||||
}
|
||||
if (ahead === 0) {
|
||||
parts.push(slotAfterNow ? '到号后可优先安排面诊' : '已到号,可尽快面诊')
|
||||
} else {
|
||||
parts.push(`前面约 ${ahead} 人,按序粗估约 ${formatWaitMinutes(queueMin)}`)
|
||||
}
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
/** 未指定具体状态时视为「全部」,此时隐藏挂号「已完成」 */
|
||||
function isBroadStatusFilter() {
|
||||
const s = filters.status as string | undefined | null
|
||||
return s === '' || s === undefined || s === null
|
||||
}
|
||||
|
||||
/** 「全部状态」时默认不展示挂号已完成,便于看板聚焦进行中 */
|
||||
const hiddenCompletedCount = computed(() => {
|
||||
if (!isBroadStatusFilter()) return 0
|
||||
return tableRows.value.filter((r) => Number(r.status) === 3).length
|
||||
})
|
||||
|
||||
const displayRows = computed(() => {
|
||||
if (!isBroadStatusFilter()) {
|
||||
return tableRows.value
|
||||
}
|
||||
return tableRows.value.filter((r) => Number(r.status) !== 3)
|
||||
})
|
||||
|
||||
const tableEmptyText = computed(() => {
|
||||
if (hiddenCompletedCount.value > 0 && displayRows.value.length === 0) {
|
||||
return `本时段有 ${hiddenCompletedCount.value} 条「已完成」已隐藏,可在状态中选择「已完成」查看`
|
||||
}
|
||||
return '该时段内暂无挂号'
|
||||
})
|
||||
|
||||
function patientInitial(name: unknown) {
|
||||
const s = String(name || '?').trim()
|
||||
return s ? s[0] : '?'
|
||||
}
|
||||
|
||||
function formatApptTime(row: any) {
|
||||
const d = row.appointment_date || ''
|
||||
let t = row.appointment_time || ''
|
||||
if (typeof t === 'string' && t.length > 5) {
|
||||
t = t.slice(0, 5)
|
||||
}
|
||||
return d ? `${d} ${t}`.trim() : '—'
|
||||
}
|
||||
|
||||
function appointmentTagType(status: number) {
|
||||
if (status === 3) return 'success'
|
||||
if (status === 4) return 'warning'
|
||||
if (status === 2) return 'info'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
/** 面诊进度四步:挂号有效 → 诊单已确认 → 已完诊 → 已开方(开方=今日已创建处方,见 prescription_today_only) */
|
||||
function buildSteps(row: any) {
|
||||
const cancelled = Number(row.status) === 2
|
||||
const regOk = !cancelled && [1, 3, 4].includes(Number(row.status))
|
||||
const confirmed = !!row.diagnosis_confirmed
|
||||
const visitDone = Number(row.status) === 3
|
||||
const rx = !!row.has_prescription
|
||||
|
||||
const steps = [
|
||||
{
|
||||
label: '挂号',
|
||||
done: regOk,
|
||||
current: regOk && !confirmed && Number(row.status) === 1,
|
||||
hint: cancelled ? '已取消' : Number(row.status) === 4 ? '已过号' : ''
|
||||
},
|
||||
{
|
||||
label: '确认诊单',
|
||||
done: confirmed,
|
||||
current: regOk && !confirmed,
|
||||
hint: ''
|
||||
},
|
||||
{
|
||||
label: '就诊中',
|
||||
done: visitDone,
|
||||
current: regOk && confirmed && !visitDone && Number(row.status) === 1,
|
||||
hint: Number(row.status) === 1 && confirmed ? '待完诊' : ''
|
||||
},
|
||||
{
|
||||
label: '开方',
|
||||
done: rx,
|
||||
current: visitDone && !rx,
|
||||
hint: ''
|
||||
}
|
||||
]
|
||||
return steps
|
||||
}
|
||||
|
||||
function progressPercent(row: any) {
|
||||
const steps = buildSteps(row)
|
||||
const n = steps.filter((s) => s.done).length
|
||||
return Math.round((n / 4) * 100)
|
||||
}
|
||||
|
||||
/** 单次请求最大条数(避免 page_type=0 一次拉全表导致服务端 500/超时) */
|
||||
const APPOINT_PAGE_SIZE = 2000
|
||||
/** 自动刷新间隔(毫秒) */
|
||||
const AUTO_REFRESH_MS = 15_000
|
||||
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function loadDoctors() {
|
||||
const res = await doctorLists({
|
||||
role_id: 1,
|
||||
page_type: 0,
|
||||
page_no: 1,
|
||||
/** 仅登录即可拉取,不依赖菜单权限(与接口 progress_board 约定一致) */
|
||||
progress_board: 1
|
||||
})
|
||||
doctors.value = res?.lists || []
|
||||
if (!doctors.value.length) {
|
||||
selectedDoctorId.value = undefined
|
||||
tableRows.value = []
|
||||
return
|
||||
}
|
||||
const valid = doctors.value.some((d) => Number(d.id) === selectedDoctorId.value)
|
||||
if (selectedDoctorId.value === undefined || !valid) {
|
||||
selectedDoctorId.value = Number(doctors.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAppointments(opts?: { silent?: boolean }) {
|
||||
if (selectedDoctorId.value === undefined) {
|
||||
tableRows.value = []
|
||||
return
|
||||
}
|
||||
const silent = opts?.silent === true
|
||||
if (!silent) {
|
||||
loading.value = true
|
||||
}
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
doctor_id: selectedDoctorId.value,
|
||||
page_type: 1,
|
||||
page_no: 1,
|
||||
page_size: APPOINT_PAGE_SIZE,
|
||||
/** 开方状态仅认「今日创建」的处方,避免历史处方误判进度 */
|
||||
prescription_today_only: 1,
|
||||
/** 本页不展示已取消挂号(status=2) */
|
||||
exclude_cancelled: 1,
|
||||
/** 不按医生/医助角色收窄数据;与接口免菜单权限约定一致 */
|
||||
progress_board: 1
|
||||
}
|
||||
if (dateRange.value?.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
if (filters.status !== '') {
|
||||
params.status = filters.status
|
||||
}
|
||||
if (filters.patient_name?.trim()) {
|
||||
params.patient_name = filters.patient_name.trim()
|
||||
}
|
||||
const res = await appointmentLists(params)
|
||||
let list = res?.lists || []
|
||||
const total = Number(res?.count ?? list.length)
|
||||
if (total > list.length && list.length >= APPOINT_PAGE_SIZE) {
|
||||
const extra: any[] = []
|
||||
const pages = Math.min(Math.ceil(total / APPOINT_PAGE_SIZE), 20)
|
||||
for (let p = 2; p <= pages; p++) {
|
||||
const r = await appointmentLists({ ...params, page_no: p })
|
||||
const chunk = r?.lists || []
|
||||
if (!chunk.length) break
|
||||
extra.push(...chunk)
|
||||
}
|
||||
list = [...list, ...extra]
|
||||
}
|
||||
tableRows.value = [...list].sort((a, b) => {
|
||||
const da = `${a.appointment_date || ''} ${a.appointment_time || ''}`
|
||||
const db = `${b.appointment_date || ''} ${b.appointment_time || ''}`
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (!silent) {
|
||||
tableRows.value = []
|
||||
}
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runAutoRefresh() {
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
return
|
||||
}
|
||||
void loadAppointments({ silent: true }).catch(() => {})
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.status = ''
|
||||
filters.patient_name = ''
|
||||
dateRange.value = [todayStr(), todayStr()]
|
||||
loadAppointments()
|
||||
}
|
||||
|
||||
function goDiagnosis(diagnosisId: number) {
|
||||
router.push({ path: '/tcm/diagnosis', query: { id: String(diagnosisId) } })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDoctors()
|
||||
await loadAppointments()
|
||||
autoRefreshTimer = setInterval(runAutoRefresh, AUTO_REFRESH_MS)
|
||||
etaTickTimer = setInterval(() => {
|
||||
etaClock.value += 1
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (autoRefreshTimer != null) {
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
if (etaTickTimer != null) {
|
||||
clearInterval(etaTickTimer)
|
||||
etaTickTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dp-page {
|
||||
padding: 20px 24px 40px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(ellipse 120% 80% at 8% -15%, rgba(64, 158, 255, 0.12), transparent 52%),
|
||||
radial-gradient(ellipse 90% 55% at 96% 8%, rgba(103, 194, 58, 0.08), transparent 48%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2f7 45%, #e8edf4 100%);
|
||||
}
|
||||
|
||||
.dp-hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.dp-hero-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dp-hero-badge {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, var(--el-color-primary) 0%, var(--el-color-primary-light-3) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 8px 28px rgba(64, 158, 255, 0.32);
|
||||
}
|
||||
|
||||
.dp-hero-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dp-hero-sub {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dp-hero-aside {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dp-pulse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
backdrop-filter: blur(8px);
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dp-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
|
||||
animation: dp-pulse 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dp-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.dp-icon-btn {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.dp-tip-lines {
|
||||
max-width: 280px;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dp-glass {
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 23, 42, 0.04),
|
||||
0 14px 44px -14px rgba(15, 23, 42, 0.14);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dp-toolbar {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.dp-toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
|
||||
.dp-field-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dp-field-date {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.dp-field-select {
|
||||
width: 168px;
|
||||
}
|
||||
|
||||
.dp-field-search {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.dp-toolbar-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dp-doc-section {
|
||||
padding: 14px 18px 18px;
|
||||
}
|
||||
|
||||
.dp-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dp-section-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.dp-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dp-section-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.dp-doc-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dp-doc-pills :deep(.el-radio-button__inner) {
|
||||
border-radius: 10px !important;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dp-doc-pills :deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||
background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary)) !important;
|
||||
border-color: transparent !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 14px rgba(64, 158, 255, 0.32);
|
||||
}
|
||||
|
||||
.dp-table-wrap {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dp-table-wrap :deep(.el-loading-mask) {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.dp-table-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-extra-light);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dp-table-head-left {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dp-table-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dp-table-range {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dp-table-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dp-chip {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dp-chip--account {
|
||||
background: var(--el-fill-color-blank);
|
||||
color: var(--el-text-color-regular);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.dp-chip--doc {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.dp-chip--count {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.dp-chip--muted {
|
||||
background: transparent;
|
||||
color: var(--el-text-color-placeholder);
|
||||
border: 1px dashed var(--el-border-color);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dp-table {
|
||||
width: 100%;
|
||||
--el-table-border-color: var(--el-border-color-extra-light);
|
||||
}
|
||||
|
||||
.dp-table :deep(.el-table__header th) {
|
||||
background: var(--el-fill-color-light) !important;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dp-table :deep(.el-table__body tr:hover > td) {
|
||||
background: var(--el-fill-color-lighter) !important;
|
||||
}
|
||||
|
||||
.dp-cell-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dp-cell-user.is-my-patients {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dp-my-patients-caption {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--el-color-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dp-my-patients-eta {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--el-text-color-secondary);
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.dp-cell-user-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dp-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: linear-gradient(145deg, #6366f1 0%, #8b5cf6 100%);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dp-user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dp-user-name {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dp-user-phone {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dp-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.dp-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
margin-bottom: 10px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.dp-step {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 6px;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.dp-step-dot {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color);
|
||||
border: 2px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.dp-step.is-done .dp-step-dot {
|
||||
background: var(--el-color-success);
|
||||
border-color: var(--el-color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dp-step.is-current:not(.is-done) .dp-step-dot {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: 0 0 0 3px var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.dp-step-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dp-step-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dp-step-hint {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.dp-step-line {
|
||||
flex: 0 0 14px;
|
||||
height: 2px;
|
||||
background: var(--el-border-color);
|
||||
border-radius: 1px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.dp-step-line.is-done {
|
||||
background: linear-gradient(90deg, var(--el-color-success-light-5), var(--el-color-success));
|
||||
}
|
||||
|
||||
.dp-progress {
|
||||
max-width: 420px;
|
||||
|
||||
:deep(.el-progress-bar__outer) {
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-progress__text) {
|
||||
font-size: 12px !important;
|
||||
min-width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.dp-muted {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -99,7 +99,7 @@
|
||||
<el-time-select
|
||||
v-model="segmentForm.start_time"
|
||||
start="00:00"
|
||||
step="00:15"
|
||||
step="00:5"
|
||||
end="23:45"
|
||||
placeholder="开始"
|
||||
style="width: 120px"
|
||||
@@ -108,7 +108,7 @@
|
||||
<el-time-select
|
||||
v-model="segmentForm.end_time"
|
||||
start="00:00"
|
||||
step="00:15"
|
||||
step="00:5"
|
||||
end="23:45"
|
||||
placeholder="结束"
|
||||
style="width: 120px"
|
||||
@@ -187,9 +187,9 @@
|
||||
<el-form-item label="时段模板">
|
||||
<div class="w-full space-y-2">
|
||||
<div v-for="(seg, idx) in batchForm.segments" :key="idx" class="flex flex-wrap items-center gap-2 p-2 bg-gray-50 rounded">
|
||||
<el-time-select v-model="seg.start_time" start="00:00" step="00:15" end="23:45" placeholder="开始" style="width: 110px" />
|
||||
<el-time-select v-model="seg.start_time" start="00:00" step="00:05" end="23:45" placeholder="开始" style="width: 110px" />
|
||||
<span>—</span>
|
||||
<el-time-select v-model="seg.end_time" start="00:00" step="00:15" end="23:45" placeholder="结束" style="width: 110px" />
|
||||
<el-time-select v-model="seg.end_time" start="00:00" step="00:05" end="23:45" placeholder="结束" style="width: 110px" />
|
||||
<el-select v-model="seg.shift_type" placeholder="班次" clearable style="width: 100px">
|
||||
<el-option label="白班" value="day" />
|
||||
<el-option label="夜班" value="night" />
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.patient_name }}</div>
|
||||
<div class="patient-phone">{{ row.patient_phone }}</div>
|
||||
<div class="patient-phone">{{ maskPhone(row.patient_phone) }}</div>
|
||||
<div class="patient-extra">
|
||||
{{ [row.gender === 1 ? '男' : row.gender === 0 ? '女' : '', row.age != null ? row.age + '岁' : '', row.height != null ? row.height + 'cm' : '', row.weight != null ? row.weight + 'kg' : ''].filter(Boolean).join(' ') || '-' }}
|
||||
</div>
|
||||
@@ -404,9 +404,9 @@
|
||||
病史信息
|
||||
</div>
|
||||
<div class="detail-grid">
|
||||
<div v-if="detailData.diabetes_discovery_year != null" class="detail-item">
|
||||
<div v-if="isDiabetesDiscoveryFilled(detailData.diabetes_discovery_year)" class="detail-item">
|
||||
<span class="detail-label">发现糖尿病病史</span>
|
||||
<span class="detail-value">{{ detailData.diabetes_discovery_year }}年</span>
|
||||
<span class="detail-value">{{ formatDiabetesDiscoveryDisplay(detailData.diabetes_discovery_year) }}</span>
|
||||
</div>
|
||||
<div v-if="detailData.local_hospital_name" class="detail-item">
|
||||
<span class="detail-label">当地就诊医院</span>
|
||||
@@ -513,6 +513,10 @@ import { appointmentLists, cancelAppointment, completeAppointment, appointmentDe
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
formatDiabetesDiscoveryDisplay,
|
||||
isDiabetesDiscoveryFilled
|
||||
} from '@/utils/diabetes-discovery-display'
|
||||
|
||||
// 重组件异步加载,避免与列表同 chunk 阻塞路由进入与首帧渲染
|
||||
const EditPopup = defineAsyncComponent(() => import('../diagnosis/edit.vue'))
|
||||
@@ -540,6 +544,12 @@ import {
|
||||
const userStore = useUserStore()
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
|
||||
// 手机号脱敏处理
|
||||
const maskPhone = (phone: string) => {
|
||||
if (!phone || phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
// 判断是否为管理员(非医生、非医助)
|
||||
const isAdmin = computed(() => {
|
||||
const roleId = userInfo.value.role_id
|
||||
|
||||
@@ -134,12 +134,10 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="空腹血糖(mmol/L)" prop="fasting_blood_sugar">
|
||||
<el-input-number
|
||||
<el-input
|
||||
v-model="formData.fasting_blood_sugar"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="0.1"
|
||||
placeholder="请输入空腹血糖"
|
||||
placeholder="请输入空腹血糖,如:8.5 或 8-9"
|
||||
maxlength="20"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -206,14 +204,14 @@
|
||||
<div class="section-title">主诉</div>
|
||||
|
||||
<el-form-item label="发现糖尿病就患病史" prop="diabetes_discovery_year">
|
||||
<el-input-number
|
||||
<el-input
|
||||
v-model="formData.diabetes_discovery_year"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="请输入年数"
|
||||
clearable
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
placeholder="可填数字或文字,如:5、1年、半年"
|
||||
class="w-full"
|
||||
/>
|
||||
<template #append>年</template>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="当地医院诊断结果">
|
||||
@@ -521,7 +519,7 @@ const formData = ref({
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
diabetes_type: '',
|
||||
diabetes_discovery_year: undefined as number | undefined,
|
||||
diabetes_discovery_year: '',
|
||||
local_hospital_diagnosis: [],
|
||||
local_hospital_name: '',
|
||||
marital_status: undefined as number | undefined,
|
||||
@@ -530,7 +528,7 @@ const formData = ref({
|
||||
region: '',
|
||||
systolic_pressure: undefined as number | undefined,
|
||||
diastolic_pressure: undefined as number | undefined,
|
||||
fasting_blood_sugar: undefined as number | undefined,
|
||||
fasting_blood_sugar: '',
|
||||
appetite: '',
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
@@ -592,7 +590,8 @@ const formRules = {
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
syndrome_type: [{ required: true, message: '请选择证型', trigger: 'change' }],
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }]
|
||||
diabetes_type: [{ required: true, message: '请选择糖尿病期数', trigger: 'change' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const diagnosisTypeOptions = ref<any[]>([])
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
v-model="formData.id_card"
|
||||
placeholder="请输入身份证号"
|
||||
maxlength="18"
|
||||
@focus="handleIdCardFocus"
|
||||
@blur="handleIdCardBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -53,6 +54,7 @@
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
@focus="handlePhoneFocus"
|
||||
@blur="handlePhoneBlur"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -163,12 +165,10 @@
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="空腹血糖(mmol/L)" prop="fasting_blood_sugar">
|
||||
<el-input-number
|
||||
<el-input
|
||||
v-model="formData.fasting_blood_sugar"
|
||||
:min="0"
|
||||
:max="50"
|
||||
:step="0.1"
|
||||
placeholder="请输入空腹血糖"
|
||||
placeholder="请输入空腹血糖,如:8.5 或 8-9"
|
||||
maxlength="20"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -239,14 +239,14 @@
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="发现糖尿病患病史" prop="diabetes_discovery_year">
|
||||
<el-input-number
|
||||
<el-input
|
||||
v-model="formData.diabetes_discovery_year"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="请输入年数"
|
||||
clearable
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
placeholder="可填数字或文字,如:5、1年、半年"
|
||||
class="w-full"
|
||||
/>
|
||||
<template #append>年</template>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -511,7 +511,7 @@
|
||||
<el-empty v-else description="请先保存诊单后再添加血糖血压记录" />
|
||||
</el-tab-pane>
|
||||
<!-- 病历记录标签页(开方后自动显示) -->
|
||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id">
|
||||
<el-tab-pane label="处方" name="bingli" :disabled="!formData.id" v-if="hasPermission(['tcm.diagnosis/chufang'])">
|
||||
<div v-if="formData.id" class="bingli-tab-content">
|
||||
<!-- 诊断信息:舌苔照片、检查报告 -->
|
||||
<div class="bingli-diagnosis-section">
|
||||
@@ -720,7 +720,7 @@ const formData = ref({
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
diabetes_type: '',
|
||||
diabetes_discovery_year: undefined as number | undefined,
|
||||
diabetes_discovery_year: '',
|
||||
local_hospital_diagnosis: [],
|
||||
local_hospital_name: '',
|
||||
marital_status: undefined as number | undefined,
|
||||
@@ -729,7 +729,7 @@ const formData = ref({
|
||||
region: '',
|
||||
systolic_pressure: undefined as number | undefined,
|
||||
diastolic_pressure: undefined as number | undefined,
|
||||
fasting_blood_sugar: undefined as number | undefined,
|
||||
fasting_blood_sugar: '',
|
||||
appetite: [] as string[],
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
@@ -750,6 +750,7 @@ const formData = ref({
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
assistant_id: undefined as number | undefined,
|
||||
tongue_images: [] as string[],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
@@ -764,13 +765,124 @@ const formData = ref({
|
||||
external_userid: ''
|
||||
})
|
||||
|
||||
// 保存原始完整数据
|
||||
const originalPhone = ref('')
|
||||
const originalIdCard = ref('')
|
||||
const isPhoneFocused = ref(false)
|
||||
const isIdCardFocused = ref(false)
|
||||
|
||||
// 脱敏函数
|
||||
const maskPhone = (phone: string) => {
|
||||
if (!phone || phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
const maskIdCard = (idCard: string) => {
|
||||
if (!idCard) return idCard
|
||||
if (idCard.length === 15) {
|
||||
return idCard.replace(/(\d{6})\d{5}(\d{4})/, '$1*****$2')
|
||||
} else if (idCard.length === 18) {
|
||||
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
|
||||
}
|
||||
return idCard
|
||||
}
|
||||
|
||||
// 手机号聚焦 - 显示完整数据
|
||||
const handlePhoneFocus = () => {
|
||||
isPhoneFocused.value = true
|
||||
if (originalPhone.value) {
|
||||
formData.value.phone = originalPhone.value
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号失焦 - 恢复脱敏并验证
|
||||
const handlePhoneBlur = async () => {
|
||||
isPhoneFocused.value = false
|
||||
|
||||
// 保存原始数据并脱敏显示
|
||||
if (formData.value.phone && formData.value.phone.length === 11) {
|
||||
originalPhone.value = formData.value.phone
|
||||
|
||||
// 验证手机号唯一性
|
||||
if (/^1[3-9]\d{9}$/.test(formData.value.phone)) {
|
||||
try {
|
||||
const result = await checkPhone({
|
||||
phone: formData.value.phone,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏显示
|
||||
formData.value.phone = maskPhone(formData.value.phone)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证聚焦 - 显示完整数据
|
||||
const handleIdCardFocus = () => {
|
||||
isIdCardFocused.value = true
|
||||
if (originalIdCard.value) {
|
||||
formData.value.id_card = originalIdCard.value
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证失焦 - 恢复脱敏并验证
|
||||
const handleIdCardBlur = async () => {
|
||||
isIdCardFocused.value = false
|
||||
|
||||
// 保存原始数据并脱敏显示
|
||||
if (formData.value.id_card && (formData.value.id_card.length === 15 || formData.value.id_card.length === 18)) {
|
||||
originalIdCard.value = formData.value.id_card
|
||||
|
||||
// 验证身份证格式并计算年龄
|
||||
if (/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
// 从身份证提取出生日期计算年龄
|
||||
const id = formData.value.id_card
|
||||
const birthYear = parseInt(id.substring(6, 10))
|
||||
const birthMonth = parseInt(id.substring(10, 12))
|
||||
const birthDay = parseInt(id.substring(12, 14))
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
formData.value.age = age
|
||||
|
||||
// 验证身份证唯一性
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 脱敏显示
|
||||
formData.value.id_card = maskIdCard(formData.value.id_card)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义验证规则
|
||||
const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
if (!value) {
|
||||
// 使用原始数据进行验证
|
||||
const phoneToValidate = originalPhone.value || value
|
||||
if (!phoneToValidate) {
|
||||
callback(new Error('请输入手机号'))
|
||||
return
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(value)) {
|
||||
if (!/^1[3-9]\d{9}$/.test(phoneToValidate)) {
|
||||
callback(new Error('手机号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -778,7 +890,9 @@ const validatePhone = (rule: any, value: any, callback: any) => {
|
||||
}
|
||||
|
||||
const validateIdCard = (rule: any, value: any, callback: any) => {
|
||||
if (value && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(value)) {
|
||||
// 使用原始数据进行验证
|
||||
const idCardToValidate = originalIdCard.value || value
|
||||
if (idCardToValidate && !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(idCardToValidate)) {
|
||||
callback(new Error('身份证号格式不正确'))
|
||||
return
|
||||
}
|
||||
@@ -793,6 +907,7 @@ const formRules = {
|
||||
age: [{ required: true, message: '请输入年龄', trigger: 'blur' }],
|
||||
fasting_blood_sugar: [{ required: true, message: '请输入空腹血糖', trigger: 'blur' }],
|
||||
diagnosis_type: [{ required: true, message: '请选择诊断类型', trigger: 'change' }],
|
||||
diabetes_discovery_year: [{ max: 50, message: '最多50个字符', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 获取字典选项
|
||||
@@ -881,58 +996,6 @@ const getDictOptions = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 手机号失焦检查
|
||||
const handlePhoneBlur = async () => {
|
||||
if (!formData.value.phone || !/^1[3-9]\d{9}$/.test(formData.value.phone)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await checkPhone({
|
||||
phone: formData.value.phone,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查手机号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 身份证号失焦检查
|
||||
const handleIdCardBlur = async () => {
|
||||
if (!formData.value.id_card || !/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/.test(formData.value.id_card)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从身份证提取出生日期计算年龄
|
||||
const id = formData.value.id_card
|
||||
const birthYear = parseInt(id.substring(6, 10))
|
||||
const birthMonth = parseInt(id.substring(10, 12))
|
||||
const birthDay = parseInt(id.substring(12, 14))
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - birthYear
|
||||
if (today.getMonth() + 1 < birthMonth || (today.getMonth() + 1 === birthMonth && today.getDate() < birthDay)) {
|
||||
age--
|
||||
}
|
||||
formData.value.age = age
|
||||
|
||||
try {
|
||||
const result = await checkIdCard({
|
||||
id_card: formData.value.id_card,
|
||||
id: formData.value.id || ''
|
||||
})
|
||||
|
||||
if (result.exists) {
|
||||
ElMessage.warning(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查身份证号失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const open = async (type: string, id?: number) => {
|
||||
mode.value = type
|
||||
visible.value = true
|
||||
@@ -943,7 +1006,19 @@ const open = async (type: string, id?: number) => {
|
||||
|
||||
if (type === 'edit' && id) {
|
||||
const data = await tcmDiagnosisDetail({ id })
|
||||
|
||||
// 保存原始完整数据
|
||||
originalPhone.value = data.phone || ''
|
||||
originalIdCard.value = data.id_card || ''
|
||||
|
||||
// 显示脱敏数据
|
||||
data.phone = maskPhone(data.phone || '')
|
||||
data.id_card = maskIdCard(data.id_card || '')
|
||||
|
||||
formData.value = data
|
||||
const y = data.diabetes_discovery_year
|
||||
formData.value.diabetes_discovery_year =
|
||||
y == null || y === '' ? '' : String(y)
|
||||
} else if (type === 'add') {
|
||||
const userStore = useUserStore()
|
||||
formData.value.assistant_id = userStore.userInfo?.id || ''
|
||||
@@ -955,8 +1030,15 @@ const handleSubmit = async () => {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 准备提交数据,使用原始完整数据
|
||||
const submitData = {
|
||||
...formData.value,
|
||||
phone: originalPhone.value || formData.value.phone,
|
||||
id_card: originalIdCard.value || formData.value.id_card
|
||||
}
|
||||
|
||||
if (mode.value === 'add') {
|
||||
const result = await tcmDiagnosisAdd(formData.value)
|
||||
const result = await tcmDiagnosisAdd(submitData)
|
||||
|
||||
// 如果是新增,保存成功后切换到编辑模式,这样可以添加血糖血压记录
|
||||
if (result && result.id) {
|
||||
@@ -967,12 +1049,20 @@ const handleSubmit = async () => {
|
||||
try {
|
||||
const detail = await tcmDiagnosisDetail({ id: result.id })
|
||||
formData.value.patient_id = detail.patient_id
|
||||
|
||||
// 更新原始数据
|
||||
originalPhone.value = detail.phone || ''
|
||||
originalIdCard.value = detail.id_card || ''
|
||||
|
||||
// 显示脱敏数据
|
||||
formData.value.phone = maskPhone(detail.phone || '')
|
||||
formData.value.id_card = maskIdCard(detail.id_card || '')
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tcmDiagnosisEdit(formData.value)
|
||||
await tcmDiagnosisEdit(submitData)
|
||||
}
|
||||
|
||||
emit('success')
|
||||
@@ -1011,17 +1101,21 @@ const handleClose = () => {
|
||||
id_card: '',
|
||||
phone: '',
|
||||
gender: 1,
|
||||
age: undefined as number | undefined,
|
||||
diagnosis_date: '',
|
||||
diagnosis_type: '',
|
||||
syndrome_type: '',
|
||||
diabetes_type: '',
|
||||
diabetes_discovery_year: '',
|
||||
local_hospital_diagnosis: [],
|
||||
local_hospital_name: '',
|
||||
marital_status: undefined as number | undefined,
|
||||
height: undefined as number | undefined,
|
||||
weight: undefined as number | undefined,
|
||||
region: '',
|
||||
systolic_pressure: undefined as number | undefined,
|
||||
diastolic_pressure: undefined as number | undefined,
|
||||
fasting_blood_sugar: undefined as number | undefined,
|
||||
diabetes_discovery_year: undefined as number | undefined,
|
||||
local_hospital_diagnosis: [],
|
||||
local_hospital_name: '',
|
||||
fasting_blood_sugar: '',
|
||||
appetite: [] as string[],
|
||||
water_intake: '',
|
||||
diet_condition: [],
|
||||
@@ -1042,6 +1136,7 @@ const handleClose = () => {
|
||||
allergy_history: 0,
|
||||
family_history: 0,
|
||||
pregnancy_history: 0,
|
||||
assistant_id: undefined as number | undefined,
|
||||
tongue_images: [] as string[],
|
||||
report_files: [],
|
||||
symptoms: '',
|
||||
@@ -1055,6 +1150,13 @@ const handleClose = () => {
|
||||
status: 1,
|
||||
external_userid: ''
|
||||
}
|
||||
|
||||
// 重置原始数据
|
||||
originalPhone.value = ''
|
||||
originalIdCard.value = ''
|
||||
isPhoneFocused.value = false
|
||||
isIdCardFocused.value = false
|
||||
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -86,17 +86,31 @@
|
||||
stripe
|
||||
>
|
||||
<el-table-column type="selection" width="48" align="center" />
|
||||
<el-table-column label="患者" min-width="150">
|
||||
<el-table-column label="ID" min-width="50">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="60">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<!-- <div class="patient-avatar">{{ (row.patient_name || '患').charAt(0) }}</div> -->
|
||||
<div class="patient-info">
|
||||
<div class="patient-name">{{ row.patient_name }}</div>
|
||||
<div class="patient-meta">{{ row.id }} · {{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label=" 性别 / 年龄" width="100" align="left">
|
||||
<template #default="{ row }">
|
||||
<div >{{ row.gender_desc || '-' }} · {{ row.age ?? '-' }}岁</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div
|
||||
@@ -104,9 +118,23 @@
|
||||
:class="appointmentCellClasses(row)"
|
||||
>
|
||||
<template v-if="row.has_appointment">
|
||||
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
|
||||
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
||||
<template v-if="appointmentRows(row).length">
|
||||
<div
|
||||
v-for="apt in appointmentRows(row)"
|
||||
:key="apt.id || `${apt.doctor_id}-${apt.time_text}`"
|
||||
class="apt-item"
|
||||
:class="appointmentItemClass(apt)"
|
||||
>
|
||||
<div class="apt-badge">{{ appointmentStatusLabelByStatus(apt.status) }}</div>
|
||||
<div class="apt-doctor">{{ apt.doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ apt.time_text || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="apt-badge">{{ appointmentStatusLabel(row) }}</div>
|
||||
<div class="apt-doctor">{{ row.appointment_doctor_name || '-' }}</div>
|
||||
<div class="apt-time">{{ row.appointment_time_text || '-' }}</div>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="apt-none">未挂号</span>
|
||||
</div>
|
||||
@@ -118,14 +146,22 @@
|
||||
<span v-else class="status-unconfirmed">未确认</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="诊断" min-width="130">
|
||||
<el-table-column label="复诊" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="diagnosis-cell">
|
||||
<span class="diagnosis-date">{{ row.diagnosis_date_text || '-' }}</span>
|
||||
<template v-if="getDictLabel(diagnosisTypeOptions, row.diagnosis_type) || getDictLabel(syndromeTypeOptions, row.syndrome_type)">
|
||||
<span class="diagnosis-extra">{{ [getDictLabel(diagnosisTypeOptions, row.diagnosis_type), getDictLabel(syndromeTypeOptions, row.syndrome_type)].filter(Boolean).join(' · ') }}</span>
|
||||
</template>
|
||||
<div v-if="row.has_prescription" class="followup-cell">
|
||||
<div class="followup-time">{{ row.followup_time_text || '—' }}</div>
|
||||
<div class="followup-doctor">{{ row.followup_doctor_name || '—' }}</div>
|
||||
<el-tag
|
||||
v-if="row.followup_rx_voided"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="followup-void-tag"
|
||||
>
|
||||
处方已作废
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="followup-none">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="助理" width="72" show-overflow-tooltip>
|
||||
@@ -231,6 +267,7 @@
|
||||
<img :src="qrcodeUrl" alt="小程序二维码" class="w-64 h-64 border border-gray-200 rounded" />
|
||||
<div class="mt-4 text-sm text-gray-600">
|
||||
<div>患者:{{ currentQRCodePatient?.patient_name }}</div>
|
||||
<div class="mt-2">挂号时间:{{ qrcodeAppointmentTimeText }}</div>
|
||||
<div class="mt-2">请使用企业微信扫描二维码,然后转发给患者</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +343,7 @@
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="患者">
|
||||
<span class="font-medium">{{ orderForm.patient_name }} ({{ orderForm.phone }})</span>
|
||||
<span class="font-medium">{{ orderForm.patient_name }} ({{ maskPhone(orderForm.phone) }})</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="订单类型" prop="order_type">
|
||||
@@ -386,7 +423,7 @@
|
||||
<div v-if="wechatContactInfo" class="mb-4">
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="患者姓名">{{ wechatCurrentPatient?.patient_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ wechatContactInfo.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机号">{{ maskPhone(wechatContactInfo.phone) || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="企微联系人ID">
|
||||
<span v-if="wechatContactInfo.external_userid">{{ wechatContactInfo.external_userid }}</span>
|
||||
<el-tag v-else type="info" size="small">未关联</el-tag>
|
||||
@@ -496,7 +533,7 @@ import { hasPermission } from '@/utils/perm'
|
||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, Picture, User, Document, Delete, CircleClose } from '@element-plus/icons-vue'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||
@@ -512,6 +549,23 @@ const formatDateTime = (timestamp: number | string) => {
|
||||
return dayjs(ts).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 手机号脱敏
|
||||
const maskPhone = (phone: string) => {
|
||||
if (!phone || phone.length < 11) return phone
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
}
|
||||
|
||||
// 身份证号脱敏
|
||||
const maskIdCard = (idCard: string) => {
|
||||
if (!idCard) return idCard
|
||||
if (idCard.length === 15) {
|
||||
return idCard.replace(/(\d{6})\d{5}(\d{4})/, '$1*****$2')
|
||||
} else if (idCard.length === 18) {
|
||||
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
|
||||
}
|
||||
return idCard
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
patient_name: '',
|
||||
diagnosis_type: '',
|
||||
@@ -832,7 +886,9 @@ const orderForm = reactive({
|
||||
phone: '',
|
||||
order_type: '' as string | number,
|
||||
amount: 0,
|
||||
remark: ''
|
||||
remark: '',
|
||||
has_appointment: false,
|
||||
appointment_time_text: ''
|
||||
})
|
||||
const orderRules = {
|
||||
order_type: [{ required: true, message: '请选择订单类型', trigger: 'change' }],
|
||||
@@ -846,6 +902,8 @@ const handleCreateOrder = (row: any) => {
|
||||
orderForm.order_type = ''
|
||||
orderForm.amount = 0
|
||||
orderForm.remark = ''
|
||||
orderForm.has_appointment = !!row.has_appointment
|
||||
orderForm.appointment_time_text = row.appointment_time_text || ''
|
||||
orderFormRef.value?.clearValidate()
|
||||
createOrderVisible.value = true
|
||||
}
|
||||
@@ -857,6 +915,8 @@ const resetOrderForm = () => {
|
||||
orderForm.order_type = ''
|
||||
orderForm.amount = 0
|
||||
orderForm.remark = ''
|
||||
orderForm.has_appointment = false
|
||||
orderForm.appointment_time_text = ''
|
||||
orderFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
@@ -878,7 +938,11 @@ const submitOrder = async () => {
|
||||
qrcodeDialogVisible.value = true
|
||||
qrcodeLoading.value = true
|
||||
qrcodeUrl.value = ''
|
||||
currentQRCodePatient.value = { patient_name: orderForm.patient_name }
|
||||
currentQRCodePatient.value = {
|
||||
patient_name: orderForm.patient_name,
|
||||
has_appointment: orderForm.has_appointment,
|
||||
appointment_time_text: orderForm.appointment_time_text
|
||||
}
|
||||
try {
|
||||
const qrRes = await generateOrderQrcode({ order_no: orderNo })
|
||||
if (qrRes?.qrcode_url) {
|
||||
@@ -904,6 +968,13 @@ const qrcodeDialogVisible = ref(false)
|
||||
const qrcodeLoading = ref(false)
|
||||
const qrcodeUrl = ref('')
|
||||
const currentQRCodePatient = ref<any>(null)
|
||||
/** 二维码弹窗展示的挂号时间(与列表「挂号」列一致) */
|
||||
const qrcodeAppointmentTimeText = computed(() => {
|
||||
const p = currentQRCodePatient.value
|
||||
if (!p) return '—'
|
||||
if (!p.has_appointment) return '未挂号'
|
||||
return p.appointment_time_text || '—'
|
||||
})
|
||||
const qrcodeDialogTitle = ref('小程序二维码')
|
||||
const lastQRCodeType = ref<'video' | 'confirm'>('confirm')
|
||||
|
||||
@@ -1021,6 +1092,35 @@ const appointmentStatusLabel = (row: any) => {
|
||||
return '已挂号'
|
||||
}
|
||||
|
||||
const appointmentStatusLabelByStatus = (status: number | string) => {
|
||||
const s = Number(status)
|
||||
if (s === 3) return '已完成'
|
||||
if (s === 4) return '已过号'
|
||||
return '已挂号'
|
||||
}
|
||||
|
||||
const appointmentRows = (row: any) => {
|
||||
const list = Array.isArray(row.appointments) ? row.appointments : []
|
||||
if (list.length > 0) return list
|
||||
if (!row.has_appointment) return []
|
||||
return [{
|
||||
id: row.appointment_id,
|
||||
status: row.appointment_status,
|
||||
doctor_id: row.appointment_doctor_id,
|
||||
doctor_name: row.appointment_doctor_name,
|
||||
time_text: row.appointment_time_text
|
||||
}]
|
||||
}
|
||||
|
||||
const appointmentItemClass = (apt: any) => {
|
||||
const s = Number(apt?.status)
|
||||
return {
|
||||
'apt-item-done': s === 3,
|
||||
'apt-item-missed': s === 4,
|
||||
'apt-item-active': s === 1
|
||||
}
|
||||
}
|
||||
|
||||
const appointmentCellClasses = (row: any) => {
|
||||
const s = Number(row.appointment_status)
|
||||
return {
|
||||
@@ -1540,21 +1640,28 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.patient-meta-col {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.appointment-cell {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
|
||||
&.has-apt {
|
||||
background: linear-gradient(135deg, rgba(var(--el-color-success-rgb), 0.12), rgba(var(--el-color-success-rgb), 0.06));
|
||||
border: 1px solid rgba(var(--el-color-success-rgb), 0.3);
|
||||
.apt-item + .apt-item {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.apt-item {
|
||||
.apt-badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-success-dark-2);
|
||||
background: var(--el-color-success-light-5);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
@@ -1568,37 +1675,56 @@ onUnmounted(() => {
|
||||
|
||||
.apt-time {
|
||||
font-size: 12px;
|
||||
color: var(--el-color-success);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
&.apt-item-active {
|
||||
.apt-badge {
|
||||
color: #3a4acc;
|
||||
background: rgba(74, 93, 255, 0.15);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: #4A5DFF;
|
||||
}
|
||||
}
|
||||
|
||||
&.apt-item-done {
|
||||
.apt-badge {
|
||||
color: var(--el-color-info-dark-2);
|
||||
background: var(--el-color-info-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
&.apt-item-missed {
|
||||
.apt-badge {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
background: var(--el-color-warning-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-apt {
|
||||
background: linear-gradient(135deg, rgba(74, 93, 255, 0.12), rgba(74, 93, 255, 0.06));
|
||||
border: 1px solid rgba(74, 93, 255, 0.3);
|
||||
}
|
||||
|
||||
&.apt-row-missed {
|
||||
background: linear-gradient(135deg, rgba(var(--el-color-warning-rgb), 0.12), rgba(var(--el-color-warning-rgb), 0.05));
|
||||
border-color: rgba(var(--el-color-warning-rgb), 0.35);
|
||||
|
||||
.apt-badge {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
background: var(--el-color-warning-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
|
||||
&.apt-row-done {
|
||||
background: linear-gradient(135deg, rgba(var(--el-color-info-rgb), 0.1), rgba(var(--el-color-info-rgb), 0.04));
|
||||
border-color: rgba(var(--el-color-info-rgb), 0.28);
|
||||
|
||||
.apt-badge {
|
||||
color: var(--el-color-info-dark-2);
|
||||
background: var(--el-color-info-light-7);
|
||||
}
|
||||
|
||||
.apt-time {
|
||||
color: var(--el-color-info);
|
||||
}
|
||||
}
|
||||
|
||||
.apt-none {
|
||||
@@ -1621,7 +1747,7 @@ onUnmounted(() => {
|
||||
|
||||
.status-prescribed {
|
||||
font-size: 13px;
|
||||
color: var(--el-color-success);
|
||||
color: #4A5DFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -1647,6 +1773,33 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.followup-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.35;
|
||||
|
||||
.followup-time {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.followup-doctor {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.followup-void-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.followup-none {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.assistant-cell {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,13 +109,14 @@
|
||||
|
||||
<script setup lang="ts" name="userSetting">
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { nextTick, onMounted, ref, reactive } from 'vue'
|
||||
import { nextTick, onMounted, onUnmounted, ref, reactive } from 'vue'
|
||||
import { Loading, CircleCheck, Connection } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { setUserInfo, getWorkWechatConfig, bindWorkWechat, unbindWorkWechat } from '@/api/user'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { attachWecomOAuthMessageCapture } from '@/utils/wecomOauthPostMessage'
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const userStore = useUserStore()
|
||||
@@ -135,12 +136,17 @@ const rules = reactive<object>({
|
||||
name: [{ required: true, message: '请输入名称', trigger: ['blur'] }]
|
||||
})
|
||||
|
||||
/** 与 bind-work-wechat 页一致:避免企微 postMessage 跳转后重复 onMounted 两次请求同一 code */
|
||||
const WX_BIND_OAUTH_LOCK = 'like_admin_wx_bind_oauth'
|
||||
|
||||
// 企业微信相关
|
||||
const wxWorkEnabled = ref(false)
|
||||
const wxWorkConfig = ref<{ corp_id: string; agent_id: string }>({ corp_id: '', agent_id: '' })
|
||||
const bindDialogVisible = ref(false)
|
||||
const bindLoading = ref(false)
|
||||
|
||||
let detachSettingWecomCapture: (() => void) | null = null
|
||||
|
||||
// 获取个人设置
|
||||
const getUser = async () => {
|
||||
const userInfo = userStore.userInfo
|
||||
@@ -200,6 +206,14 @@ const showBindDialog = async () => {
|
||||
bindLoading.value = false
|
||||
await nextTick()
|
||||
|
||||
detachSettingWecomCapture?.()
|
||||
detachSettingWecomCapture = attachWecomOAuthMessageCapture({
|
||||
pathIncludes: 'user/setting',
|
||||
lockKey: WX_BIND_OAUTH_LOCK,
|
||||
wxBindState: 'bind_wxwork',
|
||||
onCode: (code) => handleBindCallback(code)
|
||||
})
|
||||
|
||||
const redirectUri = encodeURIComponent(getBindRedirectUri())
|
||||
new (window as any).WwLogin({
|
||||
id: 'bind_wxwork_qrcode',
|
||||
@@ -215,6 +229,8 @@ const showBindDialog = async () => {
|
||||
|
||||
const onBindDialogClose = () => {
|
||||
bindLoading.value = false
|
||||
detachSettingWecomCapture?.()
|
||||
detachSettingWecomCapture = null
|
||||
}
|
||||
|
||||
// 解绑
|
||||
@@ -235,6 +251,7 @@ const handleBindCallback = async (code: string) => {
|
||||
try {
|
||||
const res = await bindWorkWechat({ code })
|
||||
formData.work_wechat_userid = res?.work_wechat_userid || ''
|
||||
bindDialogVisible.value = false
|
||||
ElMessage.success('企业微信绑定成功')
|
||||
userStore.getUserInfo()
|
||||
} catch (e: any) {
|
||||
@@ -265,11 +282,22 @@ onMounted(async () => {
|
||||
const isBind = urlParams.get('bind_wxwork')
|
||||
|
||||
if (bindCode && (state === 'bind_wxwork' || isBind === '1')) {
|
||||
handleBindCallback(bindCode)
|
||||
if (sessionStorage.getItem(WX_BIND_OAUTH_LOCK) === bindCode) {
|
||||
return
|
||||
}
|
||||
sessionStorage.setItem(WX_BIND_OAUTH_LOCK, bindCode)
|
||||
handleBindCallback(bindCode).finally(() => {
|
||||
sessionStorage.removeItem(WX_BIND_OAUTH_LOCK)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
getUser()
|
||||
|
||||
onUnmounted(() => {
|
||||
detachSettingWecomCapture?.()
|
||||
detachSettingWecomCapture = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
+977
-428
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user