This commit is contained in:
Your Name
2026-04-07 18:13:03 +08:00
parent a780356908
commit fdf714f833
397 changed files with 15086 additions and 1043 deletions
+13 -1
View File
@@ -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>
+5 -2
View File
@@ -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 }
})
}
// 医生详情
+1 -1
View File
@@ -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 })
}
+51
View File
@@ -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 })
}
// ========== 处方库 ==========
// 处方库列表
+76 -60
View File
@@ -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;
+16 -4
View File
@@ -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
+3 -1
View File
@@ -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>
-10
View File
@@ -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
View File
@@ -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)
+14 -8
View File
@@ -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 失败时不再重试,避免与路由守卫形成无限请求
}
})
}
+18
View File
@@ -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')
+3
View File
@@ -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
+35 -2
View File
@@ -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) {
+36 -3
View File
@@ -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() !== ''
}
+20
View File
@@ -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
+10
View File
@@ -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)
}
+69
View File
@@ -0,0 +1,69 @@
/**
* 企微 WwLoginiframe 内授权完成后通过 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>
+10 -4
View File
@@ -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)
+544 -14
View File
@@ -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>
+990
View File
@@ -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>
+4 -4
View File
@@ -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" />
+13 -3
View File
@@ -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
+12 -13
View File
@@ -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[]>([])
+176 -74
View File
@@ -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
}
+194 -41
View File
@@ -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
+30 -2
View File
@@ -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>
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long