更新
This commit is contained in:
@@ -2,12 +2,12 @@ import request from '@/utils/request'
|
||||
|
||||
// 医生列表(从管理员表获取,role_id=1)
|
||||
export function doctorLists(params: any) {
|
||||
return request.get({ url: '/perms.admin/lists', params: { ...params, role_id: 1 } })
|
||||
return request.get({ url: '/auth.admin/lists', params: { ...params, role_id: 1 } })
|
||||
}
|
||||
|
||||
// 医生详情
|
||||
export function doctorDetail(params: any) {
|
||||
return request.get({ url: '/perms.admin/detail', params })
|
||||
return request.get({ url: '/auth.admin/detail', params })
|
||||
}
|
||||
|
||||
// ========== 排班管理 ==========
|
||||
@@ -72,3 +72,10 @@ export function appointmentDetail(params: any) {
|
||||
export function completeAppointment(params: any) {
|
||||
return request.post({ url: '/doctor.appointment/complete', params })
|
||||
}
|
||||
|
||||
// ========== 医生统计 ==========
|
||||
|
||||
// 获取医生诊单统计
|
||||
export function getDoctorStatistics(params: any) {
|
||||
return request.get({ url: '/doctor.statistics/lists', params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 药品库列表
|
||||
*/
|
||||
export function medicineLists(params: any) {
|
||||
return request.get({ url: '/doctor.medicine/lists', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加药品
|
||||
*/
|
||||
export function medicineAdd(params: any) {
|
||||
return request.post({ url: '/doctor.medicine/add', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑药品
|
||||
*/
|
||||
export function medicineEdit(params: any) {
|
||||
return request.post({ url: '/doctor.medicine/edit', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除药品
|
||||
*/
|
||||
export function medicineDelete(params: any) {
|
||||
return request.post({ url: '/doctor.medicine/delete', params })
|
||||
}
|
||||
|
||||
/**
|
||||
* 药品详情
|
||||
*/
|
||||
export function medicineDetail(params: any) {
|
||||
return request.get({ url: '/doctor.medicine/detail', params })
|
||||
}
|
||||
@@ -40,3 +40,8 @@ export function bindWorkWechat(params: { code: string }) {
|
||||
export function unbindWorkWechat() {
|
||||
return request.post({ url: '/auth.admin/unbindWorkWechat' })
|
||||
}
|
||||
|
||||
// 首次登录修改密码
|
||||
export function changeFirstPassword(params: { password: string; password_confirm: string }) {
|
||||
return request.post({ url: '/login/changeFirstPassword', params })
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div v-else class="chat-content">
|
||||
<UIKitProvider language="zh-CN" theme="light">
|
||||
<div class="chat-layout">
|
||||
<ConversationList class="conversation-list" />
|
||||
<!-- 嵌入式问诊窗口仅与当前患者会话,不展示全局会话列表 -->
|
||||
<Chat class="chat-area">
|
||||
<MessageList :conversationID="conversationId" />
|
||||
<MessageInput>
|
||||
@@ -129,7 +129,6 @@ import {
|
||||
} from '@/utils/tuicall-error'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import {
|
||||
ConversationList,
|
||||
Chat,
|
||||
MessageList,
|
||||
MessageInput,
|
||||
@@ -183,6 +182,8 @@ const BIND_ROOM_MAX_ATTEMPTS = 40
|
||||
const BIND_ROOM_RETRY_MS = 100
|
||||
|
||||
const localCallRecorder = new CallLocalRecorder()
|
||||
/** 与 server .env [trtc] ISLOCHOSTVOD 一致:由 getCallSignature 下发,false 时不做浏览器本地录制 */
|
||||
const isLochostVodEnabled = ref(false)
|
||||
const localRecordingBadge = ref(false)
|
||||
let localRecordingRound = 0
|
||||
let startLocalRecTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -199,6 +200,10 @@ function clearLocalRecordingStartTimer() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncLochostVodFromSignature(res: { isLochostVod?: boolean }) {
|
||||
isLochostVodEnabled.value = Boolean(res?.isLochostVod)
|
||||
}
|
||||
|
||||
function finalizeLocalRecordingAndAttach(): Promise<void> {
|
||||
if (finalizeLocalRecordingPromise) {
|
||||
return finalizeLocalRecordingPromise
|
||||
@@ -206,6 +211,9 @@ function finalizeLocalRecordingAndAttach(): Promise<void> {
|
||||
finalizeLocalRecordingPromise = (async () => {
|
||||
clearLocalRecordingStartTimer()
|
||||
localRecordingBadge.value = false
|
||||
if (!isLochostVodEnabled.value) {
|
||||
return
|
||||
}
|
||||
localCallRecorder.beginStopNow()
|
||||
const id = recordingDiagnosisIdSnapshot ?? diagnosisId.value
|
||||
try {
|
||||
@@ -621,8 +629,12 @@ function setupCallRoomBinding() {
|
||||
if (next === CALL_STATUS_CONNECTED) {
|
||||
void tryBindRoomIfReady()
|
||||
clearLocalRecordingStartTimer()
|
||||
if (!isLochostVodEnabled.value) {
|
||||
previousCallStatus = next
|
||||
return
|
||||
}
|
||||
const roundAtSchedule = localRecordingRound
|
||||
|
||||
|
||||
// 立即尝试启动录制,不要等待 1200ms
|
||||
const attemptStartRecording = (retryCount = 0) => {
|
||||
if (roundAtSchedule !== localRecordingRound) {
|
||||
@@ -889,6 +901,7 @@ watch(() => activeConversation.value, async (newConversation) => {
|
||||
patient_id: newConversation.userProfile.userID.replace("patient_", ""),
|
||||
diagnosis_id: diagnosisId.value
|
||||
})
|
||||
syncLochostVodFromSignature(res)
|
||||
patientUserId.value = res.patientUserId
|
||||
assistant_ids.value = res.assistant_id
|
||||
console.log('会话切换后获取到 assistant_id:', assistant_ids.value, '完整响应:', res)
|
||||
@@ -922,6 +935,7 @@ const open = async (data: { patientId: number; patientName: string; diagnosisId?
|
||||
patient_id: data.patientId,
|
||||
diagnosis_id: data.diagnosisId || 0
|
||||
})
|
||||
syncLochostVodFromSignature(res)
|
||||
assistant_ids.value = res.assistant_id
|
||||
console.log('后端返回的签名数据:', res)
|
||||
console.log('assistant_id:', assistant_ids.value)
|
||||
@@ -1062,6 +1076,7 @@ const startGroupVideoCall = async () => {
|
||||
patient_id: patientId.value!,
|
||||
diagnosis_id: diagnosisId.value
|
||||
})
|
||||
syncLochostVodFromSignature(res)
|
||||
assistant_ids.value = res.assistant_id
|
||||
console.log('重新获取 assistant_id:', assistant_ids.value)
|
||||
}
|
||||
@@ -1344,13 +1359,9 @@ defineExpose({ open })
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.conversation-list {
|
||||
width: 285px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
@@ -227,7 +227,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const handlePreview = (url: string) => {
|
||||
previewUrl.value = url
|
||||
previewUrl.value = props.excludeDomain ? getImageUrl(url) : url
|
||||
showPreview.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
||||
<el-select
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
class="medicine-name-select w-full"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
placeholder="搜索药材名称或拼音首字母"
|
||||
:remote-method="remoteMethod"
|
||||
:loading="loading"
|
||||
clearable
|
||||
@visible-change="onVisibleChange"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { medicineLists } from '@/api/medicine'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const options = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
function ensureCurrentInOptions() {
|
||||
const v = (props.modelValue || '').trim()
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
if (!options.value.some((o) => o.name === v)) {
|
||||
options.value = [{ id: 0, name: v }, ...options.value]
|
||||
}
|
||||
}
|
||||
|
||||
const remoteMethod = async (query: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const q = (query || '').trim()
|
||||
const res = await medicineLists({
|
||||
name: q || undefined,
|
||||
page_no: 1,
|
||||
page_size: 100,
|
||||
status: 1
|
||||
})
|
||||
options.value = res.lists || []
|
||||
ensureCurrentInOptions()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
options.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibleChange = (open: boolean) => {
|
||||
if (open && options.value.length === 0) {
|
||||
remoteMethod('')
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdate = (val: string) => {
|
||||
emit('update:modelValue', val || '')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
ensureCurrentInOptions()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.medicine-name-readonly {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.medicine-name-select.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -52,7 +52,7 @@
|
||||
<el-input v-model="formData.tongue" placeholder="舌象" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<!-- <el-col :span="12">
|
||||
<el-form-item label="舌象详情" prop="tongue_image">
|
||||
<el-input v-model="formData.tongue_image" placeholder="舌象详情" />
|
||||
</el-form-item>
|
||||
@@ -61,7 +61,7 @@
|
||||
<el-form-item label="脉象详情" prop="pulse_condition">
|
||||
<el-input v-model="formData.pulse_condition" placeholder="脉象详情" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-col> -->
|
||||
<el-col :span="24">
|
||||
<el-form-item label="临床诊断" prop="clinical_diagnosis">
|
||||
<el-input
|
||||
@@ -93,28 +93,29 @@
|
||||
<span class="ml-2 text-gray-500">元</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-for="(herb, index) in formData.herbs" :key="index" class="herb-item">
|
||||
<el-form-item :label="`中药${index + 1}`">
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<el-input
|
||||
v-model="herb.name"
|
||||
placeholder="药品名称"
|
||||
style="width: 160px"
|
||||
/>
|
||||
<div class="herbs-editor-grid">
|
||||
<div
|
||||
v-for="(herb, index) in formData.herbs"
|
||||
:key="'herb-' + index"
|
||||
class="herb-editor-card"
|
||||
>
|
||||
<div class="herb-editor-card__title">中药 {{ index + 1 }}</div>
|
||||
<MedicineNameSelect v-model="herb.name" class="herb-editor-card__select" />
|
||||
<div class="herb-editor-card__row">
|
||||
<el-input-number
|
||||
v-model="herb.dosage"
|
||||
:min="0.1"
|
||||
:step="1"
|
||||
:precision="1"
|
||||
placeholder="剂量(克)"
|
||||
style="width: 120px"
|
||||
class="herb-editor-card__dose"
|
||||
/>
|
||||
<span class="text-gray-500">克</span>
|
||||
<span class="text-gray-500 shrink-0">克</span>
|
||||
<el-button type="danger" size="small" link @click="handleRemoveHerb(index)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -547,6 +548,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { prescriptionAdd, prescriptionDetail, prescriptionGetByAppointment, prescriptionVoid, tcmDiagnosisDetail, prescriptionLibraryLists } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
@@ -1253,9 +1255,42 @@ defineExpose({
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.herb-item {
|
||||
.herbs-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.herb-editor-card {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.herb-editor-card__title {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.herb-editor-card__select {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.herb-editor-card__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.herb-editor-card__dose {
|
||||
flex: 1;
|
||||
min-width: 88px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,12 @@ export function usePaging(options: Options) {
|
||||
lists: [] as any[],
|
||||
extend: {} as Record<string, any>
|
||||
})
|
||||
// 请求分页接口
|
||||
const getLists = () => {
|
||||
pager.loading = true
|
||||
// 请求分页接口;silent: true 时不改 loading(用于定时静默刷新,避免表格闪 loading)
|
||||
const getLists = (opts?: { silent?: boolean }) => {
|
||||
const silent = opts?.silent === true
|
||||
if (!silent) {
|
||||
pager.loading = true
|
||||
}
|
||||
return fetchFun({
|
||||
page_no: pager.page,
|
||||
page_size: pager.size,
|
||||
@@ -49,7 +52,9 @@ export function usePaging(options: Options) {
|
||||
return Promise.reject(err)
|
||||
})
|
||||
.finally(() => {
|
||||
pager.loading = false
|
||||
if (!silent) {
|
||||
pager.loading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
// 重置为第一页
|
||||
|
||||
@@ -49,12 +49,26 @@ const addRoutesRecursively = (routes: any, parentPath = '') => {
|
||||
|
||||
const loginPath = PageEnum.LOGIN
|
||||
const defaultPath = PageEnum.INDEX
|
||||
const changePasswordPath = '/change-password'
|
||||
// 免登录白名单
|
||||
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 === changePasswordPath) {
|
||||
if (!userStore.token) {
|
||||
// 未登录,跳转到登录页
|
||||
next({ path: loginPath })
|
||||
return
|
||||
}
|
||||
// 需要修改密码,允许访问
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (whiteList.includes(to.path)) {
|
||||
// 在免登录白名单,直接进入
|
||||
next()
|
||||
@@ -62,6 +76,13 @@ router.beforeEach(async (to, from, next) => {
|
||||
// 获取用户信息
|
||||
const hasGetUserInfo = Object.keys(userStore.userInfo).length !== 0
|
||||
if (hasGetUserInfo) {
|
||||
// 已经获取过用户信息,检查是否需要修改密码
|
||||
if (userStore.isPaw === 0) {
|
||||
// 需要修改密码,强制跳转到修改密码页面
|
||||
next({ path: changePasswordPath })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.path === loginPath) {
|
||||
next({ path: defaultPath })
|
||||
} else {
|
||||
@@ -70,6 +91,14 @@ router.beforeEach(async (to, from, next) => {
|
||||
} else {
|
||||
try {
|
||||
await userStore.getUserInfo()
|
||||
|
||||
// 获取用户信息后,检查是否需要修改密码
|
||||
if (userStore.isPaw === 0) {
|
||||
// 需要修改密码,强制跳转到修改密码页面
|
||||
next({ path: changePasswordPath })
|
||||
return
|
||||
}
|
||||
|
||||
const routes = userStore.routes
|
||||
// 找到第一个有效路由
|
||||
const routeName = findFirstValidRoute(routes)
|
||||
|
||||
@@ -35,6 +35,10 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
path: PageEnum.LOGIN,
|
||||
component: () => import('@/views/account/login.vue')
|
||||
},
|
||||
{
|
||||
path: '/change-password',
|
||||
component: () => import('@/views/account/change-password.vue')
|
||||
},
|
||||
{
|
||||
path: '/user',
|
||||
component: LAYOUT,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface UserState {
|
||||
userInfo: Record<string, any>
|
||||
routes: RouteRecordRaw[]
|
||||
perms: string[]
|
||||
isPaw: number // 0=需要修改密码,1=正常
|
||||
}
|
||||
|
||||
const useUserStore = defineStore({
|
||||
@@ -24,7 +25,9 @@ const useUserStore = defineStore({
|
||||
// 路由
|
||||
routes: [],
|
||||
// 权限
|
||||
perms: []
|
||||
perms: [],
|
||||
// 是否需要修改密码
|
||||
isPaw: 1
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
@@ -32,6 +35,7 @@ const useUserStore = defineStore({
|
||||
this.token = ''
|
||||
this.userInfo = {}
|
||||
this.perms = []
|
||||
this.isPaw = 1
|
||||
},
|
||||
login(playload: any) {
|
||||
const { account, password } = playload
|
||||
@@ -42,6 +46,7 @@ const useUserStore = defineStore({
|
||||
})
|
||||
.then((data) => {
|
||||
this.token = data.token
|
||||
this.isPaw = data.is_paw ?? 1
|
||||
cache.set(TOKEN_KEY, data.token)
|
||||
resolve(data)
|
||||
})
|
||||
@@ -69,6 +74,7 @@ const useUserStore = defineStore({
|
||||
workWechatLogin({ code })
|
||||
.then((data) => {
|
||||
this.token = data.token
|
||||
this.isPaw = data.is_paw ?? 1
|
||||
cache.set(TOKEN_KEY, data.token)
|
||||
resolve(data)
|
||||
})
|
||||
@@ -84,6 +90,8 @@ const useUserStore = defineStore({
|
||||
this.userInfo = data.user
|
||||
this.perms = data.permissions
|
||||
this.routes = filterAsyncRoutes(data.menu)
|
||||
// 更新 isPaw 状态
|
||||
this.isPaw = data.user.is_paw ?? 1
|
||||
resolve(data)
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="change-password flex flex-col">
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="change-password-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>
|
||||
|
||||
<el-form ref="formRef" :model="formData" size="large" :rules="rules">
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入新密码"
|
||||
>
|
||||
<template #prepend>
|
||||
<icon name="el-icon-Lock" size="16" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password_confirm">
|
||||
<el-input
|
||||
v-model="formData.password_confirm"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请再次输入新密码"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prepend>
|
||||
<icon name="el-icon-Lock" size="16" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-button type="primary" size="large" :loading="isLock" @click="lockSubmit" class="w-full">
|
||||
确认修改
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<layout-footer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus'
|
||||
import { ref, shallowRef, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useLockFn } from '@/hooks/useLockFn'
|
||||
import LayoutFooter from '@/layout/components/footer.vue'
|
||||
import { changeFirstPassword } from '@/api/user'
|
||||
import { PageEnum } from '@/enums/pageEnum'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
const formRef = shallowRef<FormInstance>()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 检查是否已登录
|
||||
onMounted(() => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
ElMessage.error('请先登录')
|
||||
router.push(PageEnum.LOGIN)
|
||||
}
|
||||
})
|
||||
|
||||
const formData = reactive({
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const validatePasswordConfirm = (rule: any, value: any, callback: any) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== formData.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
password_confirm: [
|
||||
{ required: true, validator: validatePasswordConfirm, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
|
||||
try {
|
||||
await changeFirstPassword({
|
||||
password: formData.password,
|
||||
password_confirm: formData.password_confirm
|
||||
})
|
||||
|
||||
ElMessage.success('密码修改成功,请重新登录')
|
||||
|
||||
// 更新 store 中的状态
|
||||
userStore.isPaw = 1
|
||||
|
||||
// 清除登录信息并跳转到登录页
|
||||
await userStore.logout()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.msg || error?.message || '密码修改失败')
|
||||
}
|
||||
}
|
||||
|
||||
const { isLock, lockFn: lockSubmit } = useLockFn(handleSubmit)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.change-password {
|
||||
background-image: url('./images/login_bg.png');
|
||||
@apply min-h-screen bg-no-repeat bg-center bg-cover;
|
||||
}
|
||||
</style>
|
||||
@@ -144,7 +144,14 @@ const handleLogin = async () => {
|
||||
remember: remAccount.value,
|
||||
account: remAccount.value ? formData.account : ''
|
||||
})
|
||||
await userStore.login(formData)
|
||||
const result: any = await userStore.login(formData)
|
||||
|
||||
// 检查是否需要修改密码
|
||||
if (result.is_paw === 0) {
|
||||
router.push('/change-password')
|
||||
return
|
||||
}
|
||||
|
||||
redirectAfterLogin()
|
||||
}
|
||||
|
||||
@@ -160,7 +167,14 @@ const redirectAfterLogin = () => {
|
||||
const handleWxWorkCodeLogin = async (code: string) => {
|
||||
wxWorkAutoLogin.value = true
|
||||
try {
|
||||
await userStore.workWechatLogin(code)
|
||||
const result: any = await userStore.workWechatLogin(code)
|
||||
|
||||
// 检查是否需要修改密码
|
||||
if (result.is_paw === 0) {
|
||||
router.push('/change-password')
|
||||
return
|
||||
}
|
||||
|
||||
redirectAfterLogin()
|
||||
} catch (error: any) {
|
||||
console.error('企业微信登录失败:', error)
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<!-- 医生状态 -->
|
||||
<el-form-item label="医生状态">
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="formData.disable" :active-value="0" :inactive-value="1" />
|
||||
<span class="ml-2 text-gray-500">{{ formData.disable === 0 ? '启用' : '禁用' }}</span>
|
||||
</el-form-item>
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
</el-form-item>
|
||||
|
||||
<!-- 医生状态 -->
|
||||
<el-form-item label="医生状态">
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="formData.disable" :active-value="0" :inactive-value="1" />
|
||||
<span class="ml-2 text-gray-500">{{ formData.disable === 0 ? '启用' : '禁用' }}</span>
|
||||
</el-form-item>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="pager.loading" class="mt-4 !border-none" shadow="never">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-button type="primary" @click="handleAdd" v-perms="['wcf.prescription/add']">
|
||||
<template #icon>
|
||||
<icon name="el-icon-Plus" />
|
||||
</template>
|
||||
@@ -61,13 +61,25 @@
|
||||
<el-table-column label="创建时间" prop="create_time" min-width="160" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleView(row)">
|
||||
<el-button type="primary" link @click="handleView(row)" v-perms="['wcf.prescription/read']">
|
||||
查看
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-button
|
||||
v-if="canEditPrescription(row)"
|
||||
type="primary"
|
||||
link
|
||||
@click="handleEdit(row)"
|
||||
v-perms="['wcf.prescription/edit']"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row.id)">
|
||||
<el-button
|
||||
v-if="canDeletePrescription(row)"
|
||||
type="danger"
|
||||
link
|
||||
@click="handleDelete(row.id)"
|
||||
v-perms="['wcf.prescription/delete']"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -115,13 +127,9 @@
|
||||
</el-button>
|
||||
<el-table :data="editForm.herbs" class="mt-2" border>
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="200">
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.name"
|
||||
placeholder="请输入药材名称"
|
||||
:disabled="editMode === 'view'"
|
||||
/>
|
||||
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="150">
|
||||
@@ -184,8 +192,10 @@ import {
|
||||
prescriptionLibraryDelete
|
||||
} from '@/api/tcm'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
@@ -213,11 +223,33 @@ const rules: FormRules = {
|
||||
]
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 与 server/config/project.php prescription_library_manage_all_roles 保持一致 */
|
||||
const PRESCRIPTION_MANAGE_ALL_ROLE_IDS = [0, 3]
|
||||
|
||||
const { pager, getLists, resetParams, resetPage } = usePaging({
|
||||
fetchFun: prescriptionLibraryLists,
|
||||
params: formData
|
||||
})
|
||||
|
||||
const isPrescriptionOwner = (row: { creator_id?: number | string }) =>
|
||||
Number(row.creator_id) === Number(userStore.userInfo?.id)
|
||||
|
||||
/** 超管或管理员角色:可编辑/删除任意处方(与后端 canManageAllPrescriptions 一致) */
|
||||
const userCanManageAllPrescriptions = () => {
|
||||
const u = userStore.userInfo
|
||||
if (!u) return false
|
||||
if (Number(u.root) === 1) return true
|
||||
const ids = Array.isArray(u.role_ids) ? u.role_ids.map((n: unknown) => Number(n)) : []
|
||||
return PRESCRIPTION_MANAGE_ALL_ROLE_IDS.some((rid) => ids.includes(rid))
|
||||
}
|
||||
|
||||
const canEditPrescription = (row: { creator_id?: number | string }) =>
|
||||
userCanManageAllPrescriptions() || isPrescriptionOwner(row)
|
||||
|
||||
const canDeletePrescription = canEditPrescription
|
||||
|
||||
// 添加药材
|
||||
const addHerb = () => {
|
||||
editForm.herbs.push({
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
<template>
|
||||
<div class="medicine-library">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-lg font-medium">药品库管理</span>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
添加药品
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form inline class="mb-4">
|
||||
<el-form-item label="药材名称">
|
||||
<el-input v-model="queryParams.name" placeholder="请输入药材名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="queryParams.supplier" placeholder="请输入供应商名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="tableData" border v-loading="loading" stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="image" label="图片" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:preview-src-list="[row.image]"
|
||||
fit="cover"
|
||||
class="w-16 h-16 rounded"
|
||||
/>
|
||||
<span v-else class="text-gray-400">暂无图片</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="药材名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="supplier" label="供应商" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="80" align="center" />
|
||||
<el-table-column prop="settlement_price" label="结算价(元)" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-blue-600">{{ formatPrice(row.settlement_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="retail_price" label="零售价(元)" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-green-600 font-medium">{{ formatPrice(row.retail_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="stock" label="库存" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStockTagType(row.stock)" size="small">
|
||||
{{ row.stock || 0 }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page_no"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleQuery"
|
||||
@current-change="handleQuery"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
@close="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="药材名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入药材名称" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="供应商" prop="supplier">
|
||||
<el-input v-model="formData.supplier" placeholder="请输入供应商名称" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-select v-model="formData.unit" placeholder="请选择单位" clearable style="width: 100%">
|
||||
<el-option label="克" value="克" />
|
||||
<el-option label="千克" value="千克" />
|
||||
<el-option label="斤" value="斤" />
|
||||
<el-option label="盒" value="盒" />
|
||||
<el-option label="瓶" value="瓶" />
|
||||
<el-option label="袋" value="袋" />
|
||||
<el-option label="包" value="包" />
|
||||
<el-option label="片" value="片" />
|
||||
<el-option label="粒" value="粒" />
|
||||
<el-option label="支" value="支" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="结算价" prop="settlement_price">
|
||||
<el-input-number
|
||||
v-model="formData.settlement_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="ml-2 text-gray-500 text-sm">元</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="零售价" prop="retail_price">
|
||||
<el-input-number
|
||||
v-model="formData.retail_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="ml-2 text-gray-500 text-sm">元</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="库存" prop="stock">
|
||||
<el-input-number
|
||||
v-model="formData.stock"
|
||||
:min="0"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="药品图片" prop="image">
|
||||
<el-upload
|
||||
class="medicine-uploader"
|
||||
:action="uploadAction"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadSuccess"
|
||||
:before-upload="beforeUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<img v-if="formData.image" :src="formData.image" class="medicine-image" />
|
||||
<el-icon v-else class="medicine-uploader-icon"><Plus /></el-icon>
|
||||
</el-upload>
|
||||
<div class="text-gray-500 text-xs mt-1">建议尺寸:300x300像素,支持jpg、png格式,大小不超过2MB</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :label="1">启用</el-radio>
|
||||
<el-radio :label="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="medicineLibrary">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessageBox, type FormInstance, type FormRules, type UploadProps } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { medicineLists, medicineAdd, medicineEdit, medicineDelete } from '@/api/medicine'
|
||||
|
||||
interface Medicine {
|
||||
id?: number
|
||||
name: string
|
||||
supplier: string
|
||||
unit: string
|
||||
settlement_price: number
|
||||
retail_price: number
|
||||
stock: number
|
||||
image: string
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const dialogVisible = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
const queryParams = reactive({
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
name: '',
|
||||
supplier: ''
|
||||
})
|
||||
|
||||
const tableData = ref<Medicine[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
const formData = ref<Medicine>({
|
||||
name: '',
|
||||
supplier: '',
|
||||
unit: '克',
|
||||
settlement_price: 0,
|
||||
retail_price: 0,
|
||||
stock: 0,
|
||||
image: '',
|
||||
status: 1,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const formRules: FormRules = {
|
||||
name: [{ required: true, message: '请输入药材名称', trigger: 'blur' }],
|
||||
supplier: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }],
|
||||
unit: [{ required: true, message: '请选择单位', trigger: 'change' }],
|
||||
settlement_price: [{ required: true, message: '请输入结算价', trigger: 'blur' }],
|
||||
retail_price: [{ required: true, message: '请输入零售价', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return formData.value.id ? '编辑药品' : '添加药品'
|
||||
})
|
||||
|
||||
const uploadAction = computed(() => {
|
||||
return import.meta.env.VITE_APP_BASE_URL + '/api/upload/image'
|
||||
})
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
return {
|
||||
token: getToken()
|
||||
}
|
||||
})
|
||||
|
||||
const formatPrice = (price: number | string | null | undefined) => {
|
||||
if (price === null || price === undefined || price === '') return '0.00'
|
||||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||||
return isNaN(num) ? '0.00' : num.toFixed(2)
|
||||
}
|
||||
|
||||
const getStockTagType = (stock: number) => {
|
||||
if (stock === 0) return 'danger'
|
||||
if (stock < 10) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const handleQuery = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.name = ''
|
||||
queryParams.supplier = ''
|
||||
queryParams.page_no = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
supplier: '',
|
||||
unit: '克',
|
||||
settlement_price: 0,
|
||||
retail_price: 0,
|
||||
stock: 0,
|
||||
image: '',
|
||||
status: 1,
|
||||
remark: ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row: Medicine) => {
|
||||
formData.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Medicine) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该药品吗?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await medicineDelete({ id: row.id })
|
||||
feedback.msgSuccess('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
feedback.msgError('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (formData.value.id) {
|
||||
await medicineEdit(formData.value)
|
||||
} else {
|
||||
await medicineAdd(formData.value)
|
||||
}
|
||||
feedback.msgSuccess('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
} catch (error) {
|
||||
feedback.msgError('保存失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDialogClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isLt2M = file.size / 1024 / 1024 < 2
|
||||
|
||||
if (!isImage) {
|
||||
feedback.msgError('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
if (!isLt2M) {
|
||||
feedback.msgError('图片大小不能超过 2MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUploadSuccess: UploadProps['onSuccess'] = (response) => {
|
||||
if (response.code === 1 && response.data?.url) {
|
||||
formData.value.image = response.data.url
|
||||
feedback.msgSuccess('上传成功')
|
||||
} else {
|
||||
feedback.msgError(response.msg || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await medicineLists(queryParams)
|
||||
tableData.value = res.lists || []
|
||||
total.value = res.count || 0
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
feedback.msgError('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.medicine-library {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.medicine-uploader {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: var(--el-transition-duration-fast);
|
||||
}
|
||||
|
||||
.medicine-uploader:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.medicine-uploader-icon {
|
||||
font-size: 28px;
|
||||
color: #8c939d;
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
text-align: center;
|
||||
line-height: 148px;
|
||||
}
|
||||
|
||||
.medicine-image {
|
||||
width: 148px;
|
||||
height: 148px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
+465
-204
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="mb-4">
|
||||
<el-form inline>
|
||||
<el-form-item label="医生">
|
||||
@@ -24,20 +24,34 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" class="mb-4" show-icon>
|
||||
每条排班为一段连续可挂号时间;同一天可添加多段(如白班 + 夜班)。休息时段不添加出诊段即可。号源间隔默认 15 分钟;号源数填 0 表示按时段长度自动满号。
|
||||
</el-alert>
|
||||
|
||||
<div class="text-center mb-4 text-gray-600">
|
||||
{{ weekText }}
|
||||
</div>
|
||||
|
||||
<el-table :data="tableData" border v-loading="loading">
|
||||
<el-table :data="filteredTableData" border v-loading="loading">
|
||||
<el-table-column prop="doctorName" label="医生" width="120" fixed />
|
||||
<el-table-column v-for="day in weekDays" :key="day.date" :label="day.label" min-width="150">
|
||||
<el-table-column v-for="day in weekDays" :key="day.date" :label="day.label" min-width="168">
|
||||
<template #default="{ row }">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'morning')">
|
||||
上午: {{ getRosterText(row, day.date, 'morning') }}
|
||||
</div>
|
||||
<div class="text-xs p-1 bg-gray-100 rounded cursor-pointer hover:bg-gray-200" @click="editRoster(row, day.date, 'afternoon')">
|
||||
下午: {{ getRosterText(row, day.date, 'afternoon') }}
|
||||
<div
|
||||
class="day-cell rounded border border-dashed border-gray-200 p-1 min-h-[56px] cursor-pointer hover:bg-gray-50"
|
||||
@click="openDayDialog(row, day.date)"
|
||||
>
|
||||
<template v-if="getDaySegments(row, day.date).length === 0">
|
||||
<div class="text-xs text-gray-400 text-center py-2">点击管理</div>
|
||||
</template>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="seg in getDaySegments(row, day.date)"
|
||||
:key="seg.id"
|
||||
class="text-xs px-1 py-0.5 rounded leading-tight"
|
||||
:class="segmentChipClass(seg.status)"
|
||||
>
|
||||
{{ formatSegmentLine(seg) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,48 +59,106 @@
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="编辑排班" width="500px">
|
||||
<el-form :model="editForm" label-width="100px">
|
||||
<el-form-item label="医生">
|
||||
<span>{{ editForm.doctorName }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期">
|
||||
<span>{{ editForm.date }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="时段">
|
||||
<span>{{ editForm.period === 'morning' ? '上午' : '下午' }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio :label="1">出诊</el-radio>
|
||||
<el-radio :label="2">停诊</el-radio>
|
||||
<el-radio :label="3">休息</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="号源数" v-if="editForm.status === 1">
|
||||
<el-input-number v-model="editForm.quota" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 某日排班列表 -->
|
||||
<el-dialog v-model="dayDialogVisible" :title="dayDialogTitle" width="720px" destroy-on-close>
|
||||
<el-table :data="dayDialogSegments" border size="small" max-height="360">
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">{{ row.start_time }} - {{ row.end_time }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班次" width="80">
|
||||
<template #default="{ row }">{{ shiftLabel(row.shift_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="88">
|
||||
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="间隔" width="72">
|
||||
<template #default="{ row }">{{ row.slot_minutes || 15 }} 分</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="号源" width="72">
|
||||
<template #default="{ row }">{{ row.status === 1 ? row.quota || '不限' : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openSegmentForm(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="removeSegment(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRoster">保存</el-button>
|
||||
<el-button @click="dayDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="openSegmentForm()">添加时段</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量排班弹窗 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量排班" width="700px">
|
||||
<el-form :model="batchForm" label-width="100px">
|
||||
<el-form-item label="选择医生" required>
|
||||
<el-select v-model="batchForm.doctorIds" multiple placeholder="请选择医生" class="w-full">
|
||||
<el-option
|
||||
v-for="doctor in tableData"
|
||||
:key="doctor.doctorId"
|
||||
:label="doctor.doctorName"
|
||||
:value="doctor.doctorId"
|
||||
<!-- 单段排班表单 -->
|
||||
<el-dialog v-model="segmentDialogVisible" :title="segmentForm.id ? '编辑时段' : '添加时段'" width="520px" destroy-on-close>
|
||||
<el-form :model="segmentForm" label-width="110px">
|
||||
<el-form-item label="接诊时间" required>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<el-time-select
|
||||
v-model="segmentForm.start_time"
|
||||
start="00:00"
|
||||
step="00:15"
|
||||
end="23:45"
|
||||
placeholder="开始"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<span>至</span>
|
||||
<el-time-select
|
||||
v-model="segmentForm.end_time"
|
||||
start="00:00"
|
||||
step="00:15"
|
||||
end="23:45"
|
||||
placeholder="结束"
|
||||
style="width: 120px"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="班次标签">
|
||||
<el-select v-model="segmentForm.shift_type" placeholder="选填" clearable style="width: 200px">
|
||||
<el-option label="白班" value="day" />
|
||||
<el-option label="夜班" value="night" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="号源间隔(分)">
|
||||
<el-input-number v-model="segmentForm.slot_minutes" :min="5" :max="120" :step="5" />
|
||||
<span class="ml-2 text-gray-500 text-sm">默认 15</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="segmentForm.status">
|
||||
<el-radio :label="1">出诊</el-radio>
|
||||
<el-radio :label="2">停诊</el-radio>
|
||||
<el-radio :label="3">休息</el-radio>
|
||||
<el-radio :label="4">请假</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="号源上限" v-if="segmentForm.status === 1">
|
||||
<el-input-number v-model="segmentForm.quota" :min="0" :max="200" />
|
||||
<div class="text-gray-500 text-xs mt-1">0 表示不限制(由时段÷间隔计算可约数)</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大接诊" v-if="segmentForm.status === 1">
|
||||
<el-input-number v-model="segmentForm.max_patients" :min="0" :max="500" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="segmentForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="segmentDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="segmentSaving" @click="saveSegment">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量排班 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量排班" width="780px" destroy-on-close>
|
||||
<el-form :model="batchForm" label-width="100px">
|
||||
<el-form-item label="选择医生" required>
|
||||
<el-select v-model="batchForm.doctorIds" multiple placeholder="请选择医生" class="w-full" filterable>
|
||||
<el-option v-for="doctor in tableData" :key="doctor.doctorId" :label="doctor.doctorName" :value="doctor.doctorId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="日期范围" required>
|
||||
<el-date-picker
|
||||
v-model="batchForm.dateRange"
|
||||
@@ -100,14 +172,7 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择时段" required>
|
||||
<el-checkbox-group v-model="batchForm.periods">
|
||||
<el-checkbox label="morning">上午</el-checkbox>
|
||||
<el-checkbox label="afternoon">下午</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择星期" required>
|
||||
<el-form-item label="星期" required>
|
||||
<el-checkbox-group v-model="batchForm.weekdays">
|
||||
<el-checkbox :label="1">周一</el-checkbox>
|
||||
<el-checkbox :label="2">周二</el-checkbox>
|
||||
@@ -119,6 +184,27 @@
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
|
||||
<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" />
|
||||
<span>—</span>
|
||||
<el-time-select v-model="seg.end_time" start="00:00" step="00:15" 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" />
|
||||
</el-select>
|
||||
<span class="text-sm text-gray-500">间隔</span>
|
||||
<el-input-number v-model="seg.slot_minutes" :min="5" :max="120" :step="5" style="width: 100px" />
|
||||
<span class="text-sm text-gray-500">号源0=不限</span>
|
||||
<el-input-number v-model="seg.quota" :min="0" :max="200" style="width: 100px" />
|
||||
<el-button type="danger" link @click="batchRemoveSegment(idx)" :disabled="batchForm.segments.length <= 1">删除</el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="batchAddSegment">+ 增加一段</el-button>
|
||||
<el-button type="success" link @click="batchAddNightTemplate">+ 夜班 18:00–22:00</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排班状态" required>
|
||||
<el-radio-group v-model="batchForm.status">
|
||||
<el-radio :label="1">出诊</el-radio>
|
||||
@@ -128,16 +214,8 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="号源数" v-if="batchForm.status === 1">
|
||||
<el-input-number v-model="batchForm.quota" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大接诊数" v-if="batchForm.status === 1">
|
||||
<el-input-number v-model="batchForm.maxPatients" :min="0" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="batchForm.remark" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
<el-input v-model="batchForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -155,99 +233,92 @@ import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { adminLists } from '@/api/perms/admin'
|
||||
import { rosterLists, rosterSave, rosterDelete, rosterBatchSave } from '@/api/doctor'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
|
||||
interface RosterItem {
|
||||
id?: number
|
||||
interface RosterSegment {
|
||||
id: number
|
||||
status: number
|
||||
quota: number
|
||||
max_patients?: number
|
||||
start_time: string
|
||||
end_time: string
|
||||
shift_type?: string | null
|
||||
slot_minutes?: number
|
||||
remark?: string
|
||||
period?: string
|
||||
}
|
||||
|
||||
interface DoctorRow {
|
||||
doctorId: number
|
||||
doctorName: string
|
||||
rosters: Record<string, RosterItem>
|
||||
/** date -> segments */
|
||||
rostersByDate: Record<string, RosterSegment[]>
|
||||
}
|
||||
|
||||
const searchName = ref('')
|
||||
const currentWeekStart = ref(dayjs().startOf('isoWeek'))
|
||||
const dialogVisible = ref(false)
|
||||
const loading = ref(false)
|
||||
const doctorOptions = ref<any[]>([])
|
||||
|
||||
// 批量排班
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchSaving = ref(false)
|
||||
const batchForm = ref({
|
||||
doctorIds: [] as number[],
|
||||
dateRange: [] as string[],
|
||||
periods: ['morning', 'afternoon'] as string[],
|
||||
weekdays: [1, 2, 3, 4, 5] as number[], // 默认周一到周五
|
||||
const dayDialogVisible = ref(false)
|
||||
const dayDialogDoctor = ref<DoctorRow | null>(null)
|
||||
const dayDialogDate = ref('')
|
||||
const dayDialogSegments = ref<RosterSegment[]>([])
|
||||
|
||||
const segmentDialogVisible = ref(false)
|
||||
const segmentSaving = ref(false)
|
||||
const segmentForm = ref({
|
||||
id: null as number | null,
|
||||
doctorId: 0,
|
||||
date: '',
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
shift_type: '' as string,
|
||||
slot_minutes: 15,
|
||||
status: 1,
|
||||
quota: 20,
|
||||
maxPatients: 30,
|
||||
quota: 0,
|
||||
max_patients: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 加载医生列表(role_id=1)
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1 // 只获取医生角色
|
||||
})
|
||||
doctorOptions.value = res?.lists || []
|
||||
|
||||
// 转换为表格数据格式
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
doctorName: doctor.name || doctor.account,
|
||||
rosters: {}
|
||||
}))
|
||||
|
||||
// 加载排班数据
|
||||
await loadRosterData()
|
||||
} catch (error) {
|
||||
console.error('加载医生列表失败:', error)
|
||||
feedback.msgError('加载医生列表失败')
|
||||
}
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchSaving = ref(false)
|
||||
|
||||
interface BatchSegTpl {
|
||||
start_time: string
|
||||
end_time: string
|
||||
shift_type: string
|
||||
slot_minutes: number
|
||||
quota: number
|
||||
}
|
||||
|
||||
// 加载排班数据
|
||||
const loadRosterData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
start_date: currentWeekStart.value.format('YYYY-MM-DD'),
|
||||
end_date: currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const res = await rosterLists(params)
|
||||
const rosters = res?.lists || []
|
||||
|
||||
// 将排班数据填充到医生行中
|
||||
tableData.value.forEach(doctor => {
|
||||
doctor.rosters = {}
|
||||
rosters.forEach((roster: any) => {
|
||||
if (roster.doctor_id === doctor.doctorId) {
|
||||
const key = `${roster.date}_${roster.period}`
|
||||
doctor.rosters[key] = {
|
||||
id: roster.id,
|
||||
status: roster.status,
|
||||
quota: roster.quota || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载排班数据失败:', error)
|
||||
feedback.msgError('加载排班数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const defaultBatchSegments = (): BatchSegTpl[] => [
|
||||
{ start_time: '09:00', end_time: '12:00', shift_type: 'day', slot_minutes: 15, quota: 0 },
|
||||
{ start_time: '14:00', end_time: '18:00', shift_type: 'day', slot_minutes: 15, quota: 0 }
|
||||
]
|
||||
|
||||
const batchForm = ref({
|
||||
doctorIds: [] as number[],
|
||||
dateRange: [] as string[],
|
||||
weekdays: [1, 2, 3, 4, 5] as number[],
|
||||
status: 1,
|
||||
remark: '',
|
||||
segments: defaultBatchSegments()
|
||||
})
|
||||
|
||||
const tableData = ref<DoctorRow[]>([])
|
||||
|
||||
const filteredTableData = computed(() => {
|
||||
const q = searchName.value.trim()
|
||||
if (!q) return tableData.value
|
||||
return tableData.value.filter((r) => r.doctorName.includes(q))
|
||||
})
|
||||
|
||||
const dayDialogTitle = computed(() => {
|
||||
if (!dayDialogDoctor.value || !dayDialogDate.value) return '当日排班'
|
||||
return `${dayDialogDoctor.value.doctorName} · ${dayDialogDate.value}`
|
||||
})
|
||||
|
||||
const weekDays = computed(() => {
|
||||
const days = []
|
||||
@@ -268,61 +339,228 @@ const weekText = computed(() => {
|
||||
return `${start} 至 ${end}`
|
||||
})
|
||||
|
||||
const tableData = ref<DoctorRow[]>([])
|
||||
|
||||
const editForm = ref({
|
||||
id: null as number | null,
|
||||
doctorId: 0,
|
||||
doctorName: '',
|
||||
date: '',
|
||||
period: 'morning',
|
||||
status: 1,
|
||||
quota: 20
|
||||
})
|
||||
|
||||
const getRosterText = (row: DoctorRow, date: string, period: string) => {
|
||||
const key = `${date}_${period}`
|
||||
const roster = row.rosters[key]
|
||||
if (!roster) return '未排班'
|
||||
const statusMap: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息' }
|
||||
return roster.status === 1 ? `${statusMap[roster.status]}(${roster.quota})` : statusMap[roster.status]
|
||||
function statusText(s: number) {
|
||||
const m: Record<number, string> = { 1: '出诊', 2: '停诊', 3: '休息', 4: '请假' }
|
||||
return m[s] ?? String(s)
|
||||
}
|
||||
|
||||
const editRoster = (row: DoctorRow, date: string, period: string) => {
|
||||
const key = `${date}_${period}`
|
||||
const roster = row.rosters[key] || { status: 1, quota: 20 }
|
||||
editForm.value = {
|
||||
id: roster.id || null,
|
||||
doctorId: row.doctorId,
|
||||
doctorName: row.doctorName,
|
||||
date,
|
||||
period,
|
||||
status: roster.status,
|
||||
quota: roster.quota
|
||||
function shiftLabel(t?: string | null) {
|
||||
if (t === 'day') return '白班'
|
||||
if (t === 'night') return '夜班'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function segmentChipClass(status: number) {
|
||||
if (status === 1) return 'bg-emerald-50 text-emerald-800 border border-emerald-100'
|
||||
if (status === 2) return 'bg-amber-50 text-amber-900 border border-amber-100'
|
||||
if (status === 3) return 'bg-slate-100 text-slate-600 border border-slate-200'
|
||||
return 'bg-gray-100 text-gray-700 border border-gray-200'
|
||||
}
|
||||
|
||||
function formatSegmentLine(seg: RosterSegment) {
|
||||
const t = `${seg.start_time}-${seg.end_time}`
|
||||
const sh = shiftLabel(seg.shift_type)
|
||||
const shPart = sh !== '—' ? ` ${sh}` : ''
|
||||
if (seg.status === 1) {
|
||||
const q = seg.quota > 0 ? ` 号源${seg.quota}` : ''
|
||||
return `${t}${shPart} 出诊${q}`
|
||||
}
|
||||
dialogVisible.value = true
|
||||
return `${t}${shPart} ${statusText(seg.status)}`
|
||||
}
|
||||
|
||||
const saveRoster = async () => {
|
||||
function getDaySegments(row: DoctorRow, date: string): RosterSegment[] {
|
||||
return row.rostersByDate[date] || []
|
||||
}
|
||||
|
||||
function refreshDayDialogList() {
|
||||
if (!dayDialogDoctor.value || !dayDialogDate.value) return
|
||||
const row = tableData.value.find((r) => r.doctorId === dayDialogDoctor.value!.doctorId)
|
||||
if (!row) return
|
||||
dayDialogSegments.value = [...(row.rostersByDate[dayDialogDate.value] || [])].sort((a, b) =>
|
||||
a.start_time.localeCompare(b.start_time)
|
||||
)
|
||||
}
|
||||
|
||||
function openDayDialog(row: DoctorRow, date: string) {
|
||||
dayDialogDoctor.value = row
|
||||
dayDialogDate.value = date
|
||||
refreshDayDialogList()
|
||||
dayDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openSegmentForm(existing?: RosterSegment) {
|
||||
if (!dayDialogDoctor.value || !dayDialogDate.value) {
|
||||
feedback.msgWarning('请先选择日期格子')
|
||||
return
|
||||
}
|
||||
if (existing) {
|
||||
segmentForm.value = {
|
||||
id: existing.id,
|
||||
doctorId: dayDialogDoctor.value.doctorId,
|
||||
date: dayDialogDate.value,
|
||||
start_time: existing.start_time || '09:00',
|
||||
end_time: existing.end_time || '12:00',
|
||||
shift_type: existing.shift_type || '',
|
||||
slot_minutes: existing.slot_minutes || 15,
|
||||
status: existing.status,
|
||||
quota: existing.quota ?? 0,
|
||||
max_patients: existing.max_patients ?? 0,
|
||||
remark: existing.remark || ''
|
||||
}
|
||||
} else {
|
||||
segmentForm.value = {
|
||||
id: null,
|
||||
doctorId: dayDialogDoctor.value.doctorId,
|
||||
date: dayDialogDate.value,
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
shift_type: 'day',
|
||||
slot_minutes: 15,
|
||||
status: 1,
|
||||
quota: 0,
|
||||
max_patients: 0,
|
||||
remark: ''
|
||||
}
|
||||
}
|
||||
segmentDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveSegment() {
|
||||
const f = segmentForm.value
|
||||
if (!f.start_time || !f.end_time) {
|
||||
feedback.msgWarning('请填写接诊开始与结束时间')
|
||||
return
|
||||
}
|
||||
if (f.status === 1 && f.start_time >= f.end_time) {
|
||||
feedback.msgWarning('出诊时结束时间须晚于开始时间')
|
||||
return
|
||||
}
|
||||
if (f.start_time >= f.end_time) {
|
||||
feedback.msgWarning('结束时间须晚于开始时间')
|
||||
return
|
||||
}
|
||||
|
||||
segmentSaving.value = true
|
||||
try {
|
||||
await rosterSave({
|
||||
id: editForm.value.id,
|
||||
doctor_id: editForm.value.doctorId,
|
||||
date: editForm.value.date,
|
||||
period: editForm.value.period,
|
||||
status: editForm.value.status,
|
||||
quota: editForm.value.status === 1 ? editForm.value.quota : 0
|
||||
})
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
doctor_id: f.doctorId,
|
||||
date: f.date,
|
||||
period: 'segment',
|
||||
start_time: f.start_time,
|
||||
end_time: f.end_time,
|
||||
slot_minutes: f.slot_minutes || 15,
|
||||
status: f.status,
|
||||
quota: f.status === 1 ? f.quota : 0,
|
||||
max_patients: f.status === 1 ? f.max_patients : 0,
|
||||
remark: f.remark || ''
|
||||
}
|
||||
if (f.shift_type) {
|
||||
payload.shift_type = f.shift_type
|
||||
}
|
||||
if (f.id) {
|
||||
payload.id = f.id
|
||||
}
|
||||
|
||||
await rosterSave(payload)
|
||||
feedback.msgSuccess('保存成功')
|
||||
dialogVisible.value = false
|
||||
segmentDialogVisible.value = false
|
||||
await loadRosterData()
|
||||
refreshDayDialogList()
|
||||
} catch {
|
||||
feedback.msgError('保存失败')
|
||||
} finally {
|
||||
segmentSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSegment(row: RosterSegment) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该时段?', '提示', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await rosterDelete({ id: row.id })
|
||||
feedback.msgSuccess('已删除')
|
||||
await loadRosterData()
|
||||
refreshDayDialogList()
|
||||
} catch {
|
||||
feedback.msgError('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await adminLists({
|
||||
page_no: 1,
|
||||
page_size: 1000,
|
||||
role_id: 1
|
||||
})
|
||||
tableData.value = (res?.lists || []).map((doctor: any) => ({
|
||||
doctorId: doctor.id,
|
||||
doctorName: doctor.name || doctor.account,
|
||||
rostersByDate: {} as Record<string, RosterSegment[]>
|
||||
}))
|
||||
await loadRosterData()
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
feedback.msgError('保存失败')
|
||||
console.error('加载医生列表失败:', error)
|
||||
feedback.msgError('加载医生列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadRosterData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
start_date: currentWeekStart.value.format('YYYY-MM-DD'),
|
||||
end_date: currentWeekStart.value.add(6, 'day').format('YYYY-MM-DD')
|
||||
}
|
||||
const res = await rosterLists(params)
|
||||
const rosters = res?.lists || []
|
||||
|
||||
tableData.value.forEach((doctor) => {
|
||||
doctor.rostersByDate = {}
|
||||
rosters.forEach((roster: any) => {
|
||||
if (roster.doctor_id !== doctor.doctorId) return
|
||||
const d = roster.date
|
||||
if (!doctor.rostersByDate[d]) doctor.rostersByDate[d] = []
|
||||
doctor.rostersByDate[d].push({
|
||||
id: roster.id,
|
||||
status: roster.status,
|
||||
quota: roster.quota || 0,
|
||||
max_patients: roster.max_patients,
|
||||
start_time: roster.start_time || legacyStart(roster.period),
|
||||
end_time: roster.end_time || legacyEnd(roster.period),
|
||||
shift_type: roster.shift_type,
|
||||
slot_minutes: roster.slot_minutes ?? 15,
|
||||
remark: roster.remark,
|
||||
period: roster.period
|
||||
})
|
||||
})
|
||||
})
|
||||
refreshDayDialogList()
|
||||
} catch (error) {
|
||||
console.error('加载排班数据失败:', error)
|
||||
feedback.msgError('加载排班数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function legacyStart(period: string) {
|
||||
if (period === 'morning') return '09:00'
|
||||
if (period === 'afternoon') return '14:00'
|
||||
if (period === 'night') return '18:00'
|
||||
return '09:00'
|
||||
}
|
||||
|
||||
function legacyEnd(period: string) {
|
||||
if (period === 'morning') return '12:00'
|
||||
if (period === 'afternoon') return '18:00'
|
||||
if (period === 'night') return '22:00'
|
||||
return '12:00'
|
||||
}
|
||||
|
||||
const loadData = () => {
|
||||
loadRosterData()
|
||||
}
|
||||
@@ -342,24 +580,44 @@ const goToday = () => {
|
||||
loadRosterData()
|
||||
}
|
||||
|
||||
// 显示批量排班弹窗
|
||||
const showBatchDialog = () => {
|
||||
batchForm.value = {
|
||||
doctorIds: [],
|
||||
dateRange: [],
|
||||
periods: ['morning', 'afternoon'],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
status: 1,
|
||||
quota: 20,
|
||||
maxPatients: 30,
|
||||
remark: ''
|
||||
remark: '',
|
||||
segments: defaultBatchSegments()
|
||||
}
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 批量保存排班
|
||||
const batchAddSegment = () => {
|
||||
batchForm.value.segments.push({
|
||||
start_time: '09:00',
|
||||
end_time: '12:00',
|
||||
shift_type: 'day',
|
||||
slot_minutes: 15,
|
||||
quota: 0
|
||||
})
|
||||
}
|
||||
|
||||
const batchAddNightTemplate = () => {
|
||||
batchForm.value.segments.push({
|
||||
start_time: '18:00',
|
||||
end_time: '22:00',
|
||||
shift_type: 'night',
|
||||
slot_minutes: 15,
|
||||
quota: 0
|
||||
})
|
||||
}
|
||||
|
||||
const batchRemoveSegment = (idx: number) => {
|
||||
if (batchForm.value.segments.length <= 1) return
|
||||
batchForm.value.segments.splice(idx, 1)
|
||||
}
|
||||
|
||||
const handleBatchSave = async () => {
|
||||
// 验证
|
||||
if (batchForm.value.doctorIds.length === 0) {
|
||||
feedback.msgWarning('请选择医生')
|
||||
return
|
||||
@@ -368,46 +626,51 @@ const handleBatchSave = async () => {
|
||||
feedback.msgWarning('请选择日期范围')
|
||||
return
|
||||
}
|
||||
if (batchForm.value.periods.length === 0) {
|
||||
feedback.msgWarning('请选择时段')
|
||||
return
|
||||
}
|
||||
if (batchForm.value.weekdays.length === 0) {
|
||||
feedback.msgWarning('请选择星期')
|
||||
return
|
||||
}
|
||||
for (const seg of batchForm.value.segments) {
|
||||
if (!seg.start_time || !seg.end_time) {
|
||||
feedback.msgWarning('请完善时段模板的开始与结束时间')
|
||||
return
|
||||
}
|
||||
if (seg.start_time >= seg.end_time) {
|
||||
feedback.msgWarning('时段模板中结束时间须晚于开始时间')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
batchSaving.value = true
|
||||
try {
|
||||
// 生成排班数据
|
||||
const rosters: any[] = []
|
||||
const startDate = dayjs(batchForm.value.dateRange[0])
|
||||
const endDate = dayjs(batchForm.value.dateRange[1])
|
||||
|
||||
// 遍历日期范围
|
||||
let currentDate = startDate
|
||||
while (currentDate.isBefore(endDate) || currentDate.isSame(endDate, 'day')) {
|
||||
const weekday = currentDate.day() // 0-6,0是周日
|
||||
|
||||
// 检查是否在选中的星期内
|
||||
const weekday = currentDate.day()
|
||||
if (batchForm.value.weekdays.includes(weekday)) {
|
||||
// 遍历医生
|
||||
batchForm.value.doctorIds.forEach(doctorId => {
|
||||
// 遍历时段
|
||||
batchForm.value.periods.forEach(period => {
|
||||
rosters.push({
|
||||
batchForm.value.doctorIds.forEach((doctorId) => {
|
||||
batchForm.value.segments.forEach((seg) => {
|
||||
const row: any = {
|
||||
doctor_id: doctorId,
|
||||
date: currentDate.format('YYYY-MM-DD'),
|
||||
period: period,
|
||||
period: 'segment',
|
||||
start_time: seg.start_time,
|
||||
end_time: seg.end_time,
|
||||
slot_minutes: seg.slot_minutes || 15,
|
||||
status: batchForm.value.status,
|
||||
quota: batchForm.value.status === 1 ? batchForm.value.quota : 0,
|
||||
max_patients: batchForm.value.status === 1 ? batchForm.value.maxPatients : 0,
|
||||
quota: batchForm.value.status === 1 ? seg.quota : 0,
|
||||
max_patients: 0,
|
||||
remark: batchForm.value.remark
|
||||
})
|
||||
}
|
||||
if (seg.shift_type) {
|
||||
row.shift_type = seg.shift_type
|
||||
}
|
||||
rosters.push(row)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
currentDate = currentDate.add(1, 'day')
|
||||
}
|
||||
|
||||
@@ -416,9 +679,7 @@ const handleBatchSave = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用批量保存接口
|
||||
await rosterBatchSave({ rosters })
|
||||
|
||||
feedback.msgSuccess(`批量保存成功!共处理 ${rosters.length} 条记录`)
|
||||
batchDialogVisible.value = false
|
||||
await loadRosterData()
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="statistics-page">
|
||||
<el-card shadow="never" class="!border-none">
|
||||
<!-- 筛选区域 -->
|
||||
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||
<el-form-item label="医生">
|
||||
<el-select
|
||||
v-model="queryParams.doctor_id"
|
||||
placeholder="全部医生"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="doctor in doctorList"
|
||||
:key="doctor.id"
|
||||
:label="doctor.name"
|
||||
:value="doctor.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间范围">
|
||||
<el-radio-group v-model="queryParams.time_type" @change="handleTimeTypeChange">
|
||||
<el-radio-button label="today">今天</el-radio-button>
|
||||
<el-radio-button label="week">最近7天</el-radio-button>
|
||||
<el-radio-button label="month">最近30天</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="queryParams.time_type === 'custom'">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 260px"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getStatistics" :loading="loading">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 统计表格 -->
|
||||
<el-card shadow="never" class="!border-none mt-4">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="statisticsList"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266', fontWeight: '600' }"
|
||||
>
|
||||
<el-table-column prop="doctor_name" label="医生姓名" width="120" fixed />
|
||||
|
||||
<el-table-column label="部门统计" min-width="250" fixed>
|
||||
<template #default="{ row }">
|
||||
<div class="dept-stats">
|
||||
<el-tag
|
||||
v-for="(item, index) in parseDeptStats(row.dept_stats)"
|
||||
:key="index"
|
||||
type="primary"
|
||||
size="small"
|
||||
class="stat-tag"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="!row.dept_stats || row.dept_stats === '未分配'" class="text-gray-400">未分配</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="渠道统计" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="channel-stats">
|
||||
<el-tag
|
||||
v-for="(item, index) in parseChannelStats(row.channel_stats)"
|
||||
:key="index"
|
||||
type="success"
|
||||
size="small"
|
||||
class="stat-tag"
|
||||
>
|
||||
{{ item }}
|
||||
</el-tag>
|
||||
<span v-if="!row.channel_stats || row.channel_stats === '未设置'" class="text-gray-400">未设置</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="total_count" label="总诊单" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold text-blue-600">{{ row.total_count }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="registered_count" label="已挂号" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" size="small">{{ row.registered_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="completed_count" label="已完成" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="success" size="small">{{ row.completed_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="missed_count" label="已过号" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning" size="small">{{ row.missed_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="cancelled_count" label="已取消" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="small">{{ row.cancelled_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="completion_rate" label="完成率" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="getCompletionRateClass(row.completion_rate)">
|
||||
{{ row.completion_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="time_range" label="统计时间" min-width="200" />
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无统计数据" />
|
||||
</template>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { doctorLists, getDoctorStatistics } from '@/api/doctor'
|
||||
|
||||
const loading = ref(false)
|
||||
const doctorList = ref<any[]>([])
|
||||
const statisticsList = ref<any[]>([])
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
const queryParams = reactive({
|
||||
doctor_id: '',
|
||||
time_type: 'today',
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const getDoctorList = async () => {
|
||||
try {
|
||||
const res = await doctorLists({ role_id: 1 })
|
||||
doctorList.value = res.lists || []
|
||||
} catch (error) {
|
||||
console.error('获取医生列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatistics = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
time_type: queryParams.time_type
|
||||
}
|
||||
|
||||
if (queryParams.doctor_id) {
|
||||
params.doctor_id = queryParams.doctor_id
|
||||
}
|
||||
|
||||
if (queryParams.time_type === 'custom' && dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
|
||||
const res = await getDoctorStatistics(params)
|
||||
statisticsList.value = res.lists || []
|
||||
} catch (error: any) {
|
||||
console.error('获取统计数据错误:', error)
|
||||
ElMessage.error(error.msg || '获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimeTypeChange = () => {
|
||||
if (queryParams.time_type !== 'custom') {
|
||||
dateRange.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.doctor_id = ''
|
||||
queryParams.time_type = 'today'
|
||||
dateRange.value = []
|
||||
getStatistics()
|
||||
}
|
||||
|
||||
const getCompletionRateClass = (rate: number) => {
|
||||
if (rate >= 80) return 'text-green-600 font-semibold'
|
||||
if (rate >= 60) return 'text-blue-600 font-semibold'
|
||||
if (rate >= 40) return 'text-orange-600 font-semibold'
|
||||
return 'text-red-600 font-semibold'
|
||||
}
|
||||
|
||||
// 解析部门统计字符串为数组
|
||||
const parseDeptStats = (stats: string) => {
|
||||
if (!stats || stats === '未分配') return []
|
||||
return stats.split(' ').filter(item => item.trim())
|
||||
}
|
||||
|
||||
// 解析渠道统计字符串为数组
|
||||
const parseChannelStats = (stats: string) => {
|
||||
if (!stats || stats === '未设置') return []
|
||||
return stats.split(' ').filter(item => item.trim())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDoctorList()
|
||||
getStatistics()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.statistics-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dept-stats,
|
||||
.channel-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -129,12 +129,9 @@
|
||||
</el-button>
|
||||
<el-table :data="formData.herbs" class="mt-2" border>
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="150">
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.name"
|
||||
placeholder="请输入药材名称"
|
||||
/>
|
||||
<MedicineNameSelect v-model="row.name" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -301,6 +298,7 @@
|
||||
import { prescriptionAdd } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
size="small"
|
||||
@click="handleChat(row)"
|
||||
>
|
||||
聊天
|
||||
通话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-perms="['doctor.appointment/complete']"
|
||||
@@ -456,7 +456,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑患者弹窗 -->
|
||||
<edit-popup ref="editRef" @success="getLists" />
|
||||
<edit-popup ref="editRef" @success="loadData" />
|
||||
|
||||
<!-- 中医处方单 -->
|
||||
<tcm-prescription ref="prescriptionRef" @success="onPrescriptionSuccess" />
|
||||
@@ -506,14 +506,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { watch } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, watch } from 'vue'
|
||||
import { appointmentLists, cancelAppointment, completeAppointment, appointmentDetail } from '@/api/doctor'
|
||||
import { getCallSignature, generateMiniProgramQrcode, tcmDiagnosisDetail, prescriptionGetByAppointment } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import feedback from '@/utils/feedback'
|
||||
import EditPopup from '../diagnosis/edit.vue'
|
||||
import TcmPrescription from '@/components/tcm-prescription/index.vue'
|
||||
import ChatDialog from '@/components/chat-dialog/index.vue'
|
||||
|
||||
// 重组件异步加载,避免与列表同 chunk 阻塞路由进入与首帧渲染
|
||||
const EditPopup = defineAsyncComponent(() => import('../diagnosis/edit.vue'))
|
||||
const TcmPrescription = defineAsyncComponent(() => import('@/components/tcm-prescription/index.vue'))
|
||||
const ChatDialog = defineAsyncComponent(() => import('@/components/chat-dialog/index.vue'))
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
import {
|
||||
@@ -554,7 +556,9 @@ const formData = reactive({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
date_preset: 'today' as '' | 'today' | 'tomorrow' | 'day_after',
|
||||
diagnosis_confirmed: '' as '' | '0' | '1' // ''=全部 1=已确认 0=未确认
|
||||
diagnosis_confirmed: '' as '' | '0' | '1', // ''=全部 1=已确认 0=未确认
|
||||
/** 为 1 时后端 extend 返回各状态数量,避免额外 4 次列表请求 */
|
||||
include_status_counts: 0 as 0 | 1
|
||||
})
|
||||
|
||||
const activeTab = ref('1')
|
||||
@@ -568,7 +572,8 @@ const statusCount = ref<Record<number, number>>({
|
||||
|
||||
const { pager, getLists, resetPage, resetParams } = usePaging({
|
||||
fetchFun: appointmentLists,
|
||||
params: formData
|
||||
params: formData,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
// 手动修改日期范围时清除快捷日期
|
||||
@@ -593,29 +598,6 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// 获取各状态数量
|
||||
const getStatusCount = async () => {
|
||||
try {
|
||||
// 获取各状态的数量
|
||||
const promises = [1, 2, 3, 4].map(async (status) => {
|
||||
const res = await appointmentLists({
|
||||
...formData,
|
||||
status,
|
||||
page_no: 1,
|
||||
page_size: 1
|
||||
})
|
||||
return { status, count: res.count || 0 }
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
results.forEach(({ status, count }) => {
|
||||
statusCount.value[status] = count
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('获取状态数量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 待接诊行高亮
|
||||
const getRowClassName = ({ row }: { row: any }) => {
|
||||
return row.status === 1 ? 'row-pending' : ''
|
||||
@@ -631,10 +613,24 @@ const handleTabChange = (tabName: string | number) => {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
// 重写getLists,同时更新状态数量
|
||||
// 列表 + Tab 角标:一次请求(后端 extend.status_count),翻页等仅 getLists 不重复统计
|
||||
const loadData = async () => {
|
||||
await getLists()
|
||||
await getStatusCount()
|
||||
formData.include_status_counts = 1
|
||||
try {
|
||||
await getLists()
|
||||
const ext = pager.extend as { status_count?: Record<number | string, number> }
|
||||
const sc = ext?.status_count
|
||||
if (sc) {
|
||||
for (const k of [1, 2, 3, 4] as const) {
|
||||
const n = sc[k] ?? sc[String(k)]
|
||||
if (typeof n === 'number') {
|
||||
statusCount.value[k] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
formData.include_status_counts = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 更多下拉命令
|
||||
@@ -730,17 +726,24 @@ const getPastHistoryLabels = (values: string[] | string) => {
|
||||
}
|
||||
|
||||
const loadDetailDictOptions = async () => {
|
||||
const [dt, st, db, ph] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getDictData({ type: 'diabetes_type' }),
|
||||
getDictData({ type: 'past_history' })
|
||||
])
|
||||
diagnosisTypeOptions.value = dt?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = st?.syndrome_type || []
|
||||
diabetesTypeOptions.value = db?.diabetes_type || []
|
||||
pastHistoryOptions.value = ph?.past_history || []
|
||||
}
|
||||
|
||||
const detailDictReady = ref(false)
|
||||
const ensureDetailDictOptions = async () => {
|
||||
if (detailDictReady.value) return
|
||||
try {
|
||||
const [dt, st, db, ph] = await Promise.all([
|
||||
getDictData({ type: 'diagnosis_type' }),
|
||||
getDictData({ type: 'syndrome_type' }),
|
||||
getDictData({ type: 'diabetes_type' }),
|
||||
getDictData({ type: 'past_history' })
|
||||
])
|
||||
diagnosisTypeOptions.value = dt?.diagnosis_type || []
|
||||
syndromeTypeOptions.value = st?.syndrome_type || []
|
||||
diabetesTypeOptions.value = db?.diabetes_type || []
|
||||
pastHistoryOptions.value = ph?.past_history || []
|
||||
await loadDetailDictOptions()
|
||||
detailDictReady.value = true
|
||||
} catch (e) {
|
||||
console.error('加载字典失败:', e)
|
||||
}
|
||||
@@ -785,7 +788,8 @@ const handleAction = (command: string, row: any) => {
|
||||
// 查看详情(含患者诊单完整信息)
|
||||
const handleDetail = async (row: any) => {
|
||||
try {
|
||||
const res = await appointmentDetail({ id: row.id })
|
||||
const detailBundle = await Promise.all([ensureDetailDictOptions(), appointmentDetail({ id: row.id })])
|
||||
const res = detailBundle[1]
|
||||
detailData.value = res
|
||||
// 有 patient_id(即诊单ID)时,拉取患者完整信息
|
||||
if (res.patient_id) {
|
||||
@@ -966,8 +970,9 @@ formData.start_date = `${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${
|
||||
formData.end_date = formData.start_date
|
||||
formData.status = 1
|
||||
|
||||
loadData()
|
||||
loadDetailDictOptions()
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -36,6 +36,20 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="渠道来源:" required>
|
||||
<el-select
|
||||
v-model="form.channel_source"
|
||||
placeholder="请选择渠道来源"
|
||||
class="channel-source-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in channelOptions"
|
||||
:key="item.value"
|
||||
:label="item.name"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 预约医生 -->
|
||||
<el-form-item label="预约医生:">
|
||||
<div class="doctor-list">
|
||||
@@ -167,6 +181,7 @@ import { Refresh } from '@element-plus/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { getDoctors } from '@/api/tcm'
|
||||
import { getDictData } from '@/api/app'
|
||||
import { getAvailableSlots, createAppointment, rosterLists, appointmentLists } from '@/api/doctor'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
@@ -192,6 +207,7 @@ const timeSlots = ref<TimeSlot[]>([])
|
||||
const lastVisit = ref('')
|
||||
const selectedDoctorId = ref(0)
|
||||
const doctorRosterDates = ref<string[]>([]) // 医生有排班的日期列表
|
||||
const channelOptions = ref<{ name: string; value: string }[]>([])
|
||||
|
||||
const patientInfo = reactive({
|
||||
id: 0,
|
||||
@@ -206,7 +222,8 @@ const form = reactive({
|
||||
patientId: 0,
|
||||
date: '',
|
||||
appointmentTime: '',
|
||||
remark: ''
|
||||
remark: '',
|
||||
channel_source: '' as string
|
||||
})
|
||||
|
||||
// 生成未来7天的日期选项(只显示有排班的日期,且大于等于今天)
|
||||
@@ -271,7 +288,12 @@ const filteredTimeSlots = computed(() => {
|
||||
|
||||
// 是否可以提交
|
||||
const canSubmit = computed(() => {
|
||||
return selectedDoctorId.value && form.date && form.appointmentTime
|
||||
return (
|
||||
selectedDoctorId.value &&
|
||||
form.date &&
|
||||
form.appointmentTime &&
|
||||
!!form.channel_source
|
||||
)
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
@@ -292,10 +314,12 @@ const open = async (patient: any) => {
|
||||
form.date = ''
|
||||
form.appointmentTime = ''
|
||||
form.remark = ''
|
||||
form.channel_source = ''
|
||||
timeSlots.value = []
|
||||
doctorRosterDates.value = []
|
||||
lastVisit.value = ''
|
||||
|
||||
await loadChannelOptions()
|
||||
// 加载医生列表
|
||||
await loadDoctors()
|
||||
|
||||
@@ -327,6 +351,16 @@ const loadLastVisit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadChannelOptions = async () => {
|
||||
try {
|
||||
const data = await getDictData({ type: 'channels' })
|
||||
channelOptions.value = (data?.channels || []).filter((row: any) => row.status !== 0)
|
||||
} catch (e) {
|
||||
console.error('加载渠道来源失败:', e)
|
||||
channelOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 加载医生列表
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
@@ -511,6 +545,10 @@ const handleConfirm = async () => {
|
||||
feedback.msgWarning('请选择预约时间')
|
||||
return
|
||||
}
|
||||
if (!form.channel_source) {
|
||||
feedback.msgWarning('请选择渠道来源')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
@@ -523,7 +561,8 @@ const handleConfirm = async () => {
|
||||
period: 'all', // 全天时段
|
||||
appointment_time: form.appointmentTime,
|
||||
appointment_type: form.appointmentType,
|
||||
remark: form.remark
|
||||
remark: form.remark,
|
||||
channel_source: form.channel_source
|
||||
}
|
||||
|
||||
console.log('提交预约参数:', params)
|
||||
@@ -549,6 +588,11 @@ defineExpose({ open })
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.channel-source-select {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-list {
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
class="mb-4"
|
||||
title="视频通话与录制回放"
|
||||
>
|
||||
<p class="call-record-tip">
|
||||
<!-- <p class="call-record-tip">
|
||||
下列为与本诊单关联的通话记录。医生接通并同步房间号后,服务端会尝试自动发起「云端混流录制」(需配置 CAM 密钥与云点播等);录制完成后由腾讯云回调写入回放地址。同时,管理端浏览器会进行「本地录制」,挂断后自动上传并合并到本条记录。
|
||||
</p>
|
||||
<p class="call-record-tip muted">
|
||||
回调 URL 示例:<code>{{ callbackHint }}</code>
|
||||
(若配置了 <code>trtc.recording_callback_token</code>,请在 URL 后附加
|
||||
<code>?token=你的密钥</code>)
|
||||
</p>
|
||||
</p> -->
|
||||
</el-alert>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" border stripe empty-text="暂无通话记录">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="im-chat-record-panel">
|
||||
<el-alert type="info" show-icon :closable="false" class="mb-4" title="说明">
|
||||
<p class="panel-tip">
|
||||
展示腾讯云 IM 单聊记录:已合并患者 与
|
||||
展示单聊记录:已合并患者 与
|
||||
<strong>所有医生 / 医助账号</strong>分别产生的会话,按时间排序。
|
||||
数据来自 IM 漫游;若未开通漫游或某账号与该患者无会话,对应侧无消息。
|
||||
</p>
|
||||
|
||||
@@ -459,6 +459,7 @@
|
||||
v-model="formData.tongue_images"
|
||||
:limit="9"
|
||||
type="image"
|
||||
:exclude-domain="true"
|
||||
/>
|
||||
<div class="form-tips">支持上传多张舌苔照片,最多9张</div>
|
||||
</el-form-item>
|
||||
@@ -468,6 +469,7 @@
|
||||
v-model="formData.report_files"
|
||||
:limit="10"
|
||||
type="image"
|
||||
:exclude-domain="true"
|
||||
/>
|
||||
<div class="form-tips">支持上传检查报告、病历、彩超等影像科检查图片,最多10个文件</div>
|
||||
</el-form-item>
|
||||
@@ -521,8 +523,8 @@
|
||||
|
||||
<el-form-item label="病史补充" prop="remark">
|
||||
<el-input
|
||||
v-model="formData.remark"
|
||||
type="textarea"
|
||||
v-mode="formData.remark"
|
||||
type="ltextarea"
|
||||
:rows="2"
|
||||
placeholder="请输入病史补充"
|
||||
/>
|
||||
|
||||
@@ -490,16 +490,17 @@ import { getDictData } from '@/api/app'
|
||||
import { getWeappConfig } from '@/api/channel/weapp'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { Loading, Warning, List, CircleCheck, Clock, ArrowDown, RefreshRight, Picture, User, Document, Delete, CircleClose } from '@element-plus/icons-vue'
|
||||
import EditPopup from './edit.vue'
|
||||
import DetailPopup from './detail.vue'
|
||||
import AppointmentPopup from './appointment.vue'
|
||||
import AssistantWatchCallDialog from './components/AssistantWatchCallDialog.vue'
|
||||
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 { nextTick } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, onUnmounted, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const EditPopup = defineAsyncComponent(() => import('./edit.vue'))
|
||||
const DetailPopup = defineAsyncComponent(() => import('./detail.vue'))
|
||||
const AppointmentPopup = defineAsyncComponent(() => import('./appointment.vue'))
|
||||
const AssistantWatchCallDialog = defineAsyncComponent(() => import('./components/AssistantWatchCallDialog.vue'))
|
||||
|
||||
// 格式化时间戳为日期时间
|
||||
const formatDateTime = (timestamp: number | string) => {
|
||||
if (!timestamp) return '-'
|
||||
@@ -589,6 +590,26 @@ const fetchDateCounts = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 首屏优先主列表,错峰拉各日 tab 条数,减轻与 lists 并发争抢 */
|
||||
function scheduleDeferredDateCounts() {
|
||||
const run = () => fetchDateCounts()
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(run, { timeout: 2500 })
|
||||
} else {
|
||||
setTimeout(run, 120)
|
||||
}
|
||||
}
|
||||
|
||||
const SILENT_LIST_POLL_MS = 10_000
|
||||
let silentListPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 定时静默刷新:不打断表格 loading,标签页隐藏时不请求 */
|
||||
function silentRefreshListsAndDateCounts() {
|
||||
if (typeof document !== 'undefined' && document.hidden) return
|
||||
void getLists({ silent: true }).catch(() => {})
|
||||
void fetchDateCounts()
|
||||
}
|
||||
|
||||
// 日期快捷选项(computed 保证每日刷新)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const todayStr = computed(() => {
|
||||
@@ -709,8 +730,22 @@ const getRowClassName = ({ row }: { row: any }) => {
|
||||
const editRef = ref()
|
||||
const detailRef = ref()
|
||||
const appointmentRef = ref()
|
||||
/** 从路由带 id 进入时,诊单弹窗为异步组件,需在 ref 就绪后再 open */
|
||||
const pendingRouteDiagnosisId = ref<number | null>(null)
|
||||
const userStore = useUserStore()
|
||||
|
||||
watch(
|
||||
[editRef, pendingRouteDiagnosisId],
|
||||
() => {
|
||||
const id = pendingRouteDiagnosisId.value
|
||||
const opener = editRef.value as { open?: (mode: string, diagnosisId: number) => void } | undefined
|
||||
if (id == null || !opener?.open) return
|
||||
opener.open('edit', id)
|
||||
pendingRouteDiagnosisId.value = null
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
const watchCallVisible = ref(false)
|
||||
const watchCallDiagnosisId = ref(0)
|
||||
|
||||
@@ -1241,16 +1276,27 @@ const handleDeleteRecord = async (id: number) => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
onMounted(async () => {
|
||||
await getDictOptions()
|
||||
onMounted(() => {
|
||||
// 默认展示当天挂号,突出重点
|
||||
formData.appointment_date = todayStr.value
|
||||
// 字典与列表并行,避免 await 字典阻塞首屏列表(LCP)
|
||||
getDictOptions()
|
||||
getLists()
|
||||
fetchDateCounts()
|
||||
// 从 URL 带 id 进入时自动打开诊单编辑(如:面诊结束通知点击跳转)
|
||||
const id = route.query.id
|
||||
if (id && editRef.value) {
|
||||
nextTick(() => editRef.value.open('edit', Number(id)))
|
||||
scheduleDeferredDateCounts()
|
||||
|
||||
silentListPollTimer = setInterval(silentRefreshListsAndDateCounts, SILENT_LIST_POLL_MS)
|
||||
|
||||
const raw = route.query.id
|
||||
if (raw != null && raw !== '') {
|
||||
const num = Number(raw)
|
||||
if (!Number.isNaN(num)) pendingRouteDiagnosisId.value = num
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (silentListPollTimer != null) {
|
||||
clearInterval(silentListPollTimer)
|
||||
silentListPollTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -142,29 +142,7 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- 医助排行 -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="font-medium text-gray-800 mb-3">医助排行</div>
|
||||
<div v-if="assistantStats.ranking?.length" class="space-y-2">
|
||||
<div
|
||||
v-for="(a, idx) in assistantStats.ranking"
|
||||
:key="a.id"
|
||||
class="flex items-center justify-between py-2 px-3 rounded-lg"
|
||||
:class="idx < 3 ? 'bg-amber-50' : 'bg-gray-50'"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-6 h-6 rounded-full text-center text-xs font-medium leading-6"
|
||||
:class="idx === 0 ? 'bg-amber-400 text-white' : idx === 1 ? 'bg-gray-300 text-gray-700' : idx === 2 ? 'bg-amber-200 text-amber-800' : 'bg-gray-200 text-gray-600'"
|
||||
>
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span class="text-gray-800">{{ a.name }}</span>
|
||||
</span>
|
||||
<span class="font-semibold text-primary">{{ a.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="py-8 text-center text-tx-secondary">暂无排行数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表 -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="font-medium text-gray-800 mb-2">诊单数分布</div>
|
||||
|
||||
Reference in New Issue
Block a user