Files
xk-admin/apps/web-antd/src/components/doctor-transfer-float/DoctorTransferFloat.vue

884 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 医生端 PC 传方入口:顶栏按钮 + 子窗口列表 + 状态操作
* 权限:有 doctor_id 且绑定 store_id 时由外层 v-if 控制,本组件只负责展示与操作
*/
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue';
import {
FileTextOutlined,
ReloadOutlined,
ZoomInOutlined,
ZoomOutOutlined,
} from '@ant-design/icons-vue';
import { usePreferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores';
import {
Badge,
Button,
Dropdown,
Image,
Input,
Menu,
Modal,
Select,
Spin,
Tag,
Textarea,
Tooltip,
message,
} from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import {
DEFAULT_SUB_WINDOW_Z_INDEX,
getSubWindowPopupZIndex,
} from '#/components/sub-window/constants';
import SubWindow from '#/components/sub-window/SubWindow.vue';
import {
getTransferPrescriptionListByStoreApi,
updateTransferPrescriptionHandleStatusApi,
} from '#/views/business/salesperson-transfer-prescription/api';
import { invokeDoctorTransferImport } from '#/views/doctor/doctor-reception/composables/useDoctorReceptionTransferImport';
const REFRESH_STORAGE_KEY = 'pc_doctor_transfer_refresh_sec';
const DEFAULT_AVATAR = '/img/user-default-avatar.png';
const IMAGE_SCALE_MIN = 0.5;
const IMAGE_SCALE_MAX = 3;
const IMAGE_SCALE_STEP = 0.25;
const userStore = useUserStore();
const { isDark } = usePreferences();
/** 与子窗口 z-index 对齐的弹出层层级 */
const popupZIndex = computed(() => getSubWindowPopupZIndex(DEFAULT_SUB_WINDOW_Z_INDEX));
const windowOpen = ref(false);
const loading = ref(false);
const list = ref<any[]>([]);
const pollTimer = ref<ReturnType<typeof setInterval> | null>(null);
const inlineImageUrl = ref('');
const imageScale = ref(1);
const imagePanX = ref(0);
const imagePanY = ref(0);
const imageDragging = ref(false);
const imageDragStartX = ref(0);
const imageDragStartY = ref(0);
const imagePanOriginX = ref(0);
const imagePanOriginY = ref(0);
const salespersonKeyword = ref('');
const patientName = ref('');
const refreshIntervalSec = ref(30);
const exceptionModalOpen = ref(false);
const exceptionMessage = ref('');
const pendingStatusItem = ref<any>(null);
const storeId = computed(() => Number(userStore.userInfo?.store_id || 0));
const pendingCount = computed(
() => list.value.filter((item) => Number(item.status) === 0).length,
);
const refreshOptions = [
{ value: 0, label: '不刷新' },
{ value: 10, label: '10秒' },
{ value: 30, label: '30秒' },
{ value: 60, label: '1分钟' },
];
const statusOptions = [
{ value: 0, label: '未处理' },
{ value: 1, label: '处理中' },
{ value: 2, label: '已完成' },
{ value: 3, label: '异常' },
];
const imageTransformStyle = computed(() => ({
transform: `translate(${imagePanX.value}px, ${imagePanY.value}px) scale(${imageScale.value})`,
transformOrigin: 'center top',
}));
/** Dropdown 挂载到 body避免被 overflow 裁剪 */
function getPopupContainer() {
return document.body;
}
function statusColor(status: number) {
const map: Record<number, string> = {
0: 'warning',
1: 'processing',
2: 'success',
3: 'error',
};
return map[Number(status)] || 'default';
}
/** 重置内嵌看图缩放与平移 */
function resetImageView() {
imageScale.value = 1;
imagePanX.value = 0;
imagePanY.value = 0;
}
/** 从 localStorage 读取刷新间隔配置 */
function loadRefreshSetting() {
const cached = localStorage.getItem(REFRESH_STORAGE_KEY);
if (cached !== null) {
const val = Number(cached);
if ([0, 10, 30, 60].includes(val)) {
refreshIntervalSec.value = val;
}
}
}
function saveRefreshSetting() {
localStorage.setItem(REFRESH_STORAGE_KEY, String(refreshIntervalSec.value));
}
/** 加载当前门店传方列表 */
async function loadList(silent = false) {
if (!storeId.value) return;
if (!silent) loading.value = true;
try {
const params: Record<string, any> = {
store_id: storeId.value,
page: 1,
pageSize: 50,
};
if (salespersonKeyword.value.trim()) {
params.salesperson_keyword = salespersonKeyword.value.trim();
}
if (patientName.value.trim()) {
params.patient_name = patientName.value.trim();
}
const res = await getTransferPrescriptionListByStoreApi(params);
list.value = res?.items ?? [];
} catch (e) {
console.error(e);
if (!silent) list.value = [];
} finally {
loading.value = false;
}
}
function handleSearch() {
loadList();
}
function handleManualRefresh() {
loadList();
}
/** 按配置间隔启动轮询0 表示不自动刷新) */
function restartPoll() {
stopPoll();
const sec = refreshIntervalSec.value;
if (sec <= 0) return;
pollTimer.value = setInterval(() => loadList(true), sec * 1000);
}
function stopPoll() {
if (pollTimer.value) {
clearInterval(pollTimer.value);
pollTimer.value = null;
}
}
function openWindow() {
windowOpen.value = true;
inlineImageUrl.value = '';
resetImageView();
loadList();
}
function openImageInWindow(url: string) {
inlineImageUrl.value = url;
resetImageView();
}
function closeInlineImage() {
inlineImageUrl.value = '';
resetImageView();
}
function zoomIn() {
imageScale.value = Math.min(
IMAGE_SCALE_MAX,
Math.round((imageScale.value + IMAGE_SCALE_STEP) * 100) / 100,
);
}
function zoomOut() {
imageScale.value = Math.max(
IMAGE_SCALE_MIN,
Math.round((imageScale.value - IMAGE_SCALE_STEP) * 100) / 100,
);
}
function resetImageZoom() {
resetImageView();
}
/** 滚轮缩放图片 */
function onImageWheel(e: WheelEvent) {
e.preventDefault();
if (e.deltaY < 0) zoomIn();
else zoomOut();
}
/** 内嵌看图:按住拖拽平移 */
function onImageMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
imageDragging.value = true;
imageDragStartX.value = e.clientX;
imageDragStartY.value = e.clientY;
imagePanOriginX.value = imagePanX.value;
imagePanOriginY.value = imagePanY.value;
document.addEventListener('mousemove', onImageMouseMove);
document.addEventListener('mouseup', onImageMouseUp);
}
function onImageMouseMove(e: MouseEvent) {
if (!imageDragging.value) return;
imagePanX.value = imagePanOriginX.value + (e.clientX - imageDragStartX.value);
imagePanY.value = imagePanOriginY.value + (e.clientY - imageDragStartY.value);
}
function onImageMouseUp() {
imageDragging.value = false;
document.removeEventListener('mousemove', onImageMouseMove);
document.removeEventListener('mouseup', onImageMouseUp);
}
/** 选择处理状态 */
async function handleStatusSelect(item: any, status: number) {
if (status === 3) {
pendingStatusItem.value = item;
exceptionMessage.value = item.exception_message || '';
exceptionModalOpen.value = true;
return;
}
await submitStatus(item, status, '');
}
async function submitStatus(item: any, status: number, exceptionMsg: string) {
if (!storeId.value) return;
try {
await updateTransferPrescriptionHandleStatusApi({
id: item.id,
store_id: storeId.value,
status,
exception_message: status === 3 ? exceptionMsg : '',
});
message.success('状态已更新');
exceptionModalOpen.value = false;
pendingStatusItem.value = null;
loadList(true);
} catch (e: any) {
message.error(e?.message || '更新失败');
}
}
async function confirmException() {
if (!exceptionMessage.value.trim()) {
message.warning('请填写异常说明');
return;
}
if (pendingStatusItem.value) {
await submitStatus(pendingStatusItem.value, 3, exceptionMessage.value.trim());
}
}
/** 是否可导入:手动输入且有药品 */
function canImportItem(item: any) {
if (Number(item.transfer_mode ?? 1) === 2) return false;
return Boolean(item.drug_list?.length || item.drug_text_lines?.length);
}
/** 当前接诊页选中的挂号 ID */
function getCurrentRegisterId() {
return Number.parseInt(localStorage.getItem('doctorReception-id') || '0', 10);
}
/** 导入传方药品到当前接诊患者 */
function handleImportToCurrentPatient(item: any) {
const registerId = getCurrentRegisterId();
if (!registerId) {
message.warning('请先在接诊页选择患者');
return;
}
if (!canImportItem(item)) {
message.warning('仅支持手动输入药品的传方');
return;
}
const ok = invokeDoctorTransferImport({ transferId: item.id, registerId });
if (!ok) {
message.warning('请先打开接诊页面');
}
}
watch(refreshIntervalSec, () => {
saveRefreshSetting();
restartPoll();
});
watch(windowOpen, (v) => {
if (!v) {
inlineImageUrl.value = '';
resetImageView();
}
});
watch(storeId, () => {
if (windowOpen.value) loadList(true);
});
onBeforeUnmount(() => {
stopPoll();
document.removeEventListener('mousemove', onImageMouseMove);
document.removeEventListener('mouseup', onImageMouseUp);
});
onMounted(() => {
loadRefreshSetting();
loadList(true);
restartPoll();
});
defineExpose({ loadList });
</script>
<template>
<div class="doctor-transfer-header-entry">
<Tooltip title="门店传方">
<Badge :count="pendingCount" :overflow-count="99" size="small">
<Button
type="text"
class="transfer-header-btn"
@click="openWindow"
>
<FileTextOutlined />
<span class="transfer-header-btn__text">传方</span>
</Button>
</Badge>
</Tooltip>
</div>
<SubWindow
v-model:open="windowOpen"
title="门店传方"
storage-key="pc_doctor_transfer_window"
:width="440"
:height="560"
:min-width="360"
:min-height="320"
:z-index="DEFAULT_SUB_WINDOW_Z_INDEX"
>
<div class="panel" :class="{ 'is-dark': isDark }">
<div v-if="inlineImageUrl" class="inline-image-layer">
<div class="inline-image-toolbar">
<Button type="link" size="small" @click="closeInlineImage">返回列表</Button>
<Button type="link" size="small" @click="zoomIn">
<ZoomInOutlined /> 放大
</Button>
<Button type="link" size="small" @click="zoomOut">
<ZoomOutOutlined /> 缩小
</Button>
<Button type="link" size="small" @click="resetImageZoom">还原</Button>
<span class="zoom-percent">{{ Math.round(imageScale * 100) }}%</span>
</div>
<div
class="inline-image-body"
:class="{ dragging: imageDragging }"
@wheel="onImageWheel"
@mousedown="onImageMouseDown"
>
<img
:src="inlineImageUrl"
class="inline-image"
:style="imageTransformStyle"
alt="处方图"
draggable="false"
/>
</div>
</div>
<div class="toolbar">
<div class="toolbar-row">
<Input.Search
v-model:value="salespersonKeyword"
allow-clear
placeholder="推广员姓名/手机"
size="small"
class="toolbar-input"
@search="handleSearch"
/>
<Input.Search
v-model:value="patientName"
allow-clear
placeholder="收货人姓名"
size="small"
class="toolbar-input"
@search="handleSearch"
/>
</div>
<div class="toolbar-row toolbar-row-bottom">
<span class="toolbar-label">刷新</span>
<Select
v-model:value="refreshIntervalSec"
size="small"
class="toolbar-select"
:options="refreshOptions"
:get-popup-container="getPopupContainer"
:dropdown-style="{ zIndex: popupZIndex }"
/>
<Button size="small" :loading="loading" @click="handleManualRefresh">
<ReloadOutlined />
</Button>
</div>
</div>
<div class="panel-body">
<div class="panel-scroll">
<Spin :spinning="loading">
<div v-if="!loading && !list.length" class="empty-tip">暂无传方记录</div>
<div v-for="item in list" :key="item.id" class="transfer-card">
<div class="card-head">
<span class="store-name">{{ item.store_name || '—' }}</span>
<Tag :color="statusColor(item.status)">
{{ item.status_text || '未处理' }}
</Tag>
</div>
<div class="salesperson-row">
<Image
:src="item.salesperson_avatar || DEFAULT_AVATAR"
:width="32"
:height="32"
class="salesperson-avatar"
:preview="false"
/>
<div class="salesperson-info">
<div class="salesperson-name">{{ item.salesperson_name || '—' }}</div>
<div v-if="item.salesperson_phone" class="salesperson-phone">
{{ item.salesperson_phone }}
</div>
</div>
</div>
<div class="card-row">
<span class="label">收货人</span>
<span class="value">
{{ item.patient_name }}
<SensitiveText :record="item" field="patient_mobile" :show-eye="false" />
</span>
</div>
<div v-if="item.patient_address" class="card-row">
<span class="label">地址</span>
<span class="value ellipsis">{{ item.patient_address }}</span>
</div>
<div v-if="item.remark" class="card-row">
<span class="label">备注</span>
<span class="value">{{ item.remark }}</span>
</div>
<div
v-if="item.drug_text_lines?.length"
class="drug-block drug-block--importable"
@click="canImportItem(item) && handleImportToCurrentPatient(item)"
>
<span class="label">药方</span>
<div class="drug-lines">
<div
v-for="(line, idx) in item.drug_text_lines"
:key="'d-' + idx"
class="drug-line"
>
{{ line }}
</div>
</div>
<span v-if="canImportItem(item)" class="import-hint">点击导入到当前患者</span>
</div>
<div v-if="item.prescription_images?.length" class="img-block">
<span class="label">处方图</span>
<div class="img-row">
<Image
v-for="(img, idx) in item.prescription_images"
:key="'img-' + idx"
:src="img"
:width="64"
:height="64"
class="thumb"
:preview="{ zIndex: popupZIndex }"
/>
</div>
<Button
type="link"
size="small"
class="link-btn"
@click="openImageInWindow(item.prescription_images[0])"
>
在窗口查看
</Button>
</div>
<div
v-if="item.status === 3 && item.exception_message"
class="exception-box"
>
异常{{ item.exception_message }}
</div>
<div class="card-foot">
<span class="time">{{ item.transfer_time_text }}</span>
<div class="card-foot-actions">
<Button
v-if="canImportItem(item)"
size="small"
type="primary"
@click="handleImportToCurrentPatient(item)"
>
导入到当前患者
</Button>
<Dropdown
:trigger="['click']"
:get-popup-container="getPopupContainer"
:overlay-style="{ zIndex: popupZIndex }"
>
<Button size="small" type="primary" ghost>操作</Button>
<template #overlay>
<Menu @click="({ key }) => handleStatusSelect(item, Number(key))">
<Menu.Item v-for="opt in statusOptions" :key="String(opt.value)">
{{ opt.label }}
</Menu.Item>
</Menu>
</template>
</Dropdown>
</div>
</div>
</div>
</Spin>
</div>
</div>
</div>
</SubWindow>
<Modal
v-model:open="exceptionModalOpen"
title="填写异常说明"
ok-text="确认"
cancel-text="取消"
:z-index="popupZIndex"
@ok="confirmException"
>
<Textarea
v-model:value="exceptionMessage"
:rows="4"
placeholder="请描述异常原因"
/>
</Modal>
</template>
<style scoped>
.panel {
flex: 1;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
flex-shrink: 0;
padding: 12px;
border-bottom: 1px solid #eee;
}
.panel.is-dark .toolbar {
border-bottom-color: #424242;
}
.toolbar-row {
display: flex;
gap: 8px;
}
.toolbar-row-bottom {
margin-top: 8px;
align-items: center;
}
.toolbar-input {
flex: 1;
}
.toolbar-select {
flex: 1;
}
.toolbar-label {
flex-shrink: 0;
font-size: 12px;
color: #86909c;
}
.panel.is-dark .toolbar-label {
color: rgba(255, 255, 255, 0.45);
}
/* 内部滚动panel-body 限定高度panel-scroll 为唯一滚动容器 */
.panel-body {
flex: 1 1 0;
min-height: 0;
overflow: hidden;
}
.panel-scroll {
height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px;
}
.empty-tip {
text-align: center;
color: #999;
padding: 40px 0;
}
.panel.is-dark .empty-tip {
color: rgba(255, 255, 255, 0.45);
}
.transfer-card {
background: #f9fafb;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
border: 1px solid #eee;
}
.panel.is-dark .transfer-card {
background: #262626;
border-color: #424242;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.store-name {
font-weight: 600;
font-size: 14px;
color: #1d2129;
}
.panel.is-dark .store-name {
color: rgba(255, 255, 255, 0.85);
}
.salesperson-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.salesperson-avatar {
flex-shrink: 0;
border-radius: 50%;
object-fit: cover;
}
.salesperson-info {
min-width: 0;
flex: 1;
}
.salesperson-name {
font-size: 14px;
font-weight: 500;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.panel.is-dark .salesperson-name {
color: rgba(255, 255, 255, 0.85);
}
.salesperson-phone {
font-size: 12px;
color: #86909c;
}
.panel.is-dark .salesperson-phone {
color: rgba(255, 255, 255, 0.45);
}
.card-row {
display: flex;
font-size: 13px;
margin-bottom: 6px;
line-height: 1.5;
}
.label {
color: #86909c;
width: 56px;
flex-shrink: 0;
}
.panel.is-dark .label {
color: rgba(255, 255, 255, 0.45);
}
.value {
color: #4e5969;
flex: 1;
}
.panel.is-dark .value {
color: rgba(255, 255, 255, 0.65);
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drug-block {
margin: 6px 0;
display: flex;
font-size: 13px;
}
.drug-block--importable {
flex-wrap: wrap;
cursor: pointer;
padding: 6px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.drug-block--importable:hover {
background: rgba(0, 135, 113, 0.08);
}
.panel.is-dark .drug-block--importable:hover {
background: rgba(52, 211, 153, 0.12);
}
.import-hint {
flex-basis: 100%;
font-size: 12px;
color: #008771;
margin-top: 4px;
margin-left: 56px;
}
.panel.is-dark .import-hint {
color: #34d399;
}
.drug-lines {
flex: 1;
}
.drug-line {
color: #008771;
margin-bottom: 2px;
}
.panel.is-dark .drug-line {
color: #34d399;
}
.img-block {
margin-top: 6px;
}
.img-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 6px 0 6px 56px;
}
.thumb {
object-fit: cover;
border-radius: 4px;
}
.link-btn {
padding-left: 56px;
}
.exception-box {
background: #fef2f2;
color: #991b1b;
font-size: 12px;
padding: 8px;
border-radius: 4px;
margin-top: 6px;
}
.panel.is-dark .exception-box {
background: rgba(127, 29, 29, 0.35);
color: #fca5a5;
}
.card-foot {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed #e5e7eb;
}
.card-foot-actions {
display: flex;
align-items: center;
gap: 8px;
}
.panel.is-dark .card-foot {
border-top-color: #424242;
}
.time {
font-size: 12px;
color: #999;
}
.panel.is-dark .time {
color: rgba(255, 255, 255, 0.45);
}
.inline-image-layer {
position: absolute;
inset: 0;
background: #fff;
z-index: 10;
display: flex;
flex-direction: column;
}
.panel.is-dark .inline-image-layer {
background: #1f1f1f;
}
.inline-image-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid #eee;
flex-shrink: 0;
}
.panel.is-dark .inline-image-toolbar {
border-bottom-color: #424242;
}
.zoom-percent {
font-size: 12px;
color: #86909c;
}
.panel.is-dark .zoom-percent {
color: rgba(255, 255, 255, 0.45);
}
.inline-image-body {
flex: 1;
overflow: auto;
overscroll-behavior: contain;
padding: 12px;
cursor: grab;
}
.inline-image-body.dragging {
cursor: grabbing;
}
.inline-image {
width: 100%;
height: auto;
display: block;
margin: 0 auto;
user-select: none;
}
.doctor-transfer-header-entry {
display: flex;
align-items: center;
margin-right: 4px;
}
.transfer-header-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 32px;
padding: 0 8px;
color: hsl(var(--foreground));
}
.transfer-header-btn__text {
font-size: 13px;
}
@media (max-width: 640px) {
.transfer-header-btn__text {
display: none;
}
}
</style>