1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2. 退款的时候可以退回分成
This commit is contained in:
@@ -0,0 +1,847 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 医生端 PC 传方悬浮窗:悬浮按钮 + 子窗口列表 + 状态操作
|
||||
*/
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Image,
|
||||
Input,
|
||||
Menu,
|
||||
Modal,
|
||||
Select,
|
||||
Spin,
|
||||
Tag,
|
||||
Textarea,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import FloatFab from '#/components/float-fab/FloatFab.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>
|
||||
<FloatFab
|
||||
storage-key="pc_doctor_transfer_fab"
|
||||
label="传方"
|
||||
:badge="pendingCount"
|
||||
@click="openWindow"
|
||||
/>
|
||||
<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 }} {{ item.patient_mobile }}</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;
|
||||
}
|
||||
</style>
|
||||
194
apps/web-antd/src/components/float-fab/FloatFab.vue
Normal file
194
apps/web-antd/src/components/float-fab/FloatFab.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PC 可拖拽吸附悬浮按钮
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { FileTextOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** localStorage 位置缓存 key */
|
||||
storageKey?: string;
|
||||
/** 显示文案 */
|
||||
label?: string;
|
||||
/** 角标数量 */
|
||||
badge?: number;
|
||||
/** 吸附阈值 px */
|
||||
snapThreshold?: number;
|
||||
zIndex?: number;
|
||||
}>(),
|
||||
{
|
||||
storageKey: 'pc_float_fab_pos',
|
||||
label: '传方',
|
||||
badge: 0,
|
||||
snapThreshold: 40,
|
||||
zIndex: 9999,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const posX = ref(0);
|
||||
const posY = ref(0);
|
||||
const fabSize = 56;
|
||||
const dragging = ref(false);
|
||||
const moved = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const originX = ref(0);
|
||||
const originY = ref(0);
|
||||
|
||||
const fabStyle = computed(() => ({
|
||||
left: `${posX.value}px`,
|
||||
top: `${posY.value}px`,
|
||||
zIndex: props.zIndex,
|
||||
}));
|
||||
|
||||
const badgeText = computed(() => {
|
||||
if (props.badge <= 0) return '';
|
||||
return props.badge > 99 ? '99+' : String(props.badge);
|
||||
});
|
||||
|
||||
function initPosition() {
|
||||
const cached = localStorage.getItem(props.storageKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const data = JSON.parse(cached);
|
||||
posX.value = data.posX ?? window.innerWidth - fabSize - 24;
|
||||
posY.value = data.posY ?? window.innerHeight * 0.6;
|
||||
} catch {
|
||||
posX.value = window.innerWidth - fabSize - 24;
|
||||
posY.value = window.innerHeight * 0.6;
|
||||
}
|
||||
} else {
|
||||
posX.value = window.innerWidth - fabSize - 24;
|
||||
posY.value = window.innerHeight * 0.6;
|
||||
}
|
||||
clampPos();
|
||||
}
|
||||
|
||||
function savePosition() {
|
||||
localStorage.setItem(
|
||||
props.storageKey,
|
||||
JSON.stringify({ posX: posX.value, posY: posY.value }),
|
||||
);
|
||||
}
|
||||
|
||||
function clampPos() {
|
||||
posX.value = Math.max(8, Math.min(posX.value, window.innerWidth - fabSize - 8));
|
||||
posY.value = Math.max(60, Math.min(posY.value, window.innerHeight - fabSize - 24));
|
||||
}
|
||||
|
||||
/** 松手吸附到最近左/右边缘 */
|
||||
function snapToEdge() {
|
||||
const centerX = posX.value + fabSize / 2;
|
||||
if (centerX < window.innerWidth / 2) {
|
||||
posX.value = props.snapThreshold;
|
||||
} else {
|
||||
posX.value = window.innerWidth - fabSize - props.snapThreshold;
|
||||
}
|
||||
clampPos();
|
||||
savePosition();
|
||||
}
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
dragging.value = true;
|
||||
moved.value = false;
|
||||
startX.value = e.clientX;
|
||||
startY.value = e.clientY;
|
||||
originX.value = posX.value;
|
||||
originY.value = posY.value;
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!dragging.value) return;
|
||||
const dx = e.clientX - startX.value;
|
||||
const dy = e.clientY - startY.value;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved.value = true;
|
||||
posX.value = originX.value + dx;
|
||||
posY.value = originY.value + dy;
|
||||
clampPos();
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
if (dragging.value && moved.value) {
|
||||
snapToEdge();
|
||||
} else if (!moved.value) {
|
||||
emit('click');
|
||||
}
|
||||
dragging.value = false;
|
||||
}
|
||||
|
||||
onMounted(initPosition);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="float-fab" :style="fabStyle" @mousedown="onMouseDown">
|
||||
<div class="fab-inner">
|
||||
<FileTextOutlined class="fab-icon" />
|
||||
<span v-if="label" class="fab-label">{{ label }}</span>
|
||||
</div>
|
||||
<span v-if="badgeText" class="fab-badge">{{ badgeText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.float-fab {
|
||||
position: fixed;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.float-fab:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.fab-inner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6acdbb, #00a88a);
|
||||
box-shadow: 0 4px 16px rgba(0, 168, 138, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.fab-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
.fab-label {
|
||||
font-size: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fab-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 12px;
|
||||
height: 12px;
|
||||
padding: 0 4px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
435
apps/web-antd/src/components/sub-window/SubWindow.vue
Normal file
435
apps/web-antd/src/components/sub-window/SubWindow.vue
Normal file
@@ -0,0 +1,435 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通用 PC 悬浮子窗口:可拖拽、四角缩放、可最小化,始终保持在可视区域内
|
||||
*/
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { CloseOutlined, MinusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { SUB_WINDOW_POPUP_Z_INDEX_KEY } from './constants';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 是否显示窗口 */
|
||||
open: boolean;
|
||||
/** 标题 */
|
||||
title?: string;
|
||||
/** localStorage 缓存 key */
|
||||
storageKey?: string;
|
||||
/** 初始宽度 */
|
||||
width?: number;
|
||||
/** 初始高度 */
|
||||
height?: number;
|
||||
/** 最小宽度 */
|
||||
minWidth?: number;
|
||||
/** 最小高度 */
|
||||
minHeight?: number;
|
||||
/** 初始 X,-1 表示靠右 */
|
||||
defaultX?: number;
|
||||
/** 初始 Y */
|
||||
defaultY?: number;
|
||||
/** 层级 */
|
||||
zIndex?: number;
|
||||
}>(),
|
||||
{
|
||||
title: '窗口',
|
||||
storageKey: 'pc_sub_window_default',
|
||||
width: 420,
|
||||
height: 520,
|
||||
minWidth: 320,
|
||||
minHeight: 240,
|
||||
defaultX: -1,
|
||||
defaultY: 80,
|
||||
zIndex: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [boolean];
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
|
||||
const posX = ref(100);
|
||||
const posY = ref(80);
|
||||
const winWidth = ref(props.width);
|
||||
const winHeight = ref(props.height);
|
||||
const minimized = ref(false);
|
||||
|
||||
type DragMode =
|
||||
| ''
|
||||
| 'move'
|
||||
| 'resize-se'
|
||||
| 'resize-sw'
|
||||
| 'resize-ne'
|
||||
| 'resize-nw';
|
||||
const dragMode = ref<DragMode>('');
|
||||
const dragStartX = ref(0);
|
||||
const dragStartY = ref(0);
|
||||
const originX = ref(0);
|
||||
const originY = ref(0);
|
||||
const originW = ref(0);
|
||||
const originH = ref(0);
|
||||
|
||||
/** 弹出层 z-index,需高于子窗口本体 */
|
||||
const popupZIndex = computed(() => props.zIndex + 100);
|
||||
provide(SUB_WINDOW_POPUP_Z_INDEX_KEY, popupZIndex);
|
||||
|
||||
const windowStyle = computed(() => ({
|
||||
left: `${posX.value}px`,
|
||||
top: `${posY.value}px`,
|
||||
width: `${winWidth.value}px`,
|
||||
height: minimized.value ? 'auto' : `${winHeight.value}px`,
|
||||
zIndex: props.zIndex,
|
||||
}));
|
||||
|
||||
/** 从缓存或默认值初始化窗口位置与尺寸 */
|
||||
function initLayout() {
|
||||
const cached = localStorage.getItem(props.storageKey);
|
||||
if (cached) {
|
||||
try {
|
||||
const data = JSON.parse(cached);
|
||||
posX.value = data.posX ?? posX.value;
|
||||
posY.value = data.posY ?? posY.value;
|
||||
winWidth.value = data.width ?? winWidth.value;
|
||||
winHeight.value = data.height ?? winHeight.value;
|
||||
minimized.value = !!data.minimized;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
winWidth.value = props.width;
|
||||
winHeight.value = props.height;
|
||||
posX.value =
|
||||
props.defaultX >= 0
|
||||
? props.defaultX
|
||||
: Math.max(16, window.innerWidth - winWidth.value - 24);
|
||||
posY.value = props.defaultY;
|
||||
}
|
||||
clampWindow();
|
||||
}
|
||||
|
||||
/** 约束窗口在可视区域内 */
|
||||
function clampWindow() {
|
||||
const h = minimized.value ? 44 : winHeight.value;
|
||||
winWidth.value = Math.max(
|
||||
props.minWidth,
|
||||
Math.min(winWidth.value, window.innerWidth - 16),
|
||||
);
|
||||
winHeight.value = Math.max(
|
||||
props.minHeight,
|
||||
Math.min(winHeight.value, window.innerHeight - 16),
|
||||
);
|
||||
posX.value = Math.max(0, Math.min(posX.value, window.innerWidth - winWidth.value));
|
||||
posY.value = Math.max(0, Math.min(posY.value, window.innerHeight - h));
|
||||
}
|
||||
|
||||
function saveLayout() {
|
||||
localStorage.setItem(
|
||||
props.storageKey,
|
||||
JSON.stringify({
|
||||
posX: posX.value,
|
||||
posY: posY.value,
|
||||
width: winWidth.value,
|
||||
height: winHeight.value,
|
||||
minimized: minimized.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function bindDragListeners() {
|
||||
document.addEventListener('mousemove', onDocumentMouseMove);
|
||||
document.addEventListener('mouseup', onDocumentMouseUp);
|
||||
}
|
||||
|
||||
function onTitleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0) return;
|
||||
dragMode.value = 'move';
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
originX.value = posX.value;
|
||||
originY.value = posY.value;
|
||||
bindDragListeners();
|
||||
}
|
||||
|
||||
/** 四角缩放开始:记录初始位置与尺寸 */
|
||||
function onResizeMouseDown(e: MouseEvent, mode: DragMode) {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragMode.value = mode;
|
||||
dragStartX.value = e.clientX;
|
||||
dragStartY.value = e.clientY;
|
||||
originX.value = posX.value;
|
||||
originY.value = posY.value;
|
||||
originW.value = winWidth.value;
|
||||
originH.value = winHeight.value;
|
||||
bindDragListeners();
|
||||
}
|
||||
|
||||
/** 左/上方向缩放时,宽高触达最小值需同步修正锚点位置 */
|
||||
function applyHorizontalResizeFromLeft(dx: number) {
|
||||
let newW = originW.value - dx;
|
||||
let newX = originX.value + dx;
|
||||
if (newW < props.minWidth) {
|
||||
newX = originX.value + originW.value - props.minWidth;
|
||||
newW = props.minWidth;
|
||||
}
|
||||
winWidth.value = newW;
|
||||
posX.value = newX;
|
||||
}
|
||||
|
||||
function applyVerticalResizeFromTop(dy: number) {
|
||||
let newH = originH.value - dy;
|
||||
let newY = originY.value + dy;
|
||||
if (newH < props.minHeight) {
|
||||
newY = originY.value + originH.value - props.minHeight;
|
||||
newH = props.minHeight;
|
||||
}
|
||||
winHeight.value = newH;
|
||||
posY.value = newY;
|
||||
}
|
||||
|
||||
function onDocumentMouseMove(e: MouseEvent) {
|
||||
const dx = e.clientX - dragStartX.value;
|
||||
const dy = e.clientY - dragStartY.value;
|
||||
|
||||
if (dragMode.value === 'move') {
|
||||
posX.value = originX.value + dx;
|
||||
posY.value = originY.value + dy;
|
||||
clampWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragMode.value === 'resize-se') {
|
||||
winWidth.value = originW.value + dx;
|
||||
winHeight.value = originH.value + dy;
|
||||
} else if (dragMode.value === 'resize-sw') {
|
||||
applyHorizontalResizeFromLeft(dx);
|
||||
winHeight.value = originH.value + dy;
|
||||
} else if (dragMode.value === 'resize-ne') {
|
||||
winWidth.value = originW.value + dx;
|
||||
applyVerticalResizeFromTop(dy);
|
||||
} else if (dragMode.value === 'resize-nw') {
|
||||
applyHorizontalResizeFromLeft(dx);
|
||||
applyVerticalResizeFromTop(dy);
|
||||
}
|
||||
clampWindow();
|
||||
}
|
||||
|
||||
function onDocumentMouseUp() {
|
||||
dragMode.value = '';
|
||||
document.removeEventListener('mousemove', onDocumentMouseMove);
|
||||
document.removeEventListener('mouseup', onDocumentMouseUp);
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
minimized.value = !minimized.value;
|
||||
clampWindow();
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false);
|
||||
emit('close');
|
||||
}
|
||||
|
||||
/** 阻止滚轮穿透到主页面:内部可滚动区域正常滚,其余区域拦截 */
|
||||
function onWindowWheel(e: WheelEvent) {
|
||||
e.stopPropagation();
|
||||
let node = e.target as HTMLElement | null;
|
||||
const root = e.currentTarget as HTMLElement;
|
||||
while (node && node !== root) {
|
||||
const style = getComputedStyle(node);
|
||||
const canScrollY =
|
||||
node.scrollHeight > node.clientHeight &&
|
||||
(style.overflowY === 'auto' || style.overflowY === 'scroll');
|
||||
if (canScrollY) {
|
||||
const goingUp = e.deltaY < 0;
|
||||
const goingDown = e.deltaY > 0;
|
||||
const atTop = node.scrollTop <= 0;
|
||||
const atBottom =
|
||||
node.scrollTop + node.clientHeight >= node.scrollHeight - 1;
|
||||
if ((goingUp && !atTop) || (goingDown && !atBottom)) return;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) initLayout();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open) initLayout();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousemove', onDocumentMouseMove);
|
||||
document.removeEventListener('mouseup', onDocumentMouseUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="sub-window-root">
|
||||
<div
|
||||
class="sub-window"
|
||||
:class="{ minimized, 'is-dark': isDark }"
|
||||
:style="windowStyle"
|
||||
@wheel="onWindowWheel"
|
||||
>
|
||||
<div class="sub-window-header" @mousedown="onTitleMouseDown">
|
||||
<span class="sub-window-title">{{ title }}</span>
|
||||
<div class="sub-window-actions" @mousedown.stop>
|
||||
<button type="button" class="action-btn" @click="toggleMinimize">
|
||||
<MinusOutlined />
|
||||
</button>
|
||||
<button type="button" class="action-btn" @click="handleClose">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!minimized" class="sub-window-body">
|
||||
<slot />
|
||||
</div>
|
||||
<template v-if="!minimized">
|
||||
<div
|
||||
class="resize-handle resize-handle-nw"
|
||||
@mousedown="onResizeMouseDown($event, 'resize-nw')"
|
||||
/>
|
||||
<div
|
||||
class="resize-handle resize-handle-ne"
|
||||
@mousedown="onResizeMouseDown($event, 'resize-ne')"
|
||||
/>
|
||||
<div
|
||||
class="resize-handle resize-handle-sw"
|
||||
@mousedown="onResizeMouseDown($event, 'resize-sw')"
|
||||
/>
|
||||
<div
|
||||
class="resize-handle resize-handle-se"
|
||||
@mousedown="onResizeMouseDown($event, 'resize-se')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sub-window-root {
|
||||
pointer-events: none;
|
||||
}
|
||||
.sub-window {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.sub-window.is-dark {
|
||||
background: #1f1f1f;
|
||||
border-color: #424242;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.sub-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sub-window.is-dark .sub-window-header {
|
||||
background: linear-gradient(135deg, #1a2e22, #1f1f1f);
|
||||
border-bottom-color: #424242;
|
||||
}
|
||||
.sub-window-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sub-window.is-dark .sub-window-title {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.sub-window-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
.action-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: #666;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.sub-window.is-dark .action-btn {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.sub-window.is-dark .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.sub-window-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
/* 四角透明缩放热区,无视觉提示 */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.resize-handle-nw {
|
||||
left: 0;
|
||||
top: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
.resize-handle-ne {
|
||||
right: 0;
|
||||
top: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.resize-handle-sw {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
.resize-handle-se {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
</style>
|
||||
10
apps/web-antd/src/components/sub-window/constants.ts
Normal file
10
apps/web-antd/src/components/sub-window/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** 子窗口默认 z-index */
|
||||
export const DEFAULT_SUB_WINDOW_Z_INDEX = 10000;
|
||||
|
||||
/** 子窗口内 Ant Design 弹出层 inject key */
|
||||
export const SUB_WINDOW_POPUP_Z_INDEX_KEY = Symbol('subWindowPopupZIndex');
|
||||
|
||||
/** 根据子窗口 z-index 计算弹出层层级 */
|
||||
export function getSubWindowPopupZIndex(baseZIndex = DEFAULT_SUB_WINDOW_Z_INDEX) {
|
||||
return baseZIndex + 100;
|
||||
}
|
||||
Reference in New Issue
Block a user