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

2. 退款的时候可以退回分成
This commit is contained in:
李琦
2026-07-06 17:03:35 +08:00
parent 7907c7cbf2
commit c02f685d83
47 changed files with 3706 additions and 464 deletions

View File

@@ -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>

View 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>

View 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>

View 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;
}