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:
10
.cursor/rules/Code-Standards.mdc
Normal file
10
.cursor/rules/Code-Standards.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
1. 写代码的时候要补充详细的中文注释(每个方法是干嘛的,为什么要这样写),如果是工作区,则所有项目都适用该规则
|
||||
2. 注意不要生成太多的空行,上一部分代码和下一部分代码中间的空行不要大于2行
|
||||
3. 有封装好的方法、组件需要复用,不要重复造轮子
|
||||
4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show)
|
||||
5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,5 +49,5 @@ vite.config.ts.*
|
||||
*.sln
|
||||
*.sw?
|
||||
.history
|
||||
/.cursor/
|
||||
/.mimocode/
|
||||
/.cursor/skills/
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface FileGalleryItem {
|
||||
type?: number;
|
||||
type_text?: string;
|
||||
type_icon?: string;
|
||||
group_ids?: number[];
|
||||
created_at?: string;
|
||||
original_name?: string;
|
||||
object_key?: string;
|
||||
@@ -36,6 +37,7 @@ export async function getFileGalleryList(params?: {
|
||||
page_size?: number;
|
||||
pid?: number;
|
||||
type?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
}) {
|
||||
return requestClient.get<{
|
||||
@@ -76,3 +78,13 @@ export async function getFileTypes() {
|
||||
export async function syncFileGalleryFromOss() {
|
||||
return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`);
|
||||
}
|
||||
|
||||
/** 文件加入自定义分组 */
|
||||
export async function addFileToGroup(file_id: number, group_id: number) {
|
||||
return requestClient.post<any>(`${prefix}add-to-group`, { file_id, group_id });
|
||||
}
|
||||
|
||||
/** 文件移出自定义分组 */
|
||||
export async function removeFileFromGroup(file_id: number, group_id: number) {
|
||||
return requestClient.post<any>(`${prefix}remove-from-group`, { file_id, group_id });
|
||||
}
|
||||
|
||||
51
apps/web-antd/src/api/core/file-group.ts
Normal file
51
apps/web-antd/src/api/core/file-group.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'file-group/';
|
||||
|
||||
export interface FileGroupRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
}
|
||||
|
||||
export interface FileGroupOptionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
/** 分组选项缓存结构 */
|
||||
export interface FileGroupCachePayload {
|
||||
hash: string;
|
||||
items: FileGroupOptionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端分组列表
|
||||
*/
|
||||
export async function getFileGroupList() {
|
||||
return requestClient.get<FileGroupRecord[]>(`${prefix}list`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 hash 的分组选项:无变化时 result 为 0
|
||||
*/
|
||||
export async function getFileGroupOptions(hash?: string) {
|
||||
return requestClient.get<FileGroupOptionItem[] | 0>(`${prefix}options`, {
|
||||
params: hash ? { hash } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createFileGroup(data: Partial<FileGroupRecord>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateFileGroup(data: Partial<FileGroupRecord> & { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteFileGroup(id: number) {
|
||||
return requestClient.post<any>(`${prefix}delete`, { id });
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { App, ConfigProvider, theme } from 'ant-design-vue';
|
||||
|
||||
import { antdLocale } from '#/locales';
|
||||
import { GlobalContextMenu } from '#/components/context-menu';
|
||||
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
|
||||
import { useWebSocket } from '#/views/business/chat/composables/useWebSocket';
|
||||
import { useChatStore } from '#/views/business/chat/stores/chat';
|
||||
import { useUserStore } from '#/views/business/chat/stores/user';
|
||||
@@ -75,6 +76,13 @@ watch(userInfoLoaded, (loaded) => {
|
||||
initWebsocket();
|
||||
}
|
||||
});
|
||||
|
||||
/** 诊所医生账号显示传方悬浮窗(有 doctor_id 且绑定门店) */
|
||||
const showDoctorTransferFloat = computed(() => {
|
||||
const info = vbenUserStore.userInfo as Record<string, any> | null;
|
||||
if (!info) return false;
|
||||
return !!(info.doctor_id && info.store_id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -82,6 +90,7 @@ watch(userInfoLoaded, (loaded) => {
|
||||
<App>
|
||||
<RouterView />
|
||||
<GlobalContextMenu />
|
||||
<DoctorTransferFloat v-if="showDoctorTransferFloat" />
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,9 +2,13 @@ import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import type { FileTypeItem } from '#/api/core/file-gallery';
|
||||
import { getFileTypes } from '#/api/core/file-gallery';
|
||||
import type { FileGroupOptionItem } from '#/api/core/file-group';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
|
||||
export const ALL_TYPE_VALUE = -1;
|
||||
|
||||
export const ALL_GROUP_VALUE = 0;
|
||||
|
||||
import { ALL_TYPE_ICON } from '#/composables/use-file-type-icon';
|
||||
|
||||
export interface FileGalleryTab {
|
||||
@@ -17,9 +21,12 @@ export interface FileGalleryTab {
|
||||
export function useFileGalleryFilter(storageKey: string) {
|
||||
const fileTypes = ref<FileTypeItem[]>([]);
|
||||
const activeType = ref<number>(ALL_TYPE_VALUE);
|
||||
const activeGroupId = ref<number>(ALL_GROUP_VALUE);
|
||||
const keyword = ref('');
|
||||
const typesLoading = ref(false);
|
||||
|
||||
const { groupOptions, loadGroupOptions } = useFileGroupCache();
|
||||
|
||||
const tabs = computed<FileGalleryTab[]>(() => [
|
||||
{ key: 'all', value: ALL_TYPE_VALUE, label: '全部', icon: ALL_TYPE_ICON },
|
||||
...fileTypes.value.map((item) => ({
|
||||
@@ -30,6 +37,16 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
})),
|
||||
]);
|
||||
|
||||
/** 分组 Tab(独立于文件类型) */
|
||||
const groupTabs = computed<FileGalleryTab[]>(() => [
|
||||
{ key: 'group-all', value: ALL_GROUP_VALUE, label: '全部分组' },
|
||||
...groupOptions.value.map((item: FileGroupOptionItem) => ({
|
||||
key: `group-${item.id}`,
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
})),
|
||||
]);
|
||||
|
||||
function restoreActiveType() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved === null) {
|
||||
@@ -50,12 +67,17 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
localStorage.setItem(storageKey, String(value));
|
||||
}
|
||||
|
||||
function setActiveGroupId(value: number) {
|
||||
activeGroupId.value = value;
|
||||
}
|
||||
|
||||
function buildListParams(page: number, pageSize: number) {
|
||||
const params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
pid: number;
|
||||
type?: number;
|
||||
group_id?: number;
|
||||
keyword?: string;
|
||||
} = {
|
||||
page,
|
||||
@@ -67,6 +89,10 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
params.type = activeType.value;
|
||||
}
|
||||
|
||||
if (activeGroupId.value > 0) {
|
||||
params.group_id = activeGroupId.value;
|
||||
}
|
||||
|
||||
const kw = keyword.value.trim();
|
||||
if (kw) {
|
||||
params.keyword = kw;
|
||||
@@ -89,16 +115,22 @@ export function useFileGalleryFilter(storageKey: string) {
|
||||
|
||||
onMounted(() => {
|
||||
void loadFileTypes();
|
||||
void loadGroupOptions();
|
||||
});
|
||||
|
||||
return {
|
||||
fileTypes,
|
||||
activeType,
|
||||
activeGroupId,
|
||||
keyword,
|
||||
typesLoading,
|
||||
tabs,
|
||||
groupTabs,
|
||||
groupOptions,
|
||||
setActiveType,
|
||||
setActiveGroupId,
|
||||
buildListParams,
|
||||
loadFileTypes,
|
||||
loadGroupOptions,
|
||||
};
|
||||
}
|
||||
|
||||
80
apps/web-antd/src/composables/use-file-group-cache.ts
Normal file
80
apps/web-antd/src/composables/use-file-group-cache.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import type { FileGroupCachePayload, FileGroupOptionItem } from '#/api/core/file-group';
|
||||
import { getFileGroupOptions } from '#/api/core/file-group';
|
||||
|
||||
const STORAGE_KEY = 'file-group-options-cache';
|
||||
|
||||
/**
|
||||
* 读取本地分组选项缓存
|
||||
*/
|
||||
function readLocalCache(): FileGroupCachePayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as FileGroupCachePayload;
|
||||
if (!parsed || !Array.isArray(parsed.items)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入本地分组选项缓存(hash 与服务端 Redis 一致)
|
||||
*/
|
||||
function writeLocalCache(payload: FileGroupCachePayload) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分组选项缓存 composable
|
||||
* 右键菜单打开时调用 loadGroupOptions 获取最新列表
|
||||
*/
|
||||
export function useFileGroupCache() {
|
||||
const groupOptions = ref<FileGroupOptionItem[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 加载分组选项(hash 未变时 result=0,使用本地缓存)
|
||||
*/
|
||||
async function loadGroupOptions(force = false): Promise<FileGroupOptionItem[]> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const cached = readLocalCache();
|
||||
const hash = force ? undefined : cached?.hash;
|
||||
const res = (await getFileGroupOptions(hash)) as any;
|
||||
const result = res?.result ?? res?.data ?? res;
|
||||
const serverHash = res?.message ?? cached?.hash ?? '';
|
||||
|
||||
if (result === 0 && cached?.items?.length) {
|
||||
groupOptions.value = cached.items;
|
||||
return cached.items;
|
||||
}
|
||||
|
||||
const items = Array.isArray(result) ? result : [];
|
||||
writeLocalCache({ hash: serverHash, items });
|
||||
groupOptions.value = items;
|
||||
return items;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 分组 CRUD 后强制刷新缓存 */
|
||||
async function invalidateGroupOptions() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return loadGroupOptions(true);
|
||||
}
|
||||
|
||||
return {
|
||||
groupOptions,
|
||||
loading,
|
||||
loadGroupOptions,
|
||||
invalidateGroupOptions,
|
||||
};
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import QrCodePreview from '#/views/business/store/settings/components/Salesperso
|
||||
import SalespersonCommissionDrawer from '#/views/system/store/components/SalespersonCommissionDrawer.vue';
|
||||
import SalespersonCreateModal from '#/views/business/store/settings/components/SalespersonCreateModal.vue';
|
||||
import SalespersonUserBindDrawer from '#/views/business/store/settings/components/SalespersonUserBindDrawer.vue';
|
||||
import SalespersonInvitedDrawer from '#/views/business/store/settings/components/SalespersonInvitedDrawer.vue';
|
||||
|
||||
const listData = ref<{ total: number; items: any[] }>({ total: 0, items: [] });
|
||||
const loading = ref(false);
|
||||
@@ -33,6 +34,7 @@ const pageSize = ref(20);
|
||||
const openingAccountId = ref(0);
|
||||
|
||||
const commissionDrawerRef = ref<InstanceType<typeof SalespersonCommissionDrawer>>();
|
||||
const invitedDrawerRef = ref<InstanceType<typeof SalespersonInvitedDrawer>>();
|
||||
|
||||
const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({
|
||||
connectedComponent: QrCodePreview,
|
||||
@@ -94,6 +96,7 @@ function openEditModal(item: any) {
|
||||
values: item,
|
||||
getList,
|
||||
update: true,
|
||||
mode: 'platform',
|
||||
});
|
||||
SalespersonCreateModalApi.open();
|
||||
}
|
||||
@@ -129,6 +132,7 @@ function tcmBaseLabel(type: number) {
|
||||
<Page auto-content-height title="推广员管理">
|
||||
<SalespersonCreateModalComponent />
|
||||
<SalespersonCommissionDrawer ref="commissionDrawerRef" />
|
||||
<SalespersonInvitedDrawer ref="invitedDrawerRef" />
|
||||
<QrCodePreviewModal />
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
@@ -189,6 +193,9 @@ function tcmBaseLabel(type: number) {
|
||||
|
||||
<div class="mb-3 space-y-1 text-sm text-gray-600">
|
||||
<div>{{ item.phone }}</div>
|
||||
<div v-if="item.parent_nick_name" class="text-xs text-gray-500">
|
||||
邀请人:{{ item.parent_nick_name }}
|
||||
</div>
|
||||
<Popover trigger="click" title="获客记录">
|
||||
<template #content>
|
||||
<div class="w-[420px]">
|
||||
@@ -213,6 +220,9 @@ function tcmBaseLabel(type: number) {
|
||||
<Button size="small" type="primary" @click="openCommission(item)">
|
||||
分成与结算
|
||||
</Button>
|
||||
<Button size="small" @click="invitedDrawerRef?.open(item)">
|
||||
下级推广员
|
||||
</Button>
|
||||
<Button
|
||||
v-if="item.qr_code?.qr_code"
|
||||
size="small"
|
||||
|
||||
@@ -57,3 +57,12 @@ export async function syncChinaOrderToErpApi(data: { order_id: number }) {
|
||||
export async function accrueSalespersonCommissionApi(data: { order_id: number }) {
|
||||
return requestClient.post<any>('salesperson/accrue-order', data);
|
||||
}
|
||||
|
||||
/** 手动退回推广员分成(仅已退款/已取消订单) */
|
||||
export async function reverseSalespersonCommissionApi(data: {
|
||||
order_id: number;
|
||||
store_id: number;
|
||||
salesperson_id: number;
|
||||
}) {
|
||||
return requestClient.post<any>('salesperson/reverse-commission', data);
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface OrderExportDataParams {
|
||||
status?: number;
|
||||
delivery_method?: number;
|
||||
prescription_type?: number;
|
||||
drug_keyword?: string;
|
||||
}
|
||||
|
||||
export interface OrderExportDataResult {
|
||||
@@ -157,6 +158,13 @@ export async function deleteOrderExportScheme(data: { id: number }) {
|
||||
return requestClient.post<boolean>(`${prefix}export-scheme-delete`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消待支付订单
|
||||
*/
|
||||
export async function cancelOrderApi(data: { order_id: number; remark?: string }) {
|
||||
return requestClient.post<any>(`${prefix}cancel`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新包邮状态
|
||||
* @param data
|
||||
|
||||
@@ -82,6 +82,7 @@ const storeId = ref<number | undefined>(undefined);
|
||||
const status = ref<number | undefined>(undefined);
|
||||
const deliveryMethod = ref<number | undefined>(undefined);
|
||||
const prescriptionType = ref<number | undefined>(undefined);
|
||||
const drugKeyword = ref('');
|
||||
const granularity = ref<ExportGranularity>('order');
|
||||
const selectedKeys = ref<string[]>(getDefaultSelectedKeys('order'));
|
||||
const orderedSelectedKeys = ref<string[]>(
|
||||
@@ -206,6 +207,7 @@ function resetFormDefaults() {
|
||||
status.value = undefined;
|
||||
deliveryMethod.value = undefined;
|
||||
prescriptionType.value = undefined;
|
||||
drugKeyword.value = '';
|
||||
granularity.value = 'order';
|
||||
selectedKeys.value = getDefaultSelectedKeys('order');
|
||||
orderedSelectedKeys.value = buildDefaultOrderedKeys(
|
||||
@@ -544,6 +546,9 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (prescriptionType.value != null) {
|
||||
params.prescription_type = prescriptionType.value;
|
||||
}
|
||||
if (drugKeyword.value.trim()) {
|
||||
params.drug_keyword = drugKeyword.value.trim();
|
||||
}
|
||||
|
||||
const res = await getOrderExportDataApi(params);
|
||||
const rows = res?.rows ?? [];
|
||||
@@ -748,6 +753,14 @@ const [Modal, modalApi] = useVbenModal({
|
||||
placeholder="全部"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs text-muted-foreground">药品</div>
|
||||
<Input
|
||||
v-model:value="drugKeyword"
|
||||
allow-clear
|
||||
placeholder="药品名称/拼音首拼"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -105,6 +105,15 @@ export const formOptions: VbenFormProps = {
|
||||
fieldName: 'prescription_type',
|
||||
label: '订单类型',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '药品名称/拼音首拼',
|
||||
},
|
||||
defaultValue: '',
|
||||
fieldName: 'drug_keyword',
|
||||
label: '药品',
|
||||
},
|
||||
{
|
||||
component: 'RangePicker',
|
||||
componentProps: {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{
|
||||
field: 'salesperson',
|
||||
title: '推广员',
|
||||
width: 180,
|
||||
width: 220,
|
||||
slots: { default: 'salesperson' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,20 +11,22 @@ import {
|
||||
useVbenDrawer,
|
||||
useVbenModal,
|
||||
} from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { SvgCakeIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Image, message, Modal as AntdModal, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
|
||||
import { Button, Image, message, Modal as AntdModal, Popconfirm, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import {
|
||||
cancelOrderApi,
|
||||
getOrderInfo,
|
||||
getOrderList,
|
||||
getVerifyRecentOrderAmounts,
|
||||
saleAmountApi,
|
||||
updateFreeShipping,
|
||||
} from '#/views/business/order/product-order/api';
|
||||
import { simulatePayApi, accrueSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
|
||||
import { simulatePayApi, accrueSalespersonCommissionApi, reverseSalespersonCommissionApi } from '#/views/business/order/api/order-ops';
|
||||
import ChinaErpSyncLogDrawer from '#/views/business/order/components/china-erp-sync-log-drawer.vue';
|
||||
import OrderTraceDrawer from '#/views/business/order/components/order-trace-drawer.vue';
|
||||
import OrderPricePercentAdjustDrawer from '#/views/business/order/components/OrderPricePercentAdjustDrawer.vue';
|
||||
@@ -39,8 +41,11 @@ import ProductOrderExportModal from './components/ProductOrderExportModal.vue';
|
||||
import Refund from './components/refund.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import { isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const canViewInviterCommission = isPlatformSuperAdmin(userStore.userInfo);
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
@@ -279,6 +284,7 @@ function handleSimulatePay(row: Record<string, any>) {
|
||||
}
|
||||
|
||||
const accruingOrderIds = ref<number[]>([]);
|
||||
const reversingOrderIds = ref<number[]>([]);
|
||||
|
||||
async function handleAccrueSalesperson(row: Record<string, any>) {
|
||||
if (accruingOrderIds.value.includes(row.id)) {
|
||||
@@ -296,6 +302,32 @@ async function handleAccrueSalesperson(row: Record<string, any>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动退回推广员分成 */
|
||||
async function handleReverseSalesperson(row: Record<string, any>, salespersonId?: number) {
|
||||
const spId = salespersonId ?? row.salesperson_id;
|
||||
if (!spId || reversingOrderIds.value.includes(row.id)) {
|
||||
return;
|
||||
}
|
||||
reversingOrderIds.value.push(row.id);
|
||||
try {
|
||||
await reverseSalespersonCommissionApi({
|
||||
order_id: row.id,
|
||||
store_id: row.store_id || 0,
|
||||
salesperson_id: spId,
|
||||
});
|
||||
message.success('退回分成成功');
|
||||
await gridApi.query();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '退回分成失败');
|
||||
} finally {
|
||||
reversingOrderIds.value = reversingOrderIds.value.filter((id) => id !== row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function isNegativeCommission(amount?: string | number) {
|
||||
return Number(amount ?? 0) < 0;
|
||||
}
|
||||
|
||||
function formatExpressAddress(row: Record<string, any>) {
|
||||
if (Number(row.delivery_method) === 1) {
|
||||
return row.store?.position || row.store?.name || '到店自提';
|
||||
@@ -308,17 +340,19 @@ function formatExpressAddress(row: Record<string, any>) {
|
||||
function salespersonStatusColor(text: string) {
|
||||
if (text === '已分成') return 'success';
|
||||
if (text === '未分成') return 'warning';
|
||||
if (text === '已冲销') return 'warning';
|
||||
if (text === '未支付') return 'default';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatCommissionRecordDetail(record: Record<string, any>) {
|
||||
const typeText = record.record_type_text ? `${record.record_type_text} · ` : '';
|
||||
const path = record.commission_path_text || '—';
|
||||
const rule = record.commission_rule_text || '—';
|
||||
if (record.commission_mode_text === '比例分成') {
|
||||
return `${path} · ${rule}`;
|
||||
return `${typeText}${path} · ${rule}`;
|
||||
}
|
||||
return `${path} · ${record.commission_mode_text || '固定单价'} · ${rule}`;
|
||||
return `${typeText}${path} · ${record.commission_mode_text || '固定单价'} · ${rule}`;
|
||||
}
|
||||
|
||||
const openPrescriptionDetail = (values) => {
|
||||
@@ -346,6 +380,17 @@ const openRefundModal = (id) => {
|
||||
RefundModalApi.open();
|
||||
};
|
||||
|
||||
/** 取消待支付订单 */
|
||||
async function handleCancelOrder(row: Record<string, any>) {
|
||||
try {
|
||||
await cancelOrderApi({ order_id: row.id });
|
||||
message.success('订单已取消');
|
||||
await gridApi.query();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '取消失败');
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFreeShipping = async (row: any, checked: boolean) => {
|
||||
try {
|
||||
const res = await updateFreeShipping({ id: row.id, is_free_shipping: checked ? 1 : 0 });
|
||||
@@ -577,43 +622,144 @@ const openOrderAmountVerify = () => {
|
||||
</div>
|
||||
</template>
|
||||
<template #salesperson="{ row }">
|
||||
<div v-if="row.salesperson?.id" class="space-y-1 text-sm leading-snug">
|
||||
<!-- <Tag :color="salespersonStatusColor(row.salesperson.commission_status_text)">-->
|
||||
<!-- {{ row.salesperson.commission_status_text }}-->
|
||||
<!-- </Tag>-->
|
||||
<!-- <div v-if="row.is_pay === 1" class="text-orange-600">-->
|
||||
<!-- 分成:¥{{ row.salesperson.commission_amount || '0.00' }}-->
|
||||
<!-- </div>-->
|
||||
<Popover trigger="click" placement="topLeft">
|
||||
<div v-if="row.salesperson?.id" class="space-y-1">
|
||||
<Popover trigger="click" placement="topLeft" overlay-class-name="salesperson-commission-popover">
|
||||
<template #title>
|
||||
<span class="font-semibold">推广员分成</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="max-w-xs space-y-2 text-sm">
|
||||
<div
|
||||
v-if="!(row.salesperson.commission_records?.length > 0)"
|
||||
class="text-gray-500"
|
||||
>
|
||||
暂无分成明细
|
||||
<div class="w-80 space-y-3 text-sm">
|
||||
<div class="rounded-lg bg-gray-50 p-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-800">直推推广员</span>
|
||||
<span class="font-medium text-orange-600">
|
||||
¥{{ row.salesperson.commission_amount || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-1 text-gray-600">{{ row.salesperson.nick_name || '—' }}</div>
|
||||
<div
|
||||
v-if="!(row.salesperson.commission_records?.length > 0)"
|
||||
class="text-gray-400"
|
||||
>
|
||||
暂无分成明细
|
||||
</div>
|
||||
<div
|
||||
v-for="(record, idx) in row.salesperson.commission_records || []"
|
||||
:key="'d-' + idx"
|
||||
class="flex items-start justify-between border-b border-gray-100 py-2 last:border-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<div class="font-medium text-gray-800">{{ record.drug_name || '—' }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatCommissionRecordDetail(record) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 font-medium"
|
||||
:class="isNegativeCommission(record.commission_amount) ? 'text-red-500' : 'text-orange-600'"
|
||||
>
|
||||
¥{{ record.commission_amount }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.salesperson.can_reverse_commission" class="mt-2 text-right">
|
||||
<Popconfirm
|
||||
title="确认退回直推推广员分成?"
|
||||
@confirm="handleReverseSalesperson(row)"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="!h-auto !px-0 !py-0"
|
||||
:loading="reversingOrderIds.includes(row.id)"
|
||||
>
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(record, idx) in row.salesperson.commission_records || []"
|
||||
:key="idx"
|
||||
class="border-b border-gray-100 pb-2 last:border-0 last:pb-0"
|
||||
v-if="canViewInviterCommission && row.salesperson.has_inviter && row.salesperson.inviter"
|
||||
class="rounded-lg bg-orange-50 p-3"
|
||||
>
|
||||
<div class="font-medium">{{ record.drug_name || '—' }}</div>
|
||||
<div class="text-gray-500">
|
||||
{{ formatCommissionRecordDetail(record) }}
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="font-semibold text-gray-800">
|
||||
邀请人({{ row.salesperson.inviter.inviter_split }}%)
|
||||
</span>
|
||||
<span class="font-medium text-orange-600">
|
||||
¥{{ row.salesperson.inviter.commission_amount || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-1 text-gray-600">{{ row.salesperson.inviter.nick_name || '—' }}</div>
|
||||
<div
|
||||
v-if="!(row.salesperson.inviter.commission_records?.length > 0)"
|
||||
class="text-gray-400"
|
||||
>
|
||||
暂无邀请分成明细
|
||||
</div>
|
||||
<div
|
||||
v-for="(record, idx) in row.salesperson.inviter.commission_records || []"
|
||||
:key="'i-' + idx"
|
||||
class="flex items-start justify-between border-b border-orange-100 py-2 last:border-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1 pr-2">
|
||||
<div class="font-medium text-gray-800">{{ record.drug_name || '—' }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ formatCommissionRecordDetail(record) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 font-medium"
|
||||
:class="isNegativeCommission(record.commission_amount) ? 'text-red-500' : 'text-orange-600'"
|
||||
>
|
||||
¥{{ record.commission_amount }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="canViewInviterCommission && row.salesperson.inviter?.can_reverse_commission"
|
||||
class="mt-2 text-right"
|
||||
>
|
||||
<Popconfirm
|
||||
title="确认退回邀请人分成?"
|
||||
@confirm="handleReverseSalesperson(row, row.salesperson.inviter.id)"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
class="!h-auto !px-0 !py-0"
|
||||
:loading="reversingOrderIds.includes(row.id)"
|
||||
>
|
||||
退回邀请分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<div class="text-orange-600">¥{{ record.commission_amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex cursor-pointer items-center gap-2 hover:text-blue-600">
|
||||
<div class="flex cursor-pointer items-center gap-2 hover:opacity-80">
|
||||
<Image
|
||||
:src="row.salesperson.avatar || '/img/user-default-avatar.png'"
|
||||
:width="28"
|
||||
:height="28"
|
||||
class="rounded-full object-cover"
|
||||
:width="32"
|
||||
:height="32"
|
||||
class="shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
<span>{{ row.salesperson.nick_name || '—' }}({{row.salesperson.commission_status_text}})</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="truncate text-sm font-medium">{{
|
||||
row.salesperson.nick_name || '—'
|
||||
}}</span>
|
||||
<Tag
|
||||
:color="salespersonStatusColor(row.salesperson.commission_status_text)"
|
||||
class="!m-0 shrink-0"
|
||||
>
|
||||
{{ row.salesperson.commission_status_text }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-orange-600">
|
||||
¥{{ row.salesperson.commission_amount || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
<Button
|
||||
@@ -630,6 +776,21 @@ const openOrderAmountVerify = () => {
|
||||
>
|
||||
立即分成
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-else-if="row.is_pay === 1 && row.salesperson.can_reverse_commission"
|
||||
title="确认退回推广员分成?"
|
||||
@confirm="handleReverseSalesperson(row)"
|
||||
>
|
||||
<Button
|
||||
class="!h-auto !px-0 !py-0"
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:loading="reversingOrderIds.includes(row.id)"
|
||||
>
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<span v-else class="text-sm text-gray-500">
|
||||
{{ row.salesperson?.commission_status_text || '无推广员' }}
|
||||
@@ -754,7 +915,7 @@ const openOrderAmountVerify = () => {
|
||||
label: '模拟支付',
|
||||
type: 'link',
|
||||
icon: 'mdi:cash-check',
|
||||
auth: ['Super Admin', 'sys:user:save'],
|
||||
auth: ['Super Admin'],
|
||||
ifShow: row.is_pay === 0,
|
||||
onClick: handleSimulatePay.bind(null, row),
|
||||
},
|
||||
@@ -762,7 +923,7 @@ const openOrderAmountVerify = () => {
|
||||
label: '发货',
|
||||
type: 'link',
|
||||
icon: 'ri:send-plane-fill',
|
||||
auth: ['Super Admin','Admin', 'sys:user:save'],
|
||||
auth: ['Super Admin','Admin'],
|
||||
onClick: wareSend.bind(null, row),
|
||||
// popConfirm: {
|
||||
// title: '确定发货吗?',
|
||||
@@ -771,10 +932,23 @@ const openOrderAmountVerify = () => {
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
label: '取消订单',
|
||||
type: 'link',
|
||||
icon: 'mdi:cancel',
|
||||
auth: ['Super Admin', 'Admin'],
|
||||
ifShow:
|
||||
row.status === 0 && row.is_pay === 0 && row.cancel_status === 0,
|
||||
popConfirm: {
|
||||
title: '确定取消该待支付订单吗?',
|
||||
confirm: handleCancelOrder.bind(null, row),
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '退款',
|
||||
type: 'link',
|
||||
icon: 'mingcute:refund-dollar-fill',
|
||||
auth: ['Super Admin','Admin'],
|
||||
// auth: ['超级订单', 'sys:user:save'],
|
||||
ifShow: row.is_pay === 1,
|
||||
onClick: openRefundModal.bind(null, row.id),
|
||||
@@ -817,7 +991,7 @@ const openOrderAmountVerify = () => {
|
||||
<div class="flex-grow space-y-2">
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">商品名称:</span>
|
||||
{{ item.drug_name }}
|
||||
{{ item.display_drug_name || item.drug_name }}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">商品规格:</span>
|
||||
@@ -858,7 +1032,7 @@ const openOrderAmountVerify = () => {
|
||||
class="tcm-drug-chip w-[200px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
|
||||
>
|
||||
<div class="font-medium">
|
||||
【{{ item.drug?.drug_number }}】{{ item.drug_name }}
|
||||
【{{ item.drug?.drug_number }}】{{ item.display_drug_name || item.drug_name }}
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
数量:*{{ item.number * (item.dosage > 0 ? item.dosage : 1) }}
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function getSalespersonTransferListApi(params: Record<string, any>)
|
||||
}
|
||||
|
||||
export async function getSalespersonTransferDetailApi(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
return requestClient.get<any>(`${prefix}salesperson-detail`, { params: { id } });
|
||||
}
|
||||
|
||||
export async function getSalespersonTransferByRegisterApi(registerId: number) {
|
||||
@@ -16,6 +16,13 @@ export async function getSalespersonTransferByRegisterApi(registerId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 医生端悬浮窗:按传方 ID + 挂号 ID 准备导入(补全门店价) */
|
||||
export async function prepareDoctorImportApi(id: number, registerId: number) {
|
||||
return requestClient.get<any>(`${prefix}prepare-doctor-import`, {
|
||||
params: { id, register_id: registerId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markSalespersonTransferImportedApi(
|
||||
id: number,
|
||||
registerId: number,
|
||||
@@ -25,3 +32,17 @@ export async function markSalespersonTransferImportedApi(
|
||||
register_id: registerId,
|
||||
});
|
||||
}
|
||||
|
||||
/** 医生端:按门店获取传方列表(悬浮窗) */
|
||||
export async function getTransferPrescriptionListByStoreApi(
|
||||
params: Record<string, any>,
|
||||
) {
|
||||
return requestClient.get<any>(`${prefix}list-by-store`, { params });
|
||||
}
|
||||
|
||||
/** 医生手动更新传方处理状态 */
|
||||
export async function updateTransferPrescriptionHandleStatusApi(
|
||||
data: Record<string, any>,
|
||||
) {
|
||||
return requestClient.post<any>(`${prefix}update-handle-status`, data);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Modal, Table } from 'ant-design-vue';
|
||||
import { Image, Modal, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getSalespersonTransferDetailApi } from '../api';
|
||||
|
||||
@@ -10,7 +10,16 @@ const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
|
||||
const drugColumns = [
|
||||
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', customRender: ({ record }: any) => record.drug_name || record.name || '-' },
|
||||
{
|
||||
title: '药品',
|
||||
key: 'drug_text',
|
||||
customRender: ({ record }: any) => {
|
||||
const name = record.drug_name || record.name || '—';
|
||||
const num = record.number ?? 1;
|
||||
const unit = record.drug_unit || record.unit || record.unit_name || 'g';
|
||||
return `${name}/${num}/${unit}`;
|
||||
},
|
||||
},
|
||||
{ title: '数量', dataIndex: 'number', key: 'number', width: 80 },
|
||||
];
|
||||
|
||||
@@ -40,6 +49,16 @@ const drugList = computed(() => {
|
||||
return list;
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const map: Record<number, string> = {
|
||||
0: 'warning',
|
||||
1: 'processing',
|
||||
2: 'success',
|
||||
3: 'error',
|
||||
};
|
||||
return map[Number(detail.value?.status)] || 'default';
|
||||
});
|
||||
|
||||
async function open(id: number) {
|
||||
visible.value = true;
|
||||
loading.value = true;
|
||||
@@ -61,16 +80,34 @@ defineExpose({ open });
|
||||
<template>
|
||||
<Modal
|
||||
v-model:open="visible"
|
||||
title="传方药品详情"
|
||||
title="传方详情"
|
||||
width="720"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
@cancel="close"
|
||||
>
|
||||
<div v-if="detail" class="mb-3 text-sm text-gray-500">
|
||||
<div v-if="detail" class="mb-4 space-y-2 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-500">处理状态:</span>
|
||||
<Tag :color="statusColor">{{ detail.status_text || '—' }}</Tag>
|
||||
<Tag>{{ detail.transfer_mode_text || '—' }}</Tag>
|
||||
</div>
|
||||
<div>患者:{{ detail.patient_name }} {{ detail.patient_mobile }}</div>
|
||||
<div>诊断:{{ detail.clinical_diagnose || '-' }}</div>
|
||||
<div>医嘱:{{ detail.doctor_order || '-' }}</div>
|
||||
<div>地址:{{ detail.patient_address || '—' }}</div>
|
||||
<div v-if="detail.remark">备注:{{ detail.remark }}</div>
|
||||
<div v-if="detail.status === 3 && detail.exception_message" class="text-red-500">
|
||||
异常说明:{{ detail.exception_message }}
|
||||
</div>
|
||||
<div v-if="detail.prescription_images?.length" class="flex flex-wrap gap-2 pt-2">
|
||||
<Image
|
||||
v-for="(img, idx) in detail.prescription_images"
|
||||
:key="idx"
|
||||
:src="img"
|
||||
:width="80"
|
||||
:height="80"
|
||||
class="rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Table
|
||||
:loading="loading"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { h, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Empty, Spin, Table, Tag } from 'ant-design-vue';
|
||||
import { Button, Empty, Spin, Table, Tag, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import DetailModal from './components/DetailModal.vue';
|
||||
import { getSalespersonTransferListApi } from './api';
|
||||
@@ -15,22 +15,63 @@ const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const detailRef = ref<InstanceType<typeof DetailModal>>();
|
||||
|
||||
function statusTag(status: number, text: string) {
|
||||
const colorMap: Record<number, string> = {
|
||||
0: 'warning',
|
||||
1: 'processing',
|
||||
2: 'success',
|
||||
3: 'error',
|
||||
};
|
||||
return h(Tag, { color: colorMap[status] || 'default' }, () => text || '—');
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '诊所', dataIndex: 'store_name', key: 'store_name' },
|
||||
{ title: '推广员', dataIndex: 'salesperson_name', key: 'salesperson_name' },
|
||||
{ title: '患者', key: 'patient', customRender: ({ record }: any) => `${record.patient_name || ''} ${record.patient_mobile || ''}` },
|
||||
{ title: '传方时间', dataIndex: 'transfer_time_text', key: 'transfer_time_text' },
|
||||
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 120 },
|
||||
{ title: '推广员', dataIndex: 'salesperson_name', key: 'salesperson_name', width: 100 },
|
||||
{
|
||||
title: '是否已导入',
|
||||
key: 'is_imported',
|
||||
title: '患者',
|
||||
key: 'patient',
|
||||
width: 140,
|
||||
customRender: ({ record }: any) => `${record.patient_name || ''} ${record.patient_mobile || ''}`,
|
||||
},
|
||||
{
|
||||
title: '方式',
|
||||
dataIndex: 'transfer_mode_text',
|
||||
key: 'transfer_mode_text',
|
||||
width: 90,
|
||||
},
|
||||
{ title: '传方时间', dataIndex: 'transfer_time_text', key: 'transfer_time_text', width: 160 },
|
||||
{
|
||||
title: '处理状态',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
customRender: ({ record }: any) =>
|
||||
record.is_imported
|
||||
? h(Tag, { color: 'success' }, () => '已导入')
|
||||
: h(Tag, { color: 'warning' }, () => '待导入'),
|
||||
statusTag(Number(record.status), record.status_text),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
key: 'remark',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
customRender: ({ record }: any) => record.remark || '—',
|
||||
},
|
||||
{
|
||||
title: '异常信息',
|
||||
key: 'exception_message',
|
||||
width: 120,
|
||||
customRender: ({ record }: any) =>
|
||||
record.exception_message
|
||||
? h(Tooltip, { title: record.exception_message }, () =>
|
||||
h('span', { class: 'text-red-500 truncate block max-w-[100px]' }, record.exception_message),
|
||||
)
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
title: '处方',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
customRender: ({ record }: any) =>
|
||||
h(Button, { type: 'link', onClick: () => detailRef.value?.open(record.id) }, () => '查看'),
|
||||
},
|
||||
@@ -67,6 +108,7 @@ function onPageChange(p: number, ps: number) {
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:scroll="{ x: 1100 }"
|
||||
:pagination="{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -26,6 +26,31 @@ export async function updateSpecialPrescriptionStatus(data: {
|
||||
return requestClient.post<any>(`${prefix}update-status`, data);
|
||||
}
|
||||
|
||||
/** 特色方包邮切换(无 SKU 时) */
|
||||
export async function updateSpecialPrescriptionFreeShipping(data: {
|
||||
id: number;
|
||||
is_free_shipping: number;
|
||||
free_shipping_min_doses?: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-free-shipping`, data);
|
||||
}
|
||||
|
||||
/** SKU 包邮切换 */
|
||||
export async function updateSpecialPrescriptionSkuFreeShipping(data: {
|
||||
sku_id: number;
|
||||
is_free_shipping: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-sku-free-shipping`, data);
|
||||
}
|
||||
|
||||
/** SKU 上下架切换 */
|
||||
export async function updateSpecialPrescriptionSkuStatus(data: {
|
||||
sku_id: number;
|
||||
status: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${prefix}update-sku-status`, data);
|
||||
}
|
||||
|
||||
export async function deleteSpecialPrescription(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ const props = withDefaults(
|
||||
doctorOrder?: string;
|
||||
/** 是否展示诊断/医嘱气泡卡(价格配置等场景可关闭) */
|
||||
showDiagnosisOrder?: boolean;
|
||||
/** SKU 贴数:大于 1 时展示套餐合计 */
|
||||
packageDoseCount?: number;
|
||||
/** 固定价占位模式:药味单价展示/保存恒为 0 */
|
||||
fixedZeroPrice?: boolean;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
@@ -60,6 +64,8 @@ const props = withDefaults(
|
||||
clinicalDiagnose: '',
|
||||
doctorOrder: '',
|
||||
showDiagnosisOrder: true,
|
||||
packageDoseCount: 1,
|
||||
fixedZeroPrice: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -82,6 +88,21 @@ const showDiagnosisOrderPanel = computed(
|
||||
() => props.showDiagnosisOrder && !props.structureReadonly,
|
||||
);
|
||||
|
||||
/** 一贴销售价合计(价格配置均分核对用) */
|
||||
const doseSaleTotal = computed(() => {
|
||||
const sum = drugList.value.reduce(
|
||||
(acc, drug) => acc + Number(drug.price || 0) * Number(drug.number || 1),
|
||||
0,
|
||||
);
|
||||
return sum.toFixed(2);
|
||||
});
|
||||
|
||||
/** 套餐销售价合计 = 一贴合计 × 贴数 */
|
||||
const packageSaleTotal = computed(() => {
|
||||
const dose = Math.max(1, Number(props.packageDoseCount || 1));
|
||||
return (Number(doseSaleTotal.value) * dose).toFixed(2);
|
||||
});
|
||||
|
||||
/** 预选诊断双向绑定 */
|
||||
const clinicalDiagnoseModel = computed({
|
||||
get: () => props.clinicalDiagnose ?? '',
|
||||
@@ -256,14 +277,15 @@ function loadDrugs(recipes: any[], dosage?: number, dayDosage?: number, drugPric
|
||||
drugList.value = (recipes || []).map((recipe) => {
|
||||
const drugId = recipe.drug_id || recipe.id;
|
||||
const configured = priceMap.get(Number(drugId));
|
||||
const zeroPrice = props.fixedZeroPrice;
|
||||
return {
|
||||
_key: Date.now() + Math.random(),
|
||||
id: drugId,
|
||||
drug_id: drugId,
|
||||
drug_name: recipe.drug_name || recipe.name || '',
|
||||
number: recipe.number || 1,
|
||||
price: configured?.sell_price ?? recipe.price ?? 0,
|
||||
buy_price: configured?.buy_price ?? recipe.buy_price ?? 0,
|
||||
price: zeroPrice ? 0 : (configured?.sell_price ?? recipe.price ?? 0),
|
||||
buy_price: zeroPrice ? 0 : (configured?.buy_price ?? recipe.buy_price ?? 0),
|
||||
way_id: recipe.way_id || 0,
|
||||
};
|
||||
});
|
||||
@@ -436,13 +458,13 @@ defineExpose({
|
||||
</Select>
|
||||
</div>
|
||||
<div class="chinese-drug-price">
|
||||
<template v-if="priceEditable">
|
||||
<template v-if="priceEditable && !fixedZeroPrice">
|
||||
<div class="price-edit-row">
|
||||
<span>售</span>
|
||||
<InputNumber
|
||||
v-model:value="drug.price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
@@ -451,8 +473,8 @@ defineExpose({
|
||||
<span>供</span>
|
||||
<InputNumber
|
||||
v-model:value="drug.buy_price"
|
||||
:min="0"
|
||||
:precision="4"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 72px"
|
||||
@@ -463,6 +485,9 @@ defineExpose({
|
||||
小计 ¥{{ ((drug.price || 0) * (drug.number || 1)).toFixed(2) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="fixedZeroPrice">
|
||||
单价 ¥0.00
|
||||
</template>
|
||||
<template v-else>
|
||||
¥{{ ((drug.price || 0) * (drug.number || 1)).toFixed(2) }}
|
||||
</template>
|
||||
@@ -531,6 +556,10 @@ defineExpose({
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<div v-if="priceEditable && !fixedZeroPrice" class="price-allocation-summary">
|
||||
<span>一贴合计:¥{{ doseSaleTotal }}</span>
|
||||
<span v-if="packageDoseCount > 1" class="ml-4">套餐合计:¥{{ packageSaleTotal }}</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -613,6 +642,17 @@ defineExpose({
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.price-allocation-summary {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.chinese-drug-delete {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { InputNumber, message } from 'ant-design-vue';
|
||||
|
||||
import { updateSpecialPrescriptionFreeShipping } from '../api';
|
||||
|
||||
/** 弹窗模式:enable=开启包邮,edit=编辑已有包邮条件 */
|
||||
type FreeShippingModalMode = 'enable' | 'edit';
|
||||
|
||||
const gridApi = ref<any>();
|
||||
const spId = ref(0);
|
||||
const spName = ref('');
|
||||
const mode = ref<FreeShippingModalMode>('enable');
|
||||
/** 包邮条件剂数:0=始终包邮,>0=大于该剂数才包邮 */
|
||||
const freeShippingMinDoses = ref(0);
|
||||
|
||||
const modalTitle = computed(() =>
|
||||
mode.value === 'edit' ? '编辑包邮条件' : '设置包邮条件',
|
||||
);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
draggable: true,
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onConfirm: async () => {
|
||||
if (spId.value <= 0) {
|
||||
return;
|
||||
}
|
||||
const minDoses = Math.max(0, Number(freeShippingMinDoses.value) || 0);
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
try {
|
||||
await updateSpecialPrescriptionFreeShipping({
|
||||
id: spId.value,
|
||||
is_free_shipping: 1,
|
||||
free_shipping_min_doses: minDoses,
|
||||
});
|
||||
message.success(mode.value === 'edit' ? '包邮条件已更新' : '已设为包邮');
|
||||
gridApi.value?.reload?.();
|
||||
gridApi.value?.query?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<{
|
||||
id: number;
|
||||
name?: string;
|
||||
free_shipping_min_doses?: number;
|
||||
mode?: FreeShippingModalMode;
|
||||
gridApi?: any;
|
||||
}>();
|
||||
gridApi.value = data?.gridApi;
|
||||
spId.value = data?.id ?? 0;
|
||||
spName.value = data?.name ?? '';
|
||||
mode.value = data?.mode ?? 'enable';
|
||||
freeShippingMinDoses.value = data?.free_shipping_min_doses ?? 0;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="modalTitle" class="w-[420px]">
|
||||
<div class="free-shipping-dose-modal">
|
||||
<p v-if="spName" class="sp-name">特色方:{{ spName }}</p>
|
||||
<div class="form-row">
|
||||
<span class="label">包邮条件(剂数)</span>
|
||||
<InputNumber
|
||||
v-model:value="freeShippingMinDoses"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
placeholder="0 表示始终包邮"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<p class="hint">0 表示始终包邮;大于该剂数才包邮</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.free-shipping-dose-modal {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.sp-name {
|
||||
margin-bottom: 16px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-row .label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Col, InputNumber, message, Radio, RadioGroup, Row } from 'ant-design-vue';
|
||||
import { InputNumber, message, Radio, RadioGroup } from 'ant-design-vue';
|
||||
|
||||
import ChineseDrugEditor from './ChineseDrugEditor.vue';
|
||||
import SkuEditor, { type SkuItem } from './SkuEditor.vue';
|
||||
import {
|
||||
distributeDrugPricesApi,
|
||||
getSpecialPrescriptionPriceConfig,
|
||||
saveSpecialPrescriptionPriceConfig,
|
||||
} from '../api';
|
||||
|
||||
/** 药品分摊价条目 */
|
||||
/** 药品占位价条目(固定价方案各药味恒为 0) */
|
||||
interface DrugPriceItem {
|
||||
drug_id: number;
|
||||
buy_price: number;
|
||||
@@ -27,28 +25,16 @@ const priceCalcScheme = ref(3);
|
||||
const buyPricePerDose = ref(0);
|
||||
const salePricePerDose = ref(0);
|
||||
const skus = ref<SkuItem[]>([]);
|
||||
const chineseDrugEditorRef = ref<InstanceType<typeof ChineseDrugEditor>>();
|
||||
const isFreeShipping = ref(0);
|
||||
const freeShippingMinDoses = ref(0);
|
||||
const skuEditorRef = ref<InstanceType<typeof SkuEditor>>();
|
||||
|
||||
/** 当前分摊视图:0=单帖,>0=SKU id */
|
||||
const activeSkuId = ref(0);
|
||||
/** 各 scope 分摊价缓存 sku_id -> prices[] */
|
||||
const drugPricesCache = ref<Record<number, DrugPriceItem[]>>({});
|
||||
/** 处方药味明细,仅用于保存时生成全 0 的 drug_prices */
|
||||
const prescriptionDetailCache = ref<any[]>([]);
|
||||
const prescriptionDosage = ref(7);
|
||||
const prescriptionDayDosage = ref(2);
|
||||
|
||||
const showFixedPriceBlock = computed(() => priceCalcScheme.value === 1);
|
||||
|
||||
/** 左侧标题:单帖或 SKU 名称 */
|
||||
const allocationTitle = computed(() => {
|
||||
if (activeSkuId.value <= 0) {
|
||||
return '单帖均摊价格';
|
||||
}
|
||||
const sku = skus.value.find((item) => Number(item.id) === Number(activeSkuId.value));
|
||||
const name = sku?.sku_name || `${sku?.dose_count || ''}贴`;
|
||||
return `${name} 均摊价格`;
|
||||
});
|
||||
/** 是否已配置 SKU(有 SKU 时以 SKU 包邮为准) */
|
||||
const hasSkus = computed(() => skus.value.length > 0);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: true,
|
||||
@@ -60,24 +46,24 @@ const [Modal, modalApi] = useVbenModal({
|
||||
if (configId.value <= 0) {
|
||||
return;
|
||||
}
|
||||
persistCurrentScopePrices();
|
||||
const payload: Record<string, any> = {
|
||||
id: configId.value,
|
||||
price_calc_scheme: priceCalcScheme.value,
|
||||
is_free_shipping: isFreeShipping.value,
|
||||
free_shipping_min_doses: freeShippingMinDoses.value,
|
||||
skus: skuEditorRef.value?.getSkusPayload() || skus.value,
|
||||
};
|
||||
if (priceCalcScheme.value === 1) {
|
||||
payload.buy_price_per_dose = buyPricePerDose.value;
|
||||
payload.sale_price_per_dose = salePricePerDose.value;
|
||||
payload.drug_prices = drugPricesCache.value[0] || [];
|
||||
// 固定价:各药味单价恒为 0,套餐价由单帖价或 SKU 总价承载
|
||||
const zeroPrices = buildZeroDrugPrices();
|
||||
payload.drug_prices = zeroPrices;
|
||||
const skuDrugPrices: Array<{ sku_id: number; drug_prices: DrugPriceItem[] }> = [];
|
||||
Object.keys(drugPricesCache.value).forEach((key) => {
|
||||
const skuId = Number(key);
|
||||
if (skuId > 0 && drugPricesCache.value[skuId]?.length) {
|
||||
skuDrugPrices.push({
|
||||
sku_id: skuId,
|
||||
drug_prices: drugPricesCache.value[skuId],
|
||||
});
|
||||
skus.value.forEach((sku) => {
|
||||
const skuId = Number(sku.id || 0);
|
||||
if (skuId > 0) {
|
||||
skuDrugPrices.push({ sku_id: skuId, drug_prices: zeroPrices });
|
||||
}
|
||||
});
|
||||
payload.sku_drug_prices = skuDrugPrices;
|
||||
@@ -98,8 +84,6 @@ const [Modal, modalApi] = useVbenModal({
|
||||
const { values, gridApi: grid } = modalApi.getData<Record<string, any>>();
|
||||
gridApi.value = grid;
|
||||
configId.value = values?.id || 0;
|
||||
activeSkuId.value = 0;
|
||||
drugPricesCache.value = {};
|
||||
if (!configId.value) {
|
||||
return;
|
||||
}
|
||||
@@ -109,107 +93,24 @@ const [Modal, modalApi] = useVbenModal({
|
||||
priceCalcScheme.value = res.price_calc_scheme ?? 3;
|
||||
buyPricePerDose.value = res.buy_price_per_dose ?? 0;
|
||||
salePricePerDose.value = res.sale_price_per_dose ?? res.price_per_dose ?? 0;
|
||||
isFreeShipping.value = res.is_free_shipping ?? 0;
|
||||
freeShippingMinDoses.value = res.free_shipping_min_doses ?? 0;
|
||||
skus.value = res.skus || [];
|
||||
skuEditorRef.value?.setSkus(res.skus || []);
|
||||
|
||||
prescriptionDetailCache.value = res.prescription_detail || [];
|
||||
const first = prescriptionDetailCache.value[0];
|
||||
prescriptionDosage.value = first?.dosage ?? 7;
|
||||
prescriptionDayDosage.value = first?.consumption ?? 2;
|
||||
|
||||
drugPricesCache.value[0] = (res.drug_prices || []).map((item: any) => ({
|
||||
drug_id: Number(item.drug_id),
|
||||
buy_price: Number(item.buy_price || 0),
|
||||
sell_price: Number(item.sell_price || item.price || 0),
|
||||
}));
|
||||
const bySku = res.drug_prices_by_sku || {};
|
||||
Object.keys(bySku).forEach((skuKey) => {
|
||||
drugPricesCache.value[Number(skuKey)] = (bySku[skuKey] || []).map((item: any) => ({
|
||||
drug_id: Number(item.drug_id),
|
||||
buy_price: Number(item.buy_price || 0),
|
||||
sell_price: Number(item.sell_price || item.price || 0),
|
||||
}));
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
switchAllocationView(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
watch(priceCalcScheme, (scheme) => {
|
||||
if (scheme === 1) {
|
||||
nextTick(() => switchAllocationView(activeSkuId.value));
|
||||
}
|
||||
});
|
||||
|
||||
/** 将编辑器当前分摊写入缓存 */
|
||||
function persistCurrentScopePrices() {
|
||||
const prices = chineseDrugEditorRef.value?.getDrugPricesPayload() || [];
|
||||
drugPricesCache.value[activeSkuId.value] = prices.map((item) => ({
|
||||
drug_id: Number(item.drug_id),
|
||||
buy_price: Number(item.buy_price || 0),
|
||||
sell_price: Number(item.sell_price || 0),
|
||||
/** 构建全 0 药味单价列表(-102 占位计价,不在 UI 配置分摊) */
|
||||
function buildZeroDrugPrices(): DrugPriceItem[] {
|
||||
return (prescriptionDetailCache.value || []).map((recipe) => ({
|
||||
drug_id: Number(recipe.drug_id || recipe.id),
|
||||
buy_price: 0,
|
||||
sell_price: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 切换单帖 / SKU 分摊视图 */
|
||||
function switchAllocationView(skuId: number) {
|
||||
persistCurrentScopePrices();
|
||||
activeSkuId.value = skuId;
|
||||
const prices = drugPricesCache.value[skuId] || drugPricesCache.value[0] || [];
|
||||
chineseDrugEditorRef.value?.loadDrugs(
|
||||
prescriptionDetailCache.value,
|
||||
prescriptionDosage.value,
|
||||
prescriptionDayDosage.value,
|
||||
prices,
|
||||
);
|
||||
}
|
||||
|
||||
function handleSkuSelectForPrice(skuId: number) {
|
||||
switchAllocationView(skuId);
|
||||
}
|
||||
|
||||
function backToDoseAllocation() {
|
||||
switchAllocationView(0);
|
||||
}
|
||||
|
||||
/** 自动均分:单帖用 per_dose,SKU 用套餐总价 */
|
||||
async function handleDistributePrices() {
|
||||
const drugs = chineseDrugEditorRef.value?.getDrugsForDistribute() || [];
|
||||
if (drugs.length === 0) {
|
||||
message.warning('请先配置特色方药品');
|
||||
return;
|
||||
}
|
||||
let buyPrice = buyPricePerDose.value;
|
||||
let salePrice = salePricePerDose.value;
|
||||
if (activeSkuId.value > 0) {
|
||||
const sku = skus.value.find((item) => Number(item.id) === Number(activeSkuId.value));
|
||||
if (!sku) {
|
||||
message.warning('请先保存 SKU 后再均分');
|
||||
return;
|
||||
}
|
||||
const dose = Math.max(1, Number(sku.dose_count || 1));
|
||||
buyPrice = Number(sku.buy_price || 0) / dose;
|
||||
salePrice = Number(sku.sale_price || 0) / dose;
|
||||
}
|
||||
try {
|
||||
const result = await distributeDrugPricesApi({
|
||||
drugs,
|
||||
buy_price_per_dose: buyPrice,
|
||||
sale_price_per_dose: salePrice,
|
||||
buy_price: buyPrice,
|
||||
sale_price: salePrice,
|
||||
});
|
||||
chineseDrugEditorRef.value?.applyDistributedPrices(result || []);
|
||||
persistCurrentScopePrices();
|
||||
message.success('已自动均分药品单价');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -221,62 +122,61 @@ async function handleDistributePrices() {
|
||||
<Radio :value="3">门店浮动</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<div v-if="showFixedPriceBlock" class="mt-4">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<div class="allocation-panel">
|
||||
<div class="allocation-header">
|
||||
<span class="allocation-title">{{ allocationTitle }}</span>
|
||||
<Button
|
||||
v-if="activeSkuId > 0"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="backToDoseAllocation"
|
||||
>
|
||||
返回单帖均摊
|
||||
</Button>
|
||||
</div>
|
||||
<ChineseDrugEditor
|
||||
ref="chineseDrugEditorRef"
|
||||
:price-editable="true"
|
||||
:structure-readonly="true"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<div class="section-title">固定单价</div>
|
||||
<div class="price-row">
|
||||
<div class="price-field">
|
||||
<span class="label">一贴供货价</span>
|
||||
<InputNumber
|
||||
v-model:value="buyPricePerDose"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="price-field">
|
||||
<span class="label">一贴销售价</span>
|
||||
<InputNumber
|
||||
v-model:value="salePricePerDose"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary" ghost @click="handleDistributePrices">
|
||||
自动均分药品单价
|
||||
</Button>
|
||||
</div>
|
||||
<div class="section-title mt-4">SKU 规格(点击行查看分摊)</div>
|
||||
<SkuEditor
|
||||
ref="skuEditorRef"
|
||||
v-model="skus"
|
||||
:selected-price-sku-id="activeSkuId"
|
||||
@select-for-price="handleSkuSelectForPrice"
|
||||
<div class="mt-4 shipping-config">
|
||||
<div class="section-title">包邮配置</div>
|
||||
<template v-if="hasSkus">
|
||||
<div class="shipping-tip">
|
||||
已配置 SKU,下单选用 SKU 时以各 SKU「是否包邮」为准(SKU 优先于特色方配置)
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="shipping-row">
|
||||
<span class="label">是否包邮</span>
|
||||
<RadioGroup v-model:value="isFreeShipping">
|
||||
<Radio :value="0">不包邮</Radio>
|
||||
<Radio :value="1">包邮</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div v-if="isFreeShipping === 1" class="shipping-row mt-2">
|
||||
<span class="label">包邮条件(剂数)</span>
|
||||
<InputNumber
|
||||
v-model:value="freeShippingMinDoses"
|
||||
:min="0"
|
||||
:precision="0"
|
||||
placeholder="0 表示始终包邮"
|
||||
style="width: 160px"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<span class="shipping-hint">大于该剂数才包邮,0 表示始终包邮</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showFixedPriceBlock" class="mt-4 fixed-price-block">
|
||||
<div class="section-title">单帖价格</div>
|
||||
<div class="price-row">
|
||||
<div class="price-field">
|
||||
<span class="label">一贴供货价</span>
|
||||
<InputNumber
|
||||
v-model:value="buyPricePerDose"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="price-field">
|
||||
<span class="label">一贴销售价</span>
|
||||
<InputNumber
|
||||
v-model:value="salePricePerDose"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed-price-tip">
|
||||
未选 SKU 时按「单帖价 × 贴数」计价;已选 SKU 时使用下方 SKU 套餐总价。药味单价固定 ¥0,下单时由系统写入套餐占位行。
|
||||
</div>
|
||||
<SkuEditor ref="skuEditorRef" v-model="skus" />
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 store-float-tip">
|
||||
@@ -292,26 +192,11 @@ async function handleDistributePrices() {
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.allocation-panel {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.allocation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.allocation-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
.fixed-price-block {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
@@ -328,12 +213,45 @@ async function handleDistributePrices() {
|
||||
.price-field .label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #666;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fixed-price-tip {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.store-float-tip {
|
||||
color: #666;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.shipping-config {
|
||||
padding: 12px;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 8px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.shipping-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shipping-row .label {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 13px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.shipping-tip,
|
||||
.shipping-hint {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Select,
|
||||
SelectOption,
|
||||
Table,
|
||||
@@ -20,6 +21,8 @@ export interface SkuItem {
|
||||
buy_price?: number;
|
||||
sale_price?: number;
|
||||
status: number;
|
||||
/** 是否包邮:0否 1是 */
|
||||
is_free_shipping?: number;
|
||||
is_default?: number;
|
||||
sort: number;
|
||||
store_id?: number;
|
||||
@@ -28,19 +31,14 @@ export interface SkuItem {
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: SkuItem[];
|
||||
/** 当前查看分摊的 SKU(0=单帖,在 PriceConfigModal 中由父组件控制) */
|
||||
selectedPriceSkuId?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: () => [],
|
||||
selectedPriceSkuId: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: SkuItem[]];
|
||||
/** 点击行切换左侧分摊视图 */
|
||||
'select-for-price': [skuId: number];
|
||||
}>();
|
||||
|
||||
const skuList = ref<SkuItem[]>([]);
|
||||
@@ -52,6 +50,7 @@ const columns = [
|
||||
{ title: '供货总价', key: 'buy_price', width: 110 },
|
||||
{ title: '销售总价', key: 'sale_price', width: 110 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '是否包邮', key: 'is_free_shipping', width: 130 },
|
||||
{ title: '排序', key: 'sort', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 70 },
|
||||
];
|
||||
@@ -65,6 +64,7 @@ watch(
|
||||
sort: item.sort ?? index,
|
||||
store_id: item.store_id ?? 0,
|
||||
status: item.status ?? 1,
|
||||
is_free_shipping: item.is_free_shipping ?? 0,
|
||||
is_default: item.is_default ?? 0,
|
||||
buy_price: item.buy_price ?? 0,
|
||||
sale_price: item.sale_price ?? 0,
|
||||
@@ -85,6 +85,7 @@ function addSku() {
|
||||
buy_price: 0,
|
||||
sale_price: 0,
|
||||
status: 1,
|
||||
is_free_shipping: 0,
|
||||
is_default: skuList.value.length === 0 ? 1 : 0,
|
||||
sort: skuList.value.length,
|
||||
store_id: 0,
|
||||
@@ -93,17 +94,12 @@ function addSku() {
|
||||
}
|
||||
|
||||
function removeSku(index: number) {
|
||||
const removed = skuList.value[index];
|
||||
const removedDefault = removed?.is_default === 1;
|
||||
const removedId = Number(removed?.id || 0);
|
||||
const removedDefault = skuList.value[index]?.is_default === 1;
|
||||
skuList.value.splice(index, 1);
|
||||
if (removedDefault && skuList.value.length > 0) {
|
||||
skuList.value[0].is_default = 1;
|
||||
}
|
||||
syncToParent();
|
||||
if (removedId > 0 && props.selectedPriceSkuId === removedId) {
|
||||
emit('select-for-price', 0);
|
||||
}
|
||||
}
|
||||
|
||||
function setDefault(index: number) {
|
||||
@@ -117,25 +113,6 @@ function onFieldChange() {
|
||||
syncToParent();
|
||||
}
|
||||
|
||||
/** 点击行查看该 SKU 分摊(需已保存有 id) */
|
||||
function onRowClick(record: SkuItem) {
|
||||
const skuId = Number(record.id || 0);
|
||||
if (skuId <= 0) {
|
||||
return;
|
||||
}
|
||||
emit('select-for-price', skuId);
|
||||
}
|
||||
|
||||
function customRow(record: SkuItem) {
|
||||
const skuId = Number(record.id || 0);
|
||||
return {
|
||||
class: skuId > 0 && skuId === Number(props.selectedPriceSkuId)
|
||||
? 'sku-row--price-active'
|
||||
: '',
|
||||
onClick: () => onRowClick(record),
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getSkusPayload: () => skuList.value,
|
||||
setSkus: (skus: SkuItem[]) => {
|
||||
@@ -145,6 +122,7 @@ defineExpose({
|
||||
sort: item.sort ?? index,
|
||||
store_id: item.store_id ?? 0,
|
||||
is_default: item.is_default ?? 0,
|
||||
is_free_shipping: item.is_free_shipping ?? 0,
|
||||
buy_price: item.buy_price ?? 0,
|
||||
sale_price: item.sale_price ?? 0,
|
||||
}));
|
||||
@@ -166,7 +144,6 @@ defineExpose({
|
||||
:columns="columns"
|
||||
:data-source="skuList"
|
||||
:pagination="false"
|
||||
:custom-row="customRow"
|
||||
row-key="_key"
|
||||
size="small"
|
||||
bordered
|
||||
@@ -183,7 +160,6 @@ defineExpose({
|
||||
<Input
|
||||
v-model:value="record.sku_name"
|
||||
placeholder="如 7贴"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -193,7 +169,6 @@ defineExpose({
|
||||
:min="1"
|
||||
:max="99"
|
||||
style="width: 100%"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -203,7 +178,6 @@ defineExpose({
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -213,7 +187,6 @@ defineExpose({
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -221,34 +194,38 @@ defineExpose({
|
||||
<Select
|
||||
v-model:value="record.status"
|
||||
style="width: 100%"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
>
|
||||
<SelectOption :value="1">上架</SelectOption>
|
||||
<SelectOption :value="0">下架</SelectOption>
|
||||
</Select>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'is_free_shipping'">
|
||||
<RadioGroup
|
||||
v-model:value="record.is_free_shipping"
|
||||
@change="onFieldChange"
|
||||
>
|
||||
<Radio :value="0">不包邮</Radio>
|
||||
<Radio :value="1">包邮</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'sort'">
|
||||
<InputNumber
|
||||
v-model:value="record.sort"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
@click.stop
|
||||
@change="onFieldChange"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" danger size="small" @click.stop="removeSku(index)">
|
||||
<Button type="link" danger size="small" @click="removeSku(index)">
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div v-if="skuList.length === 0" class="sku-empty-tip">
|
||||
暂无 SKU,用户端将使用手动输入贴数
|
||||
</div>
|
||||
<div v-else class="sku-hint">
|
||||
点击已保存的 SKU 行可在左侧查看/编辑该规格分摊价
|
||||
暂无 SKU,用户端将使用手动输入贴数,并按单帖价 × 贴数计价
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -260,19 +237,9 @@ defineExpose({
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sku-empty-tip,
|
||||
.sku-hint {
|
||||
.sku-empty-tip {
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.sku-row--price-active) {
|
||||
background-color: #e6f4ff !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.sku-table .ant-table-row) {
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -106,20 +106,6 @@ export const modalFormProps: VbenFormProps = {
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
placeholder: '是否包邮',
|
||||
options: [
|
||||
{ label: '不包邮', value: 0 },
|
||||
{ label: '包邮', value: 1 },
|
||||
],
|
||||
},
|
||||
fieldName: 'is_free_shipping',
|
||||
label: '是否包邮',
|
||||
formItemClass: 'col-span-6',
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
componentProps: {
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
|
||||
import { getSpecialPrescriptionList } from '../api';
|
||||
import {
|
||||
flattenSpecialPrescriptionList,
|
||||
type SpecialPrescriptionListRow,
|
||||
} from '../utils/flattenList';
|
||||
|
||||
interface RowType {
|
||||
id: number;
|
||||
name: string;
|
||||
category_id: number;
|
||||
category_name: string;
|
||||
cover_image: string;
|
||||
tags: string[];
|
||||
price_per_dose: number;
|
||||
sales_count: number;
|
||||
sort: number;
|
||||
status: number;
|
||||
status_txt: string;
|
||||
prescription_sub_type: number;
|
||||
created_at: string;
|
||||
}
|
||||
interface RowType extends SpecialPrescriptionListRow {}
|
||||
|
||||
const prescriptionTypeMap: Record<number, string> = {
|
||||
1: '中药',
|
||||
@@ -28,19 +18,28 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
checkboxConfig: {
|
||||
highlight: true,
|
||||
labelField: '',
|
||||
/** SKU 子行不可批量勾选 */
|
||||
checkMethod: ({ row }) => row.rowType === 'sp',
|
||||
},
|
||||
columnConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
keyField: '_rowKey',
|
||||
isHover: true,
|
||||
isCurrent: true,
|
||||
},
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||
{ field: 'name', align: 'left', title: '名称', minWidth: 160 },
|
||||
{
|
||||
field: 'name',
|
||||
align: 'left',
|
||||
title: '名称',
|
||||
minWidth: 200,
|
||||
slots: { default: 'name' },
|
||||
},
|
||||
{ field: 'category_name', align: 'left', title: '分类', width: 120 },
|
||||
{
|
||||
field: 'cover_image',
|
||||
@@ -73,19 +72,32 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
width: 90,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'is_free_shipping',
|
||||
align: 'left',
|
||||
title: '包邮',
|
||||
width: 120,
|
||||
slots: { default: 'free_shipping' },
|
||||
},
|
||||
{ field: 'created_at', title: '创建时间', width: 180 },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 220 },
|
||||
{ type: 'html', title: '操作', slots: { default: 'action' }, fixed: 'right', width: 220 },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getSpecialPrescriptionList({
|
||||
const res = await getSpecialPrescriptionList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
});
|
||||
const items = res?.items || res?.data?.items || [];
|
||||
const total = res?.total ?? res?.data?.total ?? items.length;
|
||||
return {
|
||||
items: flattenSpecialPrescriptionList(items),
|
||||
total,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,16 +12,26 @@ import { TableAction } from '#/components/table-action';
|
||||
|
||||
import {
|
||||
deleteSpecialPrescription,
|
||||
updateSpecialPrescriptionFreeShipping,
|
||||
updateSpecialPrescriptionSkuFreeShipping,
|
||||
updateSpecialPrescriptionSkuStatus,
|
||||
updateSpecialPrescriptionStatus,
|
||||
} from './api';
|
||||
import EditPrescriptionModal from './components/EditPrescriptionModal.vue';
|
||||
import PriceConfigModal from './components/PriceConfigModal.vue';
|
||||
import FreeShippingDoseModal from './components/FreeShippingDoseModal.vue';
|
||||
import SpecialPrescriptionModal from './components/modal.vue';
|
||||
import PriceConfigModal from './components/PriceConfigModal.vue';
|
||||
import { formOptions as searchFormOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import {
|
||||
formatFreeShippingThreshold,
|
||||
formatListRowName,
|
||||
type SpecialPrescriptionListRow,
|
||||
} from './utils/flattenList';
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
const statusLoadingId = ref<number | null>(null);
|
||||
const statusLoadingKey = ref<null | string>(null);
|
||||
const shippingLoadingKey = ref<null | string>(null);
|
||||
|
||||
const gridEvents: VxeGridListeners<any> = {
|
||||
checkboxChange() {
|
||||
@@ -52,6 +62,10 @@ const [PriceConfigModalComp, priceConfigModalApi] = useVbenModal({
|
||||
connectedComponent: PriceConfigModal,
|
||||
});
|
||||
|
||||
const [FreeShippingDoseModalComp, freeShippingDoseModalApi] = useVbenModal({
|
||||
connectedComponent: FreeShippingDoseModal,
|
||||
});
|
||||
|
||||
const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.setData({
|
||||
values: data,
|
||||
@@ -61,7 +75,7 @@ const showModal = (data = {}, isUpdate = false) => {
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
const showEditPrescriptionModal = (row: any) => {
|
||||
const showEditPrescriptionModal = (row: SpecialPrescriptionListRow) => {
|
||||
editPrescriptionModalApi.setData({
|
||||
values: row,
|
||||
gridApi,
|
||||
@@ -69,7 +83,7 @@ const showEditPrescriptionModal = (row: any) => {
|
||||
editPrescriptionModalApi.open();
|
||||
};
|
||||
|
||||
const showPriceConfigModal = (row: any) => {
|
||||
const showPriceConfigModal = (row: SpecialPrescriptionListRow) => {
|
||||
priceConfigModalApi.setData({
|
||||
values: row,
|
||||
gridApi,
|
||||
@@ -77,26 +91,94 @@ const showPriceConfigModal = (row: any) => {
|
||||
priceConfigModalApi.open();
|
||||
};
|
||||
|
||||
/** 点击状态 Tag 切换上下架 */
|
||||
const toggleStatus = async (row: any) => {
|
||||
if (statusLoadingId.value === row.id) {
|
||||
/** 打开包邮条件弹窗(无 SKU 特色方专用) */
|
||||
const openFreeShippingDoseModal = (
|
||||
row: SpecialPrescriptionListRow,
|
||||
modalMode: 'edit' | 'enable',
|
||||
) => {
|
||||
freeShippingDoseModalApi.setData({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
free_shipping_min_doses: row.free_shipping_min_doses ?? 0,
|
||||
mode: modalMode,
|
||||
gridApi,
|
||||
});
|
||||
freeShippingDoseModalApi.open();
|
||||
};
|
||||
|
||||
/** 是否为无 SKU 的特色方主行 */
|
||||
const isSpWithoutSku = (row: SpecialPrescriptionListRow) =>
|
||||
row.rowType === 'sp' && !row.has_sku;
|
||||
|
||||
/** 点击状态 Tag 切换上下架(特色方 / SKU) */
|
||||
const toggleStatus = async (row: SpecialPrescriptionListRow) => {
|
||||
const loadingKey =
|
||||
row.rowType === 'sku' ? `sku-status-${row.sku_id}` : `sp-status-${row.id}`;
|
||||
if (statusLoadingKey.value === loadingKey) {
|
||||
return;
|
||||
}
|
||||
const newStatus = row.status === 1 ? 0 : 1;
|
||||
statusLoadingId.value = row.id;
|
||||
statusLoadingKey.value = loadingKey;
|
||||
try {
|
||||
await updateSpecialPrescriptionStatus({ id: row.id, status: newStatus });
|
||||
await (row.rowType === 'sku' && row.sku_id
|
||||
? updateSpecialPrescriptionSkuStatus({
|
||||
sku_id: row.sku_id,
|
||||
status: newStatus,
|
||||
})
|
||||
: updateSpecialPrescriptionStatus({ id: row.id, status: newStatus }));
|
||||
message.success(newStatus === 1 ? '已上架' : '已下架');
|
||||
gridApi.query();
|
||||
} finally {
|
||||
statusLoadingId.value = null;
|
||||
statusLoadingKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApi = (row: any) => {
|
||||
let ids: (string | number)[] = [];
|
||||
/** 点击包邮 Tag 切换(特色方无 SKU 开启包邮弹窗 / SKU 子行直接切换) */
|
||||
const toggleFreeShipping = async (row: SpecialPrescriptionListRow) => {
|
||||
if (row.rowType === 'sp' && row.has_sku) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 无 SKU 特色方:不包邮 → 包邮时打开弹窗设置剂数
|
||||
if (isSpWithoutSku(row) && row.is_free_shipping !== 1) {
|
||||
openFreeShippingDoseModal(row, 'enable');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingKey =
|
||||
row.rowType === 'sku' ? `sku-ship-${row.sku_id}` : `sp-ship-${row.id}`;
|
||||
if (shippingLoadingKey.value === loadingKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
shippingLoadingKey.value = loadingKey;
|
||||
try {
|
||||
if (row.rowType === 'sku' && row.sku_id) {
|
||||
const newVal = row.is_free_shipping === 1 ? 0 : 1;
|
||||
await updateSpecialPrescriptionSkuFreeShipping({
|
||||
sku_id: row.sku_id,
|
||||
is_free_shipping: newVal,
|
||||
});
|
||||
message.success(newVal === 1 ? '已设为包邮' : '已设为不包邮');
|
||||
} else {
|
||||
// 无 SKU 特色方:包邮 → 不包邮,直接关闭
|
||||
await updateSpecialPrescriptionFreeShipping({
|
||||
id: row.id,
|
||||
is_free_shipping: 0,
|
||||
free_shipping_min_doses: row.free_shipping_min_doses ?? 0,
|
||||
});
|
||||
message.success('已设为不包邮');
|
||||
}
|
||||
gridApi.query();
|
||||
} finally {
|
||||
shippingLoadingKey.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteApi = (row?: any) => {
|
||||
let ids: (number | string)[] = [];
|
||||
if (row) {
|
||||
ids.push(row);
|
||||
ids.push(typeof row === 'object' ? row.id : row);
|
||||
} else {
|
||||
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
|
||||
}
|
||||
@@ -112,6 +194,7 @@ const deleteApi = (row: any) => {
|
||||
<FormModal />
|
||||
<EditPrescriptionModalComp />
|
||||
<PriceConfigModalComp />
|
||||
<FreeShippingDoseModalComp />
|
||||
|
||||
<div class="p-4">
|
||||
<TableAction
|
||||
@@ -126,20 +209,27 @@ const deleteApi = (row: any) => {
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<template #name="{ row }">
|
||||
<span :class="{ 'sku-row-name': row.rowType === 'sku' }">
|
||||
{{ formatListRowName(row) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cover="{ row }">
|
||||
<Image
|
||||
v-if="row.cover_image"
|
||||
:width="60"
|
||||
v-if="row.rowType === 'sp' && row.cover_image"
|
||||
:height="60"
|
||||
:src="row.cover_image"
|
||||
:fallback="'/static/mine/avatar_1.png'"
|
||||
:width="60"
|
||||
fallback="/static/mine/avatar_1.png"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #tags="{ row }">
|
||||
<Tag v-for="tag in row.tags || []" :key="tag" class="mb-1">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
<template v-if="row.rowType === 'sp'">
|
||||
<Tag v-for="tag in row.tags || []" :key="tag" class="mb-1">
|
||||
{{ tag }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
@@ -152,6 +242,45 @@ const deleteApi = (row: any) => {
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<template #free_shipping="{ row }">
|
||||
<template v-if="row.rowType === 'sp' && row.has_sku">
|
||||
<Tag color="default">见 SKU</Tag>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="isSpWithoutSku(row) && row.is_free_shipping === 1"
|
||||
>
|
||||
<div class="free-shipping-cell">
|
||||
<Tag
|
||||
class="cursor-pointer select-none"
|
||||
color="success"
|
||||
@click="toggleFreeShipping(row)"
|
||||
>
|
||||
包邮
|
||||
</Tag>
|
||||
<span class="shipping-threshold-text">
|
||||
{{ formatFreeShippingThreshold(row.free_shipping_min_doses) }}
|
||||
</span>
|
||||
<Button
|
||||
class="shipping-edit-link"
|
||||
size="small"
|
||||
type="link"
|
||||
@click.stop="openFreeShippingDoseModal(row, 'edit')"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Tag
|
||||
:color="row.is_free_shipping === 1 ? 'success' : 'default'"
|
||||
class="cursor-pointer select-none"
|
||||
@click="toggleFreeShipping(row)"
|
||||
>
|
||||
{{ row.is_free_shipping === 1 ? '包邮' : '不包邮' }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #toolbar-buttons>
|
||||
<Button
|
||||
v-if="hasTopTableDropDownActions"
|
||||
@@ -165,6 +294,7 @@ const deleteApi = (row: any) => {
|
||||
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
v-if="row.rowType === 'sp'"
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
@@ -194,3 +324,28 @@ const deleteApi = (row: any) => {
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sku-row-name {
|
||||
padding-left: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.free-shipping-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shipping-threshold-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.shipping-edit-link {
|
||||
padding: 0 4px;
|
||||
height: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/** 列表行类型:特色方主行或 SKU 子行 */
|
||||
export interface SpecialPrescriptionListRow {
|
||||
rowType: 'sp' | 'sku';
|
||||
_rowKey: string;
|
||||
id: number;
|
||||
name: string;
|
||||
category_id?: number;
|
||||
category_name?: string;
|
||||
cover_image?: string;
|
||||
tags?: string[];
|
||||
price_per_dose?: number;
|
||||
sales_count?: number;
|
||||
sort?: number;
|
||||
status: number;
|
||||
is_free_shipping?: number;
|
||||
free_shipping_min_doses?: number;
|
||||
has_sku?: boolean;
|
||||
prescription_sub_type?: number;
|
||||
created_at?: string;
|
||||
/** SKU 子行专用 */
|
||||
sku_id?: number;
|
||||
sku_name?: string;
|
||||
dose_count?: number;
|
||||
sp_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将特色方列表扁平化为「主行 + SKU 子行」,供表格一行一个 SKU 展示
|
||||
*/
|
||||
export function flattenSpecialPrescriptionList(
|
||||
items: Record<string, any>[],
|
||||
): SpecialPrescriptionListRow[] {
|
||||
const rows: SpecialPrescriptionListRow[] = [];
|
||||
for (const sp of items) {
|
||||
rows.push({
|
||||
...sp,
|
||||
rowType: 'sp',
|
||||
_rowKey: `sp-${sp.id}`,
|
||||
status: sp.status ?? 0,
|
||||
is_free_shipping: sp.is_free_shipping ?? 0,
|
||||
free_shipping_min_doses: sp.free_shipping_min_doses ?? 0,
|
||||
has_sku: sp.has_sku ?? (sp.skus?.length > 0),
|
||||
});
|
||||
const skus = sp.skus || [];
|
||||
for (const sku of skus) {
|
||||
rows.push({
|
||||
rowType: 'sku',
|
||||
_rowKey: `sku-${sku.id}`,
|
||||
id: sp.id,
|
||||
sp_id: sp.id,
|
||||
sku_id: sku.id,
|
||||
sku_name: sku.sku_name,
|
||||
dose_count: sku.dose_count,
|
||||
name: sku.sku_name || `${sku.dose_count}贴`,
|
||||
status: sku.status ?? 0,
|
||||
is_free_shipping: sku.is_free_shipping ?? 0,
|
||||
prescription_sub_type: sp.prescription_sub_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 列表名称列展示文案 */
|
||||
export function formatListRowName(row: SpecialPrescriptionListRow): string {
|
||||
if (row.rowType === 'sku') {
|
||||
const label = row.sku_name || `${row.dose_count || ''}贴`;
|
||||
return `└ ${label}(${row.dose_count || '-'}贴)`;
|
||||
}
|
||||
return row.name || '';
|
||||
}
|
||||
|
||||
/** 包邮条件展示文案:0=始终包邮,N>0=大于N剂包邮 */
|
||||
export function formatFreeShippingThreshold(minDoses?: number): string {
|
||||
const n = minDoses ?? 0;
|
||||
return n > 0 ? `>${n}剂包邮` : '始终包邮';
|
||||
}
|
||||
@@ -178,3 +178,22 @@ export async function getPlatformStoreOptionsApi(params: { keyword?: string }) {
|
||||
export async function platformSalespersonCreateApi(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`salesperson/platform-create`, data);
|
||||
}
|
||||
|
||||
/** 同门店推广员搜索(Picker) */
|
||||
export async function salespersonOptionsApi(params: Record<string, any>) {
|
||||
return requestClient.get<any[]>(`salesperson/salesperson-options`, { params });
|
||||
}
|
||||
|
||||
/** 下级推广员列表 */
|
||||
export async function invitedSalespersonListApi(params: Record<string, any>) {
|
||||
return requestClient.get<any>(`salesperson/invited-salesperson-list`, { params });
|
||||
}
|
||||
|
||||
/** 手动退回推广员分成(仅已退款/已取消订单) */
|
||||
export async function reverseCommissionApi(data: {
|
||||
order_id: number;
|
||||
store_id: number;
|
||||
salesperson_id: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`salesperson/reverse-commission`, data);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import QrCodePreview from '#/views/business/store/settings/components/Salesperso
|
||||
import SalespersonCommissionDrawer from '#/views/business/store/settings/components/SalespersonCommissionDrawer.vue';
|
||||
import SalespersonCreateModal from '#/views/business/store/settings/components/SalespersonCreateModal.vue';
|
||||
import SalespersonUserBindDrawer from '#/views/business/store/settings/components/SalespersonUserBindDrawer.vue';
|
||||
import SalespersonInvitedDrawer from '#/views/business/store/settings/components/SalespersonInvitedDrawer.vue';
|
||||
|
||||
const listData = ref<{ total: number; items: any[] }>({
|
||||
total: 0,
|
||||
@@ -43,6 +44,7 @@ const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({
|
||||
});
|
||||
|
||||
const commissionDrawerRef = ref<InstanceType<typeof SalespersonCommissionDrawer>>();
|
||||
const invitedDrawerRef = ref<InstanceType<typeof SalespersonInvitedDrawer>>();
|
||||
|
||||
function openCommissionDrawer(item: any) {
|
||||
commissionDrawerRef.value?.open({
|
||||
@@ -104,6 +106,7 @@ function tcmBaseLabel(type: number) {
|
||||
<SalespersonCreateModalComponent />
|
||||
<QrCodePreviewModal />
|
||||
<SalespersonCommissionDrawer ref="commissionDrawerRef" @settled="getList" />
|
||||
<SalespersonInvitedDrawer ref="invitedDrawerRef" />
|
||||
|
||||
<div v-if="!listData.items.length" class="py-12">
|
||||
<Empty description="暂无推广员">
|
||||
@@ -146,6 +149,9 @@ function tcmBaseLabel(type: number) {
|
||||
|
||||
<div class="mb-4 space-y-1.5 text-sm text-gray-600">
|
||||
<div>{{ item.phone }}</div>
|
||||
<div v-if="item.parent_nick_name" class="text-xs text-gray-500">
|
||||
邀请人:{{ item.parent_nick_name }}
|
||||
</div>
|
||||
<div>加入:{{ item.created_at }}</div>
|
||||
<Popover trigger="click" title="获客记录">
|
||||
<template #content>
|
||||
@@ -184,6 +190,9 @@ function tcmBaseLabel(type: number) {
|
||||
<Button size="small" type="primary" @click="openCommissionDrawer(item)">
|
||||
分成与结算
|
||||
</Button>
|
||||
<Button size="small" @click="invitedDrawerRef?.open(item)">
|
||||
下级推广员
|
||||
</Button>
|
||||
<Button size="small" @click="openEditModal(item)">编辑</Button>
|
||||
<Button
|
||||
v-if="Number(item.split_type) === 0"
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Image,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
getSettlementDetailApi,
|
||||
getSettlementListApi,
|
||||
getSettlementPreviewApi,
|
||||
reverseCommissionApi,
|
||||
} from '#/views/business/store/settings/api';
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -52,17 +55,19 @@ const periodRange = ref<any[]>([]);
|
||||
const settlementConfirmRef = ref<InstanceType<typeof SalespersonSettlementConfirmModal>>();
|
||||
|
||||
const orderCommissionColumns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '明细数', dataIndex: 'item_count', width: 80 },
|
||||
{ title: '分成总额', dataIndex: 'commission_amount', width: 100 },
|
||||
{ title: '支付时间', dataIndex: 'order_pay_at_text', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 90 },
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 150 },
|
||||
{ title: '订单状态', key: 'order_status', width: 90 },
|
||||
{ title: '明细数', dataIndex: 'item_count', width: 70 },
|
||||
{ title: '净分成', key: 'net_commission', width: 90 },
|
||||
{ title: '支付时间', dataIndex: 'order_pay_at_text', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
];
|
||||
|
||||
const commissionDetailColumns = [
|
||||
{ title: '类型', dataIndex: 'record_type_text', width: 90 },
|
||||
{ title: '药品信息', dataIndex: 'drug_info', ellipsis: true },
|
||||
{ title: '分成金额', dataIndex: 'commission_amount', width: 100 },
|
||||
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 170 },
|
||||
{ title: '分成金额', key: 'commission_amount', width: 100 },
|
||||
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 160 },
|
||||
];
|
||||
|
||||
const settlementLineColumns = [
|
||||
@@ -157,6 +162,38 @@ async function showOrderDetail(record: { order_id: number; order_no: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const reversingOrderIds = ref<number[]>([]);
|
||||
|
||||
/** 手动退回分成(仅已退款/已取消订单) */
|
||||
async function handleReverseCommission(record: { order_id: number }) {
|
||||
if (!salespersonId.value || reversingOrderIds.value.includes(record.order_id)) {
|
||||
return;
|
||||
}
|
||||
reversingOrderIds.value.push(record.order_id);
|
||||
try {
|
||||
await reverseCommissionApi({
|
||||
order_id: record.order_id,
|
||||
store_id: 0,
|
||||
salesperson_id: salespersonId.value,
|
||||
});
|
||||
message.success('退回分成成功');
|
||||
await loadCommission();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '退回分成失败');
|
||||
} finally {
|
||||
reversingOrderIds.value = reversingOrderIds.value.filter((id) => id !== record.order_id);
|
||||
}
|
||||
}
|
||||
|
||||
function orderStatusTagColor(text?: string) {
|
||||
if (text === '已退款' || text === '已取消') return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function isNegativeAmount(amount?: string | number) {
|
||||
return Number(amount ?? 0) < 0;
|
||||
}
|
||||
|
||||
async function loadSettlements() {
|
||||
if (!salespersonId.value) return;
|
||||
const res = await getSettlementListApi({
|
||||
@@ -264,10 +301,29 @@ defineExpose({ open });
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<template v-if="column.key === 'order_status'">
|
||||
<Tag :color="orderStatusTagColor(record.order_status_text)">
|
||||
{{ record.order_status_text || '—' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'net_commission'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.net_commission_amount) }">
|
||||
¥{{ record.net_commission_amount ?? record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="showOrderDetail(record)">
|
||||
查看明细
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="record.can_reverse_commission"
|
||||
title="确认退回该订单的推广员分成?"
|
||||
@confirm="handleReverseCommission(record)"
|
||||
>
|
||||
<Button type="link" size="small" danger :loading="reversingOrderIds.includes(record.order_id)">
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -287,10 +343,29 @@ defineExpose({ open });
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<template v-if="column.key === 'order_status'">
|
||||
<Tag :color="orderStatusTagColor(record.order_status_text)">
|
||||
{{ record.order_status_text || '—' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'net_commission'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.net_commission_amount) }">
|
||||
¥{{ record.net_commission_amount ?? record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="showOrderDetail(record)">
|
||||
查看明细
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="record.can_reverse_commission"
|
||||
title="确认退回该订单的推广员分成?"
|
||||
@confirm="handleReverseCommission(record)"
|
||||
>
|
||||
<Button type="link" size="small" danger :loading="reversingOrderIds.includes(record.order_id)">
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -331,7 +406,15 @@ defineExpose({ open });
|
||||
:loading="orderDetailLoading"
|
||||
:row-key="(r) => r.id"
|
||||
size="small"
|
||||
/>
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'commission_amount'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.commission_amount) }">
|
||||
¥{{ record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:open="detailVisible" title="结算单详情" width="720" destroy-on-close>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message, Select } from 'ant-design-vue';
|
||||
import { message, Select, InputNumber } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
saveDrugCommissionApi,
|
||||
} from '#/views/business/store/settings/api';
|
||||
import SalespersonDrugCommissionEditor from '#/views/business/store/settings/components/SalespersonDrugCommissionEditor.vue';
|
||||
import SalespersonPicker from '#/views/business/store/settings/components/SalespersonPicker.vue';
|
||||
|
||||
import { salespersonModalFormProps } from '../config/form';
|
||||
|
||||
@@ -26,6 +27,9 @@ const storeOptions = ref<{ label: string; value: number }[]>([]);
|
||||
const storeLoading = ref(false);
|
||||
let storeSearchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const editorRef = ref<InstanceType<typeof SalespersonDrugCommissionEditor>>();
|
||||
const parentSalespersonId = ref(0);
|
||||
const inviterSplit = ref<number | undefined>(undefined);
|
||||
const parentInitial = ref<{ avatar?: string; id?: number; nick_name?: string; phone?: string } | null>(null);
|
||||
|
||||
const isFixedAmount = computed(() => Number(splitType.value) === 0);
|
||||
const modalClass = computed(() =>
|
||||
@@ -85,9 +89,15 @@ const [Modal, modalApi] = useVbenModal({
|
||||
message.warning('请选择诊所');
|
||||
return;
|
||||
}
|
||||
if (parentSalespersonId.value > 0 && (!inviterSplit.value || inviterSplit.value <= 0)) {
|
||||
message.warning('请填写邀请分成比例');
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await formApi.getValues();
|
||||
const payload = { ...values };
|
||||
payload.parent_salesperson_id = parentSalespersonId.value || 0;
|
||||
payload.inviter_split = parentSalespersonId.value > 0 ? inviterSplit.value : 0;
|
||||
if (Number(payload.split_type) === 0) {
|
||||
payload.split = 0;
|
||||
}
|
||||
@@ -135,6 +145,8 @@ const [Modal, modalApi] = useVbenModal({
|
||||
editorRef.value?.resetDirty();
|
||||
storeId.value = undefined;
|
||||
storeOptions.value = [];
|
||||
parentSalespersonId.value = 0;
|
||||
inviterSplit.value = undefined;
|
||||
return;
|
||||
}
|
||||
const { values, update, mode } = modalApi.getData<Record<string, any>>() || {};
|
||||
@@ -144,10 +156,24 @@ const [Modal, modalApi] = useVbenModal({
|
||||
formApi.setValues(values);
|
||||
splitType.value = Number(values.split_type ?? 0);
|
||||
salespersonId.value = Number(values.id ?? 0);
|
||||
storeId.value = Number(values.qr_code?.store_id ?? storeId.value ?? 0) || storeId.value;
|
||||
parentSalespersonId.value = Number(values.parent_salesperson_id ?? 0);
|
||||
inviterSplit.value = Number(values.inviter_split ?? 0) || undefined;
|
||||
parentInitial.value =
|
||||
parentSalespersonId.value > 0
|
||||
? {
|
||||
id: parentSalespersonId.value,
|
||||
nick_name: values.parent_nick_name,
|
||||
phone: values.parent_phone,
|
||||
}
|
||||
: null;
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
splitType.value = 0;
|
||||
salespersonId.value = 0;
|
||||
parentSalespersonId.value = 0;
|
||||
inviterSplit.value = undefined;
|
||||
parentInitial.value = null;
|
||||
}
|
||||
if (isPlatformMode.value && !isUpdate.value) {
|
||||
fetchStoreOptions('');
|
||||
@@ -173,6 +199,26 @@ const [Modal, modalApi] = useVbenModal({
|
||||
/>
|
||||
</div>
|
||||
<Form />
|
||||
<div class="mb-4 mt-2">
|
||||
<div class="mb-2 text-sm font-medium">邀请人推广员(可选)</div>
|
||||
<SalespersonPicker
|
||||
v-model="parentSalespersonId"
|
||||
:exclude-id="salespersonId"
|
||||
:initial-selected="parentInitial"
|
||||
:store-id="storeId || 0"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="parentSalespersonId > 0" class="mb-4">
|
||||
<div class="mb-2 text-sm font-medium">邀请分成比例(%)</div>
|
||||
<InputNumber
|
||||
v-model:value="inviterSplit"
|
||||
:max="100"
|
||||
:min="0.01"
|
||||
:precision="2"
|
||||
class="w-full"
|
||||
placeholder="上级从该推广员订单利润中获得的比例"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="isFixedAmount && !isUpdate"
|
||||
class="mb-2 text-sm text-gray-500"
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Drawer, Table } from 'ant-design-vue';
|
||||
|
||||
import { invitedSalespersonListApi } from '#/views/business/store/settings/api';
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const salespersonId = ref(0);
|
||||
const nickName = ref('');
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'nick_name', key: 'nick_name', width: 120 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 120 },
|
||||
{ title: '邀请分成', dataIndex: 'inviter_split', key: 'inviter_split', width: 90 },
|
||||
{ title: '待结算', dataIndex: 'invite_pending_amount', key: 'pending', width: 100 },
|
||||
{ title: '已结算', dataIndex: 'invite_settled_amount', key: 'settled', width: 100 },
|
||||
{ title: '累计收益', dataIndex: 'invite_total_amount', key: 'total', width: 100 },
|
||||
{ title: '入驻时间', dataIndex: 'created_at_text', key: 'created_at_text', width: 160 },
|
||||
];
|
||||
|
||||
async function loadList() {
|
||||
if (salespersonId.value <= 0) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await invitedSalespersonListApi({
|
||||
salesperson_id: salespersonId.value,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
list.value = res?.items ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (val) => {
|
||||
if (val) loadList();
|
||||
});
|
||||
|
||||
function openDrawer(item: { id: number; nick_name?: string }) {
|
||||
salespersonId.value = item.id;
|
||||
nickName.value = item.nick_name || '';
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
defineExpose({ open: openDrawer });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
v-model:open="open"
|
||||
:title="`${nickName} · 下级推广员`"
|
||||
width="760"
|
||||
>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="list"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
/>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { Avatar, Drawer, Empty, Input, Spin } from 'ant-design-vue';
|
||||
|
||||
import { salespersonOptionsApi } from '#/views/business/store/settings/api';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
excludeId?: number;
|
||||
initialSelected?: { avatar?: string; id?: number; nick_name?: string; phone?: string } | null;
|
||||
modelValue?: number;
|
||||
storeId?: number;
|
||||
}>(),
|
||||
{
|
||||
modelValue: 0,
|
||||
excludeId: 0,
|
||||
storeId: 0,
|
||||
initialSelected: null,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number];
|
||||
change: [item: Record<string, any> | null];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const keyword = ref('');
|
||||
const options = ref<any[]>([]);
|
||||
const selectedItem = ref<any>(null);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const displayName = computed(
|
||||
() => selectedItem.value?.nick_name || '请选择邀请人推广员',
|
||||
);
|
||||
|
||||
async function fetchOptions(kw = '') {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
keyword: kw.trim(),
|
||||
exclude_id: props.excludeId,
|
||||
};
|
||||
if (props.storeId > 0) {
|
||||
params.store_id = props.storeId;
|
||||
}
|
||||
options.value = (await salespersonOptionsApi(params)) ?? [];
|
||||
if (props.modelValue > 0 && !selectedItem.value) {
|
||||
selectedItem.value =
|
||||
options.value.find((o) => Number(o.id) === Number(props.modelValue)) ??
|
||||
selectedItem.value;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => fetchOptions(keyword.value), 300);
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
open.value = true;
|
||||
keyword.value = '';
|
||||
fetchOptions('');
|
||||
}
|
||||
|
||||
function pick(item: any) {
|
||||
selectedItem.value = item;
|
||||
emit('update:modelValue', Number(item.id));
|
||||
emit('change', item);
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function clear() {
|
||||
selectedItem.value = null;
|
||||
emit('update:modelValue', 0);
|
||||
emit('change', null);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val <= 0) {
|
||||
selectedItem.value = null;
|
||||
} else if (props.initialSelected && Number(props.initialSelected.id) === Number(val)) {
|
||||
selectedItem.value = props.initialSelected;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 px-3 py-2 hover:border-blue-400"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<Avatar v-if="selectedItem?.avatar" :src="selectedItem.avatar" :size="36" />
|
||||
<Avatar v-else :size="36">{{ (selectedItem?.nick_name || '?').charAt(0) }}</Avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-medium">{{ displayName }}</div>
|
||||
<div v-if="selectedItem?.phone" class="text-xs text-gray-500">
|
||||
{{ selectedItem.phone }}
|
||||
</div>
|
||||
</div>
|
||||
<a class="text-xs text-gray-400" @click.stop="clear">清空</a>
|
||||
</div>
|
||||
|
||||
<Drawer v-model:open="open" title="选择邀请人推广员" width="420">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="mb-4"
|
||||
placeholder="搜索姓名、手机号"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
<Spin :spinning="loading">
|
||||
<Empty v-if="!loading && !options.length" description="无匹配推广员" />
|
||||
<div
|
||||
v-for="item in options"
|
||||
:key="item.id"
|
||||
class="mb-2 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-100 px-3 py-3 hover:bg-gray-50"
|
||||
@click="pick(item)"
|
||||
>
|
||||
<Avatar v-if="item.avatar" :src="item.avatar" :size="40" />
|
||||
<Avatar v-else :size="40">{{ (item.nick_name || '?').charAt(0) }}</Avatar>
|
||||
<div>
|
||||
<div class="font-medium">{{ item.nick_name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ item.phone }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -64,7 +64,7 @@ export const salespersonModalFormProps: VbenFormProps = {
|
||||
{
|
||||
component: 'RadioGroup',
|
||||
fieldName: 'split_type',
|
||||
label: '分成类型',
|
||||
label: '西药分成类型',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
placeholder: '请选择',
|
||||
@@ -79,12 +79,12 @@ export const salespersonModalFormProps: VbenFormProps = {
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultValue: 0,
|
||||
defaultValue: 1,
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'split',
|
||||
label: '分成比例(%)',
|
||||
label: '西药分成比例(%)',
|
||||
componentProps: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, watch } from 'vue';
|
||||
import { Button, Drawer, Empty, List, message, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getSalespersonTransferByRegisterApi } from '#/views/business/salesperson-transfer-prescription/api';
|
||||
import { parseSalespersonTransferDrugList } from '#/views/doctor/doctor-reception/utils/parseSalespersonTransferDrugList';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
@@ -19,23 +20,7 @@ const loading = ref(false);
|
||||
const list = ref<any[]>([]);
|
||||
|
||||
function parseDrugList(item: any) {
|
||||
if (item.drug_list?.length) return item.drug_list;
|
||||
if (item.prescription?.content?.length) return item.prescription.content;
|
||||
const content = item.content;
|
||||
if (!content?.repice) return [];
|
||||
const drugs: any[] = [];
|
||||
for (const recipe of content.repice) {
|
||||
let rc = recipe.content;
|
||||
if (typeof rc === 'string') {
|
||||
try {
|
||||
rc = JSON.parse(rc);
|
||||
} catch {
|
||||
rc = [];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rc)) drugs.push(...rc);
|
||||
}
|
||||
return drugs;
|
||||
return parseSalespersonTransferDrugList(item);
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 接诊页与传方悬浮窗之间的导入桥接
|
||||
* 悬浮窗调用 invokeDoctorTransferImport,接诊页 onMounted 时注册 handler
|
||||
*/
|
||||
|
||||
export type DoctorTransferImportPayload = {
|
||||
transferId: number;
|
||||
registerId: number;
|
||||
};
|
||||
|
||||
export type DoctorTransferImportHandler = (
|
||||
payload: DoctorTransferImportPayload,
|
||||
) => void | Promise<void>;
|
||||
|
||||
let importHandler: DoctorTransferImportHandler | null = null;
|
||||
|
||||
/** 接诊页挂载时注册导入处理器 */
|
||||
export function registerDoctorTransferImportHandler(
|
||||
fn: DoctorTransferImportHandler,
|
||||
) {
|
||||
importHandler = fn;
|
||||
}
|
||||
|
||||
/** 接诊页卸载时注销,避免误导入到其他页面 */
|
||||
export function unregisterDoctorTransferImportHandler() {
|
||||
importHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 悬浮窗触发导入:返回 false 表示接诊页未就绪
|
||||
*/
|
||||
export function invokeDoctorTransferImport(
|
||||
payload: DoctorTransferImportPayload,
|
||||
): boolean {
|
||||
if (!importHandler) {
|
||||
return false;
|
||||
}
|
||||
void importHandler(payload);
|
||||
return true;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, ref, watch} from 'vue';
|
||||
import {computed, onBeforeUnmount, onMounted, ref, watch} from 'vue';
|
||||
|
||||
import {Page, useVbenDrawer, useVbenModal} from '@vben/common-ui';
|
||||
import {useUserStore} from '@vben/stores';
|
||||
@@ -77,7 +77,12 @@ import {
|
||||
import TransferPrescriptionCard from '#/views/doctor/online-consultation/components/TransferPrescriptionCard.vue';
|
||||
import SalespersonTransferDrawer from '#/views/doctor/doctor-reception/components/SalespersonTransferDrawer.vue';
|
||||
import { getTransferPrescriptionByRegisterApi } from '#/views/business/chat/api/transferPrescription';
|
||||
import { getSalespersonTransferByRegisterApi } from '#/views/business/salesperson-transfer-prescription/api';
|
||||
import { getSalespersonTransferByRegisterApi, prepareDoctorImportApi } from '#/views/business/salesperson-transfer-prescription/api';
|
||||
import {
|
||||
registerDoctorTransferImportHandler,
|
||||
unregisterDoctorTransferImportHandler,
|
||||
} from '#/views/doctor/doctor-reception/composables/useDoctorReceptionTransferImport';
|
||||
import { parseSalespersonTransferDrugList } from '#/views/doctor/doctor-reception/utils/parseSalespersonTransferDrugList';
|
||||
import {
|
||||
calcItemMarginPercent,
|
||||
calcTotalMarginPercent,
|
||||
@@ -240,12 +245,21 @@ function handleSalespersonTransferImport(payload: {
|
||||
clinical_diagnose: string;
|
||||
doctor_order: string;
|
||||
drugList: any[];
|
||||
dosage?: number;
|
||||
dayDosage?: number;
|
||||
}) {
|
||||
salespersonTransferPrescriptionId.value = payload.transferId;
|
||||
diagnosis.value = payload.clinical_diagnose || '';
|
||||
medicalAdvice.value = payload.doctor_order || '';
|
||||
activeCategory.value = 1;
|
||||
tabType.value = 2;
|
||||
if (payload.dosage != null) {
|
||||
dosage.value = Number(payload.dosage) || 7;
|
||||
}
|
||||
if (payload.dayDosage != null) {
|
||||
dayDosage.value = Number(payload.dayDosage) || 2;
|
||||
}
|
||||
packageMethodId.value = 2;
|
||||
currentDrugs.value = payload.drugList.map((drug: any) => ({
|
||||
index_id: drug.index_id ?? drug.drug_id ?? drug.id,
|
||||
id: drug.drug_id || drug.id,
|
||||
@@ -261,6 +275,40 @@ function handleSalespersonTransferImport(payload: {
|
||||
message.success('传方已导入,请确认后发送处方');
|
||||
}
|
||||
|
||||
/** 悬浮窗触发导入:拉取补价后的传方数据并写入当前患者 */
|
||||
async function handleFloatTransferImport(payload: {
|
||||
transferId: number;
|
||||
registerId: number;
|
||||
}) {
|
||||
try {
|
||||
const item = await prepareDoctorImportApi(payload.transferId, payload.registerId);
|
||||
const drugList = parseSalespersonTransferDrugList(item);
|
||||
if (!drugList.length) {
|
||||
message.warning('传方药品为空');
|
||||
return;
|
||||
}
|
||||
const content = item.content ?? {};
|
||||
handleSalespersonTransferImport({
|
||||
transferId: payload.transferId,
|
||||
clinical_diagnose: item.clinical_diagnose || content.clinical_diagnose || '',
|
||||
doctor_order: item.doctor_order || content.doctor_order || '',
|
||||
drugList,
|
||||
dosage: content.dosage ?? item.dosage,
|
||||
dayDosage: content.day_dosage ?? item.day_dosage,
|
||||
});
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
registerDoctorTransferImportHandler(handleFloatTransferImport);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unregisterDoctorTransferImportHandler();
|
||||
});
|
||||
|
||||
watch(myStoreId, () => {
|
||||
fetchStoreSeeRate();
|
||||
});
|
||||
@@ -569,20 +617,22 @@ const selectPatient = (patient: Patient, isUpdateTabType = true) => {
|
||||
}
|
||||
activePatient.value = patient.user_patient;
|
||||
|
||||
// 恢复之前保存的 activeCategory
|
||||
const savedCategory = localStorage.getItem(`activeCategory${patient.user_patient?.id}`);
|
||||
if (savedCategory) {
|
||||
activeCategory.value = Number.parseInt(savedCategory);
|
||||
} else {
|
||||
// 如果没有保存的值,检查两个存储键哪个有数据
|
||||
const chineseData = localStorage.getItem(`prescriptionData_1_${patient.user_patient?.id}`);
|
||||
const westData = localStorage.getItem(`prescriptionData_2_${patient.user_patient?.id}`);
|
||||
if (chineseData && JSON.parse(chineseData).length > 0) {
|
||||
activeCategory.value = 1;
|
||||
} else if (westData && JSON.parse(westData).length > 0) {
|
||||
activeCategory.value = 2;
|
||||
const registerId = patient.id;
|
||||
// 优先恢复完整草稿(诊断、医嘱、诊疗费、中药配置等)
|
||||
const hasDraft = restoreReceptionDraft(registerId);
|
||||
if (!hasDraft) {
|
||||
const savedCategory = localStorage.getItem(`activeCategory${registerId}`);
|
||||
if (savedCategory) {
|
||||
activeCategory.value = Number.parseInt(savedCategory);
|
||||
} else {
|
||||
const chineseData = localStorage.getItem(getStorageKey(registerId, 1));
|
||||
const westData = localStorage.getItem(getStorageKey(registerId, 2));
|
||||
if (chineseData && JSON.parse(chineseData).length > 0) {
|
||||
activeCategory.value = 1;
|
||||
} else if (westData && JSON.parse(westData).length > 0) {
|
||||
activeCategory.value = 2;
|
||||
}
|
||||
}
|
||||
// 否则保持默认值
|
||||
}
|
||||
|
||||
// 如果恢复的处方类型是中药,检测并显示转诊提示
|
||||
@@ -645,20 +695,108 @@ const updateDrugUsage = (index: number, field: string, value: any) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取药品数据的存储key(按类型分开存储)
|
||||
* @param patientId 患者ID
|
||||
* 获取当前挂号 ID(与 doctorReception-id 一致,用于 localStorage 键)
|
||||
*/
|
||||
function getRegisterId(): number {
|
||||
if (selectPatientId.value > 0) {
|
||||
return selectPatientId.value;
|
||||
}
|
||||
return Number.parseInt(localStorage.getItem('doctorReception-id') || '0', 10);
|
||||
}
|
||||
|
||||
/** 接诊草稿 key:诊断、医嘱、诊疗费、中药配置等 */
|
||||
function getDraftKey(registerId: number | string) {
|
||||
return `doctorReceptionDraft_${registerId}`;
|
||||
}
|
||||
|
||||
/** 保存接诊表单草稿(刷新后恢复) */
|
||||
function saveReceptionDraft() {
|
||||
const registerId = getRegisterId();
|
||||
if (!registerId) return;
|
||||
localStorage.setItem(
|
||||
getDraftKey(registerId),
|
||||
JSON.stringify({
|
||||
activeCategory: activeCategory.value,
|
||||
diagnosis: diagnosis.value,
|
||||
medicalAdvice: medicalAdvice.value,
|
||||
treatmentPrice: treatmentPrice.value,
|
||||
ruleType: ruleType.value,
|
||||
packageMethodId: packageMethodId.value,
|
||||
processRuleId: processRuleId.value,
|
||||
childProcessRuleId: childProcessRuleId.value,
|
||||
processRuleNoteId: processRuleNoteId.value,
|
||||
dosage: dosage.value,
|
||||
dayDosage: dayDosage.value,
|
||||
priceDiscount: priceDiscount.value,
|
||||
}),
|
||||
);
|
||||
localStorage.setItem(`activeCategory${registerId}`, String(activeCategory.value));
|
||||
}
|
||||
|
||||
/** 恢复接诊草稿 */
|
||||
function restoreReceptionDraft(registerId: number) {
|
||||
if (!registerId) return false;
|
||||
const raw = localStorage.getItem(getDraftKey(registerId));
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const draft = JSON.parse(raw);
|
||||
if (draft.activeCategory != null) {
|
||||
activeCategory.value = Number(draft.activeCategory);
|
||||
}
|
||||
diagnosis.value = draft.diagnosis ?? '';
|
||||
medicalAdvice.value = draft.medicalAdvice ?? '';
|
||||
treatmentPrice.value = draft.treatmentPrice ?? 0;
|
||||
ruleType.value = draft.ruleType ?? 1;
|
||||
if (draft.packageMethodId != null) {
|
||||
packageMethodId.value = draft.packageMethodId;
|
||||
}
|
||||
if (draft.processRuleId != null) {
|
||||
processRuleId.value = draft.processRuleId;
|
||||
}
|
||||
if (draft.childProcessRuleId != null) {
|
||||
childProcessRuleId.value = draft.childProcessRuleId;
|
||||
}
|
||||
if (draft.processRuleNoteId != null) {
|
||||
processRuleNoteId.value = draft.processRuleNoteId;
|
||||
}
|
||||
if (draft.dosage != null) dosage.value = draft.dosage;
|
||||
if (draft.dayDosage != null) dayDosage.value = draft.dayDosage;
|
||||
if (draft.priceDiscount != null) priceDiscount.value = draft.priceDiscount;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearReceptionDraft(registerId?: number) {
|
||||
const id = registerId || getRegisterId();
|
||||
if (id) {
|
||||
localStorage.removeItem(getDraftKey(id));
|
||||
}
|
||||
}
|
||||
|
||||
let draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function debouncedSaveReceptionDraft() {
|
||||
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
||||
draftSaveTimer = setTimeout(() => saveReceptionDraft(), 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取药品数据的存储key(按挂号ID + 类型分开存储)
|
||||
* @param registerId 挂号ID
|
||||
* @param category 类型:1-中药,2-西药
|
||||
*/
|
||||
const getStorageKey = (patientId: number | string | undefined, category: number) => {
|
||||
return `prescriptionData_${category}_${patientId}`;
|
||||
const getStorageKey = (registerId: number | string | undefined, category: number) => {
|
||||
return `prescriptionData_${category}_${registerId}`;
|
||||
};
|
||||
|
||||
// 保存到本地存储(按类型分开存储)
|
||||
const saveToLocalStorage = () => {
|
||||
localStorage.setItem(
|
||||
getStorageKey(activePatient.value?.id, activeCategory.value),
|
||||
getStorageKey(getRegisterId(), activeCategory.value),
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
saveReceptionDraft();
|
||||
};
|
||||
|
||||
function snapshotDrugOrigins(drugs: any[]) {
|
||||
@@ -713,9 +851,10 @@ const removeDrug = (index: number) => {
|
||||
*/
|
||||
const updateLocalStorage = () => {
|
||||
localStorage.setItem(
|
||||
getStorageKey(activePatient.value?.id, activeCategory.value),
|
||||
getStorageKey(getRegisterId(), activeCategory.value),
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
saveReceptionDraft();
|
||||
getCurrentDrugs();
|
||||
};
|
||||
|
||||
@@ -725,7 +864,7 @@ const currentDrugs = ref([]);
|
||||
*/
|
||||
const getCurrentDrugs = () => {
|
||||
currentDrugs.value = JSON.parse(
|
||||
localStorage.getItem(getStorageKey(activePatient.value?.id, activeCategory.value)) || '[]',
|
||||
localStorage.getItem(getStorageKey(getRegisterId(), activeCategory.value)) || '[]',
|
||||
);
|
||||
};
|
||||
getCurrentDrugs();
|
||||
@@ -860,6 +999,7 @@ const sendPrescription = () => {
|
||||
|
||||
// 清空当前数据
|
||||
clearPrescriptionCart(false);
|
||||
clearReceptionDraft(getRegisterId());
|
||||
tabType.value = 1;
|
||||
salespersonTransferPrescriptionId.value = 0;
|
||||
processRulePrice.value = 0;
|
||||
@@ -1296,25 +1436,20 @@ function splitString(str: string) {
|
||||
* 4. 如果切换到中药且当前诊所为西医诊所,显示转诊提示
|
||||
*/
|
||||
function tabChange(id) {
|
||||
// 先保存当前Tab的药品数据
|
||||
const registerId = getRegisterId();
|
||||
localStorage.setItem(
|
||||
getStorageKey(activePatient.value?.id, activeCategory.value),
|
||||
getStorageKey(registerId, activeCategory.value),
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
|
||||
// 更新Tab类型
|
||||
localStorage.setItem(`activeCategory${activePatient.value?.id}`, id);
|
||||
saveReceptionDraft();
|
||||
localStorage.setItem(`activeCategory${registerId}`, String(id));
|
||||
activeCategory.value = id;
|
||||
if (id === 1) {
|
||||
checkSalespersonTransfer();
|
||||
}
|
||||
|
||||
// 加载新Tab对应的药品数据
|
||||
currentDrugs.value = JSON.parse(
|
||||
localStorage.getItem(getStorageKey(activePatient.value?.id, id)) || '[]',
|
||||
localStorage.getItem(getStorageKey(registerId, id)) || '[]',
|
||||
);
|
||||
|
||||
// 如果切换到中药(id === 1),检测当前诊所是否为西医诊所
|
||||
if (id === 1) {
|
||||
checkAndShowTransferTip();
|
||||
}
|
||||
@@ -1527,7 +1662,7 @@ async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean>
|
||||
return false;
|
||||
}
|
||||
|
||||
const pid = activePatient.value?.id;
|
||||
const pid = getRegisterId();
|
||||
if (!pid) {
|
||||
message.warning('请先选择患者');
|
||||
return false;
|
||||
@@ -1641,12 +1776,8 @@ async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean>
|
||||
getStorageKey(pid, activeCategory.value),
|
||||
JSON.stringify(currentDrugs.value),
|
||||
);
|
||||
if (activePatient.value?.id) {
|
||||
localStorage.setItem(
|
||||
`activeCategory${activePatient.value.id}`,
|
||||
String(activeCategory.value),
|
||||
);
|
||||
}
|
||||
localStorage.setItem(`activeCategory${pid}`, String(activeCategory.value));
|
||||
saveReceptionDraft();
|
||||
getCurrentDrugs();
|
||||
return true;
|
||||
}
|
||||
@@ -1764,6 +1895,28 @@ function selectPackageMethod(id) {
|
||||
const dosage = ref(7);
|
||||
const dayDosage = ref(2);
|
||||
|
||||
watch(
|
||||
[
|
||||
diagnosis,
|
||||
medicalAdvice,
|
||||
treatmentPrice,
|
||||
ruleType,
|
||||
packageMethodId,
|
||||
processRuleId,
|
||||
childProcessRuleId,
|
||||
processRuleNoteId,
|
||||
dosage,
|
||||
dayDosage,
|
||||
priceDiscount,
|
||||
activeCategory,
|
||||
],
|
||||
() => {
|
||||
if (getRegisterId()) {
|
||||
debouncedSaveReceptionDraft();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 清空药方:退出特色方 SKU 计价状态,恢复可编辑
|
||||
* @param showToast 是否提示成功
|
||||
@@ -1779,6 +1932,7 @@ function clearPrescriptionCart(showToast = true) {
|
||||
clearAppliedSpecialPrescription();
|
||||
priceDiscount.value = 100;
|
||||
updateLocalStorage();
|
||||
clearReceptionDraft(getRegisterId());
|
||||
if (showToast) {
|
||||
message.success('已清空药方');
|
||||
}
|
||||
@@ -1925,7 +2079,7 @@ const selectChineseId = ref(0);
|
||||
function selectOldDrugInfo(id) {
|
||||
if (guardSpecialPrescriptionCartEdit()) return;
|
||||
const check = JSON.parse(
|
||||
localStorage.getItem(getStorageKey(activePatient.value?.id, activeCategory.value)) || '[]',
|
||||
localStorage.getItem(getStorageKey(getRegisterId(), activeCategory.value)) || '[]',
|
||||
).find((v) => v.id === id);
|
||||
if (check) {
|
||||
currentDrugs.value[selectChineseIndex.value].id = selectChineseId.value;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 从传方记录中解析药品列表(兼容 drug_list / prescription.content / content.repice)
|
||||
*/
|
||||
export function parseSalespersonTransferDrugList(item: any): any[] {
|
||||
if (item.drug_list?.length) return item.drug_list;
|
||||
if (item.prescription?.content?.length) return item.prescription.content;
|
||||
const content = item.content;
|
||||
if (!content?.repice) return [];
|
||||
const drugs: any[] = [];
|
||||
for (const recipe of content.repice) {
|
||||
let rc = recipe.content;
|
||||
if (typeof rc === 'string') {
|
||||
try {
|
||||
rc = JSON.parse(rc);
|
||||
} catch {
|
||||
rc = [];
|
||||
}
|
||||
}
|
||||
if (Array.isArray(rc)) drugs.push(...rc);
|
||||
}
|
||||
return drugs;
|
||||
}
|
||||
@@ -16,7 +16,12 @@ export const accountChangeGridOptions: VxeGridProps = {
|
||||
{ field: 'field_name_txt', title: '变更字段', width: 120 },
|
||||
{ field: 'before_amount', title: '变动前', width: 110 },
|
||||
{ field: 'after_amount', title: '变动后', width: 110 },
|
||||
{ field: 'change_amount', title: '变动值', width: 110 },
|
||||
{
|
||||
field: 'change_amount',
|
||||
title: '变动值',
|
||||
width: 110,
|
||||
slots: { default: 'change_amount' },
|
||||
},
|
||||
{ field: 'order_no', title: '订单号', minWidth: 160 },
|
||||
{ field: 'source_table', title: '来源表', width: 140 },
|
||||
{ field: 'remark', title: '备注', minWidth: 180 },
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { AnalysisChartsTabs, Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { FloatButton, FloatButtonGroup, message, Popover, Tag } from 'ant-design-vue';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
FloatButton,
|
||||
FloatButtonGroup,
|
||||
message,
|
||||
Popover,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -65,7 +72,10 @@ function openOrderDetail(row: Record<string, any>) {
|
||||
}
|
||||
|
||||
if (orderType === 2 && orderNo) {
|
||||
router.push({ path: '/business/register/list', query: { order_no: orderNo } });
|
||||
router.push({
|
||||
path: '/business/register/list',
|
||||
query: { order_no: orderNo },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,23 +106,31 @@ function updateShowStatus() {
|
||||
<Statistics v-show="isShow" :grid-api="GridApi" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #trends>
|
||||
<div style="min-height: 500px;">
|
||||
<div style="min-height: 500px">
|
||||
<Grid>
|
||||
<template #toolbar-buttons></template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #user_id="{ row }">
|
||||
<span v-if="row.user_type === 1">{{ row.store.name }}</span>
|
||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||
<span v-else-if="row.user_type === 3">{{ row.supplier.name }}</span>
|
||||
<span v-else-if="row.user_type === 3">{{
|
||||
row.supplier.name
|
||||
}}</span>
|
||||
</template>
|
||||
<template #check_status="{ row }">
|
||||
<Tag v-if="row.check_status === 1" color="blue">待审核</Tag>
|
||||
<Tag v-else-if="row.check_status === 2" color="green">审核成功</Tag>
|
||||
<Tag v-else-if="row.check_status === 2" color="green">
|
||||
审核成功
|
||||
</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="red">拒绝</Tag>
|
||||
</template>
|
||||
<template #check_result="{ row }">
|
||||
<Tag v-if="row.check_status === 2" color="green">{{ row.check_result }}</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="red">{{ row.check_result }}</Tag>
|
||||
<Tag v-if="row.check_status === 2" color="green">
|
||||
{{ row.check_result }}
|
||||
</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="red">
|
||||
{{ row.check_result }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template #dakuan_status="{ row }">
|
||||
<span v-if="row.dakuan_status === -1">打款失败</span>
|
||||
@@ -120,7 +138,11 @@ function updateShowStatus() {
|
||||
<span v-else-if="row.dakuan_status === 1">打款成功</span>
|
||||
</template>
|
||||
<template #check_id="{ row }">
|
||||
<span>{{ row.check_admin?.username || row.check_admin?.nick_name || '暂无' }}</span>
|
||||
<span>{{
|
||||
row.check_admin?.username ||
|
||||
row.check_admin?.nick_name ||
|
||||
'暂无'
|
||||
}}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||
@@ -129,7 +151,7 @@ function updateShowStatus() {
|
||||
</div>
|
||||
</template>
|
||||
<template #visits>
|
||||
<div style="min-height: 500px;">
|
||||
<div style="min-height: 500px">
|
||||
<Grid3>
|
||||
<template #toolbar-buttons></template>
|
||||
<template #toolbar-tools></template>
|
||||
@@ -157,14 +179,16 @@ function updateShowStatus() {
|
||||
</div>
|
||||
</template>
|
||||
<template #visitsItems>
|
||||
<div style="min-height: 500px;">
|
||||
<div style="min-height: 500px">
|
||||
<Grid2>
|
||||
<template #toolbar-buttons></template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #user_id="{ row }">
|
||||
<span v-if="row.user_type === 1">{{ row.store.name }}</span>
|
||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||
<span v-else-if="row.user_type === 3">{{ row.supplier.name }}</span>
|
||||
<span v-else-if="row.user_type === 3">{{
|
||||
row.supplier.name
|
||||
}}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||
@@ -173,10 +197,39 @@ function updateShowStatus() {
|
||||
</div>
|
||||
</template>
|
||||
<template #accountChange>
|
||||
<div style="min-height: 500px;">
|
||||
<div style="min-height: 500px">
|
||||
<Grid4>
|
||||
<template #toolbar-buttons></template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #change_amount="{ row }">
|
||||
<span
|
||||
v-if="Number(row.change_amount) > 0"
|
||||
style="
|
||||
color: #52c41a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
+{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-else-if="Number(row.change_amount) < 0"
|
||||
style="
|
||||
color: #ff4d4f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
|
||||
<span v-else style="color: #999">0</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
@@ -207,7 +260,27 @@ function updateShowStatus() {
|
||||
@click="updateShowStatus"
|
||||
>
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M21.5 16.052V7.948a4.14 4.14 0 0 0-1.236-2.945a4.25 4.25 0 0 0-2.985-1.22H6.72a4.25 4.25 0 0 0-2.985 1.22A4.14 4.14 0 0 0 2.5 7.948v8.104c0 1.105.445 2.164 1.236 2.945a4.25 4.25 0 0 0 2.985 1.22H17.28c1.12 0 2.193-.44 2.985-1.22a4.14 4.14 0 0 0 1.236-2.945"/><path d="M8.552 12.14a2.054 2.054 0 1 0 0-4.108a2.054 2.054 0 0 0 0 4.108m3.081 3.828c0-.812-.324-1.59-.902-2.165a3.09 3.09 0 0 0-4.358 0a3.05 3.05 0 0 0-.902 2.165m9.097-7.049h3.594M14.568 12h1.54m-1.54 3.081h3.594"/></g></svg>
|
||||
<svg
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
d="M21.5 16.052V7.948a4.14 4.14 0 0 0-1.236-2.945a4.25 4.25 0 0 0-2.985-1.22H6.72a4.25 4.25 0 0 0-2.985 1.22A4.14 4.14 0 0 0 2.5 7.948v8.104c0 1.105.445 2.164 1.236 2.945a4.25 4.25 0 0 0 2.985 1.22H17.28c1.12 0 2.193-.44 2.985-1.22a4.14 4.14 0 0 0 1.236-2.945"
|
||||
/>
|
||||
<path
|
||||
d="M8.552 12.14a2.054 2.054 0 1 0 0-4.108a2.054 2.054 0 0 0 0 4.108m3.081 3.828c0-.812-.324-1.59-.902-2.165a3.09 3.09 0 0 0-4.358 0a3.05 3.05 0 0 0-.902 2.165m9.097-7.049h3.594M14.568 12h1.54m-1.54 3.081h3.594"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
</FloatButton>
|
||||
</Popover>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||
|
||||
import { EditOutlined, FolderOutlined } from '@ant-design/icons-vue';
|
||||
import { EditOutlined, FolderAddOutlined, FolderOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -23,6 +23,7 @@ import { useFileGalleryFilter } from '#/composables/use-file-gallery-filter';
|
||||
import { resolveTypeIcon } from '#/composables/use-file-type-icon';
|
||||
import type { FileGalleryItem } from '#/api/core/file-gallery';
|
||||
import {
|
||||
addFileToGroup,
|
||||
batchArchiveFiles,
|
||||
deleteFileGalleryItem,
|
||||
getFileGalleryList,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
renameFileGalleryItem,
|
||||
syncFileGalleryFromOss,
|
||||
} from '#/api/core/file-gallery';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
|
||||
import FileDetailDrawer from './components/file-detail-drawer.vue';
|
||||
|
||||
@@ -49,14 +51,19 @@ const pageSizeOptions = ['12', '24', '48', '96'];
|
||||
|
||||
const {
|
||||
activeType,
|
||||
activeGroupId,
|
||||
keyword,
|
||||
tabs,
|
||||
groupTabs,
|
||||
setActiveType,
|
||||
setActiveGroupId,
|
||||
buildListParams,
|
||||
fileTypes,
|
||||
typesLoading,
|
||||
} = useFileGalleryFilter('file-management-active-type');
|
||||
|
||||
const { loadGroupOptions: refreshGroupOptionsForMenu } = useFileGroupCache();
|
||||
|
||||
const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
|
||||
connectedComponent: FileDetailDrawer,
|
||||
});
|
||||
@@ -151,7 +158,27 @@ async function handleMoveType(item: FileGalleryItem, type: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
function buildContextMenus(item: FileGalleryItem): ContextMenuItem[] {
|
||||
async function handleAddToGroup(item: FileGalleryItem, groupId: number) {
|
||||
await addFileToGroup(item.id, groupId);
|
||||
message.success('已加入分组');
|
||||
if (!item.group_ids) {
|
||||
item.group_ids = [];
|
||||
}
|
||||
if (!item.group_ids.includes(groupId)) {
|
||||
item.group_ids.push(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildContextMenus(item: FileGalleryItem): Promise<ContextMenuItem[]> {
|
||||
const groups = await refreshGroupOptionsForMenu();
|
||||
const joinedIds = new Set(item.group_ids ?? []);
|
||||
const groupChildren: ContextMenuItem[] = groups.map((groupItem) => ({
|
||||
key: `group-${groupItem.id}`,
|
||||
label: groupItem.name,
|
||||
disabled: joinedIds.has(groupItem.id),
|
||||
handler: () => handleAddToGroup(item, groupItem.id),
|
||||
}));
|
||||
|
||||
const moveChildren: ContextMenuItem[] = fileTypes.value.map((typeItem) => ({
|
||||
key: `move-${typeItem.value}`,
|
||||
label: typeItem.name,
|
||||
@@ -167,6 +194,12 @@ function buildContextMenus(item: FileGalleryItem): ContextMenuItem[] {
|
||||
icon: EditOutlined,
|
||||
handler: () => handleRename(item),
|
||||
},
|
||||
{
|
||||
key: 'add-group',
|
||||
label: '加入分组',
|
||||
icon: FolderAddOutlined,
|
||||
children: groupChildren.length ? groupChildren : [{ key: 'empty', label: '暂无分组', disabled: true }],
|
||||
},
|
||||
{
|
||||
key: 'move',
|
||||
label: '移动分类',
|
||||
@@ -176,8 +209,9 @@ function buildContextMenus(item: FileGalleryItem): ContextMenuItem[] {
|
||||
];
|
||||
}
|
||||
|
||||
function onContextMenu(e: MouseEvent, item: FileGalleryItem) {
|
||||
showContextMenu(e, buildContextMenus(item), item);
|
||||
async function onContextMenu(e: MouseEvent, item: FileGalleryItem) {
|
||||
const menus = await buildContextMenus(item);
|
||||
showContextMenu(e, menus, item);
|
||||
}
|
||||
|
||||
function onTabChange(key: string | number) {
|
||||
@@ -186,6 +220,12 @@ function onTabChange(key: string | number) {
|
||||
void load();
|
||||
}
|
||||
|
||||
function onGroupTabChange(key: string | number) {
|
||||
setActiveGroupId(Number(key));
|
||||
page.value = 1;
|
||||
void load();
|
||||
}
|
||||
|
||||
function onSearch(value: string) {
|
||||
keyword.value = value;
|
||||
page.value = 1;
|
||||
@@ -236,6 +276,16 @@ watch(typesLoading, (val) => {
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
||||
<Tabs
|
||||
v-if="groupTabs.length > 1"
|
||||
:active-key="String(activeGroupId)"
|
||||
class="group-tabs"
|
||||
size="small"
|
||||
@change="onGroupTabChange"
|
||||
>
|
||||
<Tabs.TabPane v-for="tab in groupTabs" :key="String(tab.value)" :tab="tab.label" />
|
||||
</Tabs>
|
||||
|
||||
<div class="toolbar">
|
||||
<Input.Search
|
||||
v-model:value="keyword"
|
||||
@@ -315,6 +365,10 @@ watch(typesLoading, (val) => {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.group-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
152
apps/web-antd/src/views/system/file-group/index.vue
Normal file
152
apps/web-antd/src/views/system/file-group/index.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Space, Table, message } from 'ant-design-vue';
|
||||
|
||||
import type { FileGroupRecord } from '#/api/core/file-group';
|
||||
import {
|
||||
createFileGroup,
|
||||
deleteFileGroup,
|
||||
getFileGroupList,
|
||||
updateFileGroup,
|
||||
} from '#/api/core/file-group';
|
||||
import { useFileGroupCache } from '#/composables/use-file-group-cache';
|
||||
|
||||
defineOptions({ name: 'FileGroup' });
|
||||
|
||||
const loading = ref(false);
|
||||
const items = ref<FileGroupRecord[]>([]);
|
||||
const modalOpen = ref(false);
|
||||
const isUpdate = ref(false);
|
||||
const form = ref<Partial<FileGroupRecord>>({
|
||||
name: '',
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const { invalidateGroupOptions } = useFileGroupCache();
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
];
|
||||
|
||||
/** 加载分组列表 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getFileGroupList();
|
||||
const data = (res as any)?.result ?? (res as any)?.data ?? res;
|
||||
items.value = Array.isArray(data) ? data : [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
isUpdate.value = false;
|
||||
form.value = { name: '', sort: 0 };
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: FileGroupRecord) {
|
||||
isUpdate.value = true;
|
||||
form.value = { ...row };
|
||||
modalOpen.value = true;
|
||||
}
|
||||
|
||||
/** 提交创建/更新并刷新缓存 */
|
||||
async function submitForm() {
|
||||
if (!form.value.name?.trim()) {
|
||||
message.warning('请填写分组名称');
|
||||
return;
|
||||
}
|
||||
if (isUpdate.value && form.value.id) {
|
||||
await updateFileGroup({
|
||||
id: form.value.id,
|
||||
name: form.value.name,
|
||||
sort: form.value.sort,
|
||||
});
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createFileGroup(form.value);
|
||||
message.success('创建成功');
|
||||
}
|
||||
modalOpen.value = false;
|
||||
await invalidateGroupOptions();
|
||||
await load();
|
||||
}
|
||||
|
||||
function handleDelete(row: FileGroupRecord) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定删除分组「${row.name}」吗?组内关联将被移除。`,
|
||||
okType: 'danger',
|
||||
async onOk() {
|
||||
await deleteFileGroup(row.id);
|
||||
message.success('删除成功');
|
||||
await invalidateGroupOptions();
|
||||
await load();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
void load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="分组管理">
|
||||
<Card>
|
||||
<div class="toolbar">
|
||||
<Button type="primary" @click="openCreate">新增分组</Button>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data-source="items"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Space>
|
||||
<Button type="link" size="small" @click="openEdit(record)">编辑</Button>
|
||||
<Button danger type="link" size="small" @click="handleDelete(record)">删除</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
v-model:open="modalOpen"
|
||||
:title="isUpdate ? '编辑分组' : '新增分组'"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="submitForm"
|
||||
>
|
||||
<Form layout="vertical" class="mt-2">
|
||||
<Form.Item label="名称" required>
|
||||
<Input v-model:value="form.name" placeholder="如:活动素材" />
|
||||
</Form.Item>
|
||||
<Form.Item label="排序">
|
||||
<InputNumber v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -30,6 +30,15 @@ export function getSettlementDetail(params: { id: number; store_id: number }) {
|
||||
return requestClient.get(`${prefix}settlement-detail`, { params });
|
||||
}
|
||||
|
||||
/** 手动退回推广员分成(仅已退款/已取消订单) */
|
||||
export function reverseCommissionApi(data: {
|
||||
order_id: number;
|
||||
store_id: number;
|
||||
salesperson_id: number;
|
||||
}) {
|
||||
return requestClient.post(`${prefix}reverse-commission`, data);
|
||||
}
|
||||
|
||||
export function getDrugCommissionList(params: Record<string, any>) {
|
||||
return requestClient.get(`${prefix}drug-commission-list`, { params });
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Image,
|
||||
Input,
|
||||
message,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
getSettlementDetail,
|
||||
getSettlementList,
|
||||
getSettlementPreview,
|
||||
reverseCommissionApi,
|
||||
} from '../api/salesperson';
|
||||
|
||||
const visible = ref(false);
|
||||
@@ -51,17 +54,19 @@ const periodRange = ref<any[]>([]);
|
||||
const settlementConfirmRef = ref<InstanceType<typeof SalespersonSettlementConfirmModal>>();
|
||||
|
||||
const orderCommissionColumns = [
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 160 },
|
||||
{ title: '明细数', dataIndex: 'item_count', width: 80 },
|
||||
{ title: '分成总额', dataIndex: 'commission_amount', width: 100 },
|
||||
{ title: '支付时间', dataIndex: 'order_pay_at_text', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 90 },
|
||||
{ title: '订单号', dataIndex: 'order_no', width: 150 },
|
||||
{ title: '订单状态', key: 'order_status', width: 90 },
|
||||
{ title: '明细数', dataIndex: 'item_count', width: 70 },
|
||||
{ title: '净分成', key: 'net_commission', width: 90 },
|
||||
{ title: '支付时间', dataIndex: 'order_pay_at_text', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 140 },
|
||||
];
|
||||
|
||||
const commissionDetailColumns = [
|
||||
{ title: '类型', dataIndex: 'record_type_text', width: 90 },
|
||||
{ title: '药品信息', dataIndex: 'drug_info', ellipsis: true },
|
||||
{ title: '分成金额', dataIndex: 'commission_amount', width: 100 },
|
||||
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 170 },
|
||||
{ title: '分成金额', key: 'commission_amount', width: 100 },
|
||||
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 160 },
|
||||
];
|
||||
|
||||
const settlementLineColumns = [
|
||||
@@ -162,6 +167,41 @@ async function showOrderDetail(record: { order_id: number; order_no: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const reversingOrderIds = ref<number[]>([]);
|
||||
|
||||
/** 手动退回分成(仅已退款/已取消订单) */
|
||||
async function handleReverseCommission(record: {
|
||||
order_id: number;
|
||||
can_reverse_commission?: boolean;
|
||||
}) {
|
||||
if (!storeId.value || !salespersonId.value || reversingOrderIds.value.includes(record.order_id)) {
|
||||
return;
|
||||
}
|
||||
reversingOrderIds.value.push(record.order_id);
|
||||
try {
|
||||
await reverseCommissionApi({
|
||||
order_id: record.order_id,
|
||||
store_id: storeId.value,
|
||||
salesperson_id: salespersonId.value,
|
||||
});
|
||||
message.success('退回分成成功');
|
||||
await loadCommission();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '退回分成失败');
|
||||
} finally {
|
||||
reversingOrderIds.value = reversingOrderIds.value.filter((id) => id !== record.order_id);
|
||||
}
|
||||
}
|
||||
|
||||
function orderStatusTagColor(text?: string) {
|
||||
if (text === '已退款' || text === '已取消') return 'warning';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function isNegativeAmount(amount?: string | number) {
|
||||
return Number(amount ?? 0) < 0;
|
||||
}
|
||||
|
||||
async function loadSettlements() {
|
||||
if (!storeId.value || !salespersonId.value) return;
|
||||
const res = await getSettlementList({
|
||||
@@ -262,8 +302,27 @@ defineExpose({ open });
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<template v-if="column.key === 'order_status'">
|
||||
<Tag :color="orderStatusTagColor(record.order_status_text)">
|
||||
{{ record.order_status_text || '—' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'net_commission'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.net_commission_amount) }">
|
||||
¥{{ record.net_commission_amount ?? record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="showOrderDetail(record)">查看明细</Button>
|
||||
<Popconfirm
|
||||
v-if="record.can_reverse_commission"
|
||||
title="确认退回该订单的推广员分成?"
|
||||
@confirm="handleReverseCommission(record)"
|
||||
>
|
||||
<Button type="link" size="small" danger :loading="reversingOrderIds.includes(record.order_id)">
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -278,8 +337,27 @@ defineExpose({ open });
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<template v-if="column.key === 'order_status'">
|
||||
<Tag :color="orderStatusTagColor(record.order_status_text)">
|
||||
{{ record.order_status_text || '—' }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'net_commission'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.net_commission_amount) }">
|
||||
¥{{ record.net_commission_amount ?? record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="showOrderDetail(record)">查看明细</Button>
|
||||
<Popconfirm
|
||||
v-if="record.can_reverse_commission"
|
||||
title="确认退回该订单的推广员分成?"
|
||||
@confirm="handleReverseCommission(record)"
|
||||
>
|
||||
<Button type="link" size="small" danger :loading="reversingOrderIds.includes(record.order_id)">
|
||||
退回分成
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -313,7 +391,15 @@ defineExpose({ open });
|
||||
:loading="orderDetailLoading"
|
||||
:row-key="(r) => r.id"
|
||||
size="small"
|
||||
/>
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'commission_amount'">
|
||||
<span :class="{ 'text-red-500': isNegativeAmount(record.commission_amount) }">
|
||||
¥{{ record.commission_amount }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:open="detailVisible" title="结算单详情" width="720" destroy-on-close>
|
||||
|
||||
Reference in New Issue
Block a user