1. 特色方功能迭代(固定价格),固定价格目前统一使用药品id(-102),后期会考虑新增特色方的时候自动添加一个特色方药品
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

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

View File

@@ -0,0 +1,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
View File

@@ -49,5 +49,5 @@ vite.config.ts.*
*.sln
*.sw?
.history
/.cursor/
/.mimocode/
/.cursor/skills/

View File

@@ -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 });
}

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

View File

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

View File

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

View File

@@ -0,0 +1,194 @@
<script setup lang="ts">
/**
* PC 可拖拽吸附悬浮按钮
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { FileTextOutlined } from '@ant-design/icons-vue';
const props = withDefaults(
defineProps<{
/** localStorage 位置缓存 key */
storageKey?: string;
/** 显示文案 */
label?: string;
/** 角标数量 */
badge?: number;
/** 吸附阈值 px */
snapThreshold?: number;
zIndex?: number;
}>(),
{
storageKey: 'pc_float_fab_pos',
label: '传方',
badge: 0,
snapThreshold: 40,
zIndex: 9999,
},
);
const emit = defineEmits<{
click: [];
}>();
const posX = ref(0);
const posY = ref(0);
const fabSize = 56;
const dragging = ref(false);
const moved = ref(false);
const startX = ref(0);
const startY = ref(0);
const originX = ref(0);
const originY = ref(0);
const fabStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`,
zIndex: props.zIndex,
}));
const badgeText = computed(() => {
if (props.badge <= 0) return '';
return props.badge > 99 ? '99+' : String(props.badge);
});
function initPosition() {
const cached = localStorage.getItem(props.storageKey);
if (cached) {
try {
const data = JSON.parse(cached);
posX.value = data.posX ?? window.innerWidth - fabSize - 24;
posY.value = data.posY ?? window.innerHeight * 0.6;
} catch {
posX.value = window.innerWidth - fabSize - 24;
posY.value = window.innerHeight * 0.6;
}
} else {
posX.value = window.innerWidth - fabSize - 24;
posY.value = window.innerHeight * 0.6;
}
clampPos();
}
function savePosition() {
localStorage.setItem(
props.storageKey,
JSON.stringify({ posX: posX.value, posY: posY.value }),
);
}
function clampPos() {
posX.value = Math.max(8, Math.min(posX.value, window.innerWidth - fabSize - 8));
posY.value = Math.max(60, Math.min(posY.value, window.innerHeight - fabSize - 24));
}
/** 松手吸附到最近左/右边缘 */
function snapToEdge() {
const centerX = posX.value + fabSize / 2;
if (centerX < window.innerWidth / 2) {
posX.value = props.snapThreshold;
} else {
posX.value = window.innerWidth - fabSize - props.snapThreshold;
}
clampPos();
savePosition();
}
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
dragging.value = true;
moved.value = false;
startX.value = e.clientX;
startY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function onMouseMove(e: MouseEvent) {
if (!dragging.value) return;
const dx = e.clientX - startX.value;
const dy = e.clientY - startY.value;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved.value = true;
posX.value = originX.value + dx;
posY.value = originY.value + dy;
clampPos();
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (dragging.value && moved.value) {
snapToEdge();
} else if (!moved.value) {
emit('click');
}
dragging.value = false;
}
onMounted(initPosition);
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
});
</script>
<template>
<div class="float-fab" :style="fabStyle" @mousedown="onMouseDown">
<div class="fab-inner">
<FileTextOutlined class="fab-icon" />
<span v-if="label" class="fab-label">{{ label }}</span>
</div>
<span v-if="badgeText" class="fab-badge">{{ badgeText }}</span>
</div>
</template>
<style scoped>
.float-fab {
position: fixed;
width: 30px;
height: 30px;
cursor: grab;
user-select: none;
}
.float-fab:active {
cursor: grabbing;
}
.fab-inner {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #6acdbb, #00a88a);
box-shadow: 0 4px 16px rgba(0, 168, 138, 0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #fff;
}
.fab-icon {
font-size: 12px;
}
.fab-label {
font-size: 10px;
margin-top: 2px;
}
.fab-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 12px;
height: 12px;
padding: 0 4px;
background: #ef4444;
color: #fff;
font-size: 11px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
</style>

View File

@@ -0,0 +1,435 @@
<script setup lang="ts">
/**
* 通用 PC 悬浮子窗口:可拖拽、四角缩放、可最小化,始终保持在可视区域内
*/
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { usePreferences } from '@vben/preferences';
import { CloseOutlined, MinusOutlined } from '@ant-design/icons-vue';
import { SUB_WINDOW_POPUP_Z_INDEX_KEY } from './constants';
const props = withDefaults(
defineProps<{
/** 是否显示窗口 */
open: boolean;
/** 标题 */
title?: string;
/** localStorage 缓存 key */
storageKey?: string;
/** 初始宽度 */
width?: number;
/** 初始高度 */
height?: number;
/** 最小宽度 */
minWidth?: number;
/** 最小高度 */
minHeight?: number;
/** 初始 X-1 表示靠右 */
defaultX?: number;
/** 初始 Y */
defaultY?: number;
/** 层级 */
zIndex?: number;
}>(),
{
title: '窗口',
storageKey: 'pc_sub_window_default',
width: 420,
height: 520,
minWidth: 320,
minHeight: 240,
defaultX: -1,
defaultY: 80,
zIndex: 10000,
},
);
const emit = defineEmits<{
'update:open': [boolean];
close: [];
}>();
const { isDark } = usePreferences();
const posX = ref(100);
const posY = ref(80);
const winWidth = ref(props.width);
const winHeight = ref(props.height);
const minimized = ref(false);
type DragMode =
| ''
| 'move'
| 'resize-se'
| 'resize-sw'
| 'resize-ne'
| 'resize-nw';
const dragMode = ref<DragMode>('');
const dragStartX = ref(0);
const dragStartY = ref(0);
const originX = ref(0);
const originY = ref(0);
const originW = ref(0);
const originH = ref(0);
/** 弹出层 z-index需高于子窗口本体 */
const popupZIndex = computed(() => props.zIndex + 100);
provide(SUB_WINDOW_POPUP_Z_INDEX_KEY, popupZIndex);
const windowStyle = computed(() => ({
left: `${posX.value}px`,
top: `${posY.value}px`,
width: `${winWidth.value}px`,
height: minimized.value ? 'auto' : `${winHeight.value}px`,
zIndex: props.zIndex,
}));
/** 从缓存或默认值初始化窗口位置与尺寸 */
function initLayout() {
const cached = localStorage.getItem(props.storageKey);
if (cached) {
try {
const data = JSON.parse(cached);
posX.value = data.posX ?? posX.value;
posY.value = data.posY ?? posY.value;
winWidth.value = data.width ?? winWidth.value;
winHeight.value = data.height ?? winHeight.value;
minimized.value = !!data.minimized;
} catch {
/* ignore */
}
} else {
winWidth.value = props.width;
winHeight.value = props.height;
posX.value =
props.defaultX >= 0
? props.defaultX
: Math.max(16, window.innerWidth - winWidth.value - 24);
posY.value = props.defaultY;
}
clampWindow();
}
/** 约束窗口在可视区域内 */
function clampWindow() {
const h = minimized.value ? 44 : winHeight.value;
winWidth.value = Math.max(
props.minWidth,
Math.min(winWidth.value, window.innerWidth - 16),
);
winHeight.value = Math.max(
props.minHeight,
Math.min(winHeight.value, window.innerHeight - 16),
);
posX.value = Math.max(0, Math.min(posX.value, window.innerWidth - winWidth.value));
posY.value = Math.max(0, Math.min(posY.value, window.innerHeight - h));
}
function saveLayout() {
localStorage.setItem(
props.storageKey,
JSON.stringify({
posX: posX.value,
posY: posY.value,
width: winWidth.value,
height: winHeight.value,
minimized: minimized.value,
}),
);
}
function bindDragListeners() {
document.addEventListener('mousemove', onDocumentMouseMove);
document.addEventListener('mouseup', onDocumentMouseUp);
}
function onTitleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
dragMode.value = 'move';
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
bindDragListeners();
}
/** 四角缩放开始:记录初始位置与尺寸 */
function onResizeMouseDown(e: MouseEvent, mode: DragMode) {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragMode.value = mode;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
originX.value = posX.value;
originY.value = posY.value;
originW.value = winWidth.value;
originH.value = winHeight.value;
bindDragListeners();
}
/** 左/上方向缩放时,宽高触达最小值需同步修正锚点位置 */
function applyHorizontalResizeFromLeft(dx: number) {
let newW = originW.value - dx;
let newX = originX.value + dx;
if (newW < props.minWidth) {
newX = originX.value + originW.value - props.minWidth;
newW = props.minWidth;
}
winWidth.value = newW;
posX.value = newX;
}
function applyVerticalResizeFromTop(dy: number) {
let newH = originH.value - dy;
let newY = originY.value + dy;
if (newH < props.minHeight) {
newY = originY.value + originH.value - props.minHeight;
newH = props.minHeight;
}
winHeight.value = newH;
posY.value = newY;
}
function onDocumentMouseMove(e: MouseEvent) {
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (dragMode.value === 'move') {
posX.value = originX.value + dx;
posY.value = originY.value + dy;
clampWindow();
return;
}
if (dragMode.value === 'resize-se') {
winWidth.value = originW.value + dx;
winHeight.value = originH.value + dy;
} else if (dragMode.value === 'resize-sw') {
applyHorizontalResizeFromLeft(dx);
winHeight.value = originH.value + dy;
} else if (dragMode.value === 'resize-ne') {
winWidth.value = originW.value + dx;
applyVerticalResizeFromTop(dy);
} else if (dragMode.value === 'resize-nw') {
applyHorizontalResizeFromLeft(dx);
applyVerticalResizeFromTop(dy);
}
clampWindow();
}
function onDocumentMouseUp() {
dragMode.value = '';
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
saveLayout();
}
function toggleMinimize() {
minimized.value = !minimized.value;
clampWindow();
saveLayout();
}
function handleClose() {
emit('update:open', false);
emit('close');
}
/** 阻止滚轮穿透到主页面:内部可滚动区域正常滚,其余区域拦截 */
function onWindowWheel(e: WheelEvent) {
e.stopPropagation();
let node = e.target as HTMLElement | null;
const root = e.currentTarget as HTMLElement;
while (node && node !== root) {
const style = getComputedStyle(node);
const canScrollY =
node.scrollHeight > node.clientHeight &&
(style.overflowY === 'auto' || style.overflowY === 'scroll');
if (canScrollY) {
const goingUp = e.deltaY < 0;
const goingDown = e.deltaY > 0;
const atTop = node.scrollTop <= 0;
const atBottom =
node.scrollTop + node.clientHeight >= node.scrollHeight - 1;
if ((goingUp && !atTop) || (goingDown && !atBottom)) return;
}
node = node.parentElement;
}
e.preventDefault();
}
watch(
() => props.open,
(v) => {
if (v) initLayout();
},
);
onMounted(() => {
if (props.open) initLayout();
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', onDocumentMouseMove);
document.removeEventListener('mouseup', onDocumentMouseUp);
});
</script>
<template>
<Teleport to="body">
<div v-if="open" class="sub-window-root">
<div
class="sub-window"
:class="{ minimized, 'is-dark': isDark }"
:style="windowStyle"
@wheel="onWindowWheel"
>
<div class="sub-window-header" @mousedown="onTitleMouseDown">
<span class="sub-window-title">{{ title }}</span>
<div class="sub-window-actions" @mousedown.stop>
<button type="button" class="action-btn" @click="toggleMinimize">
<MinusOutlined />
</button>
<button type="button" class="action-btn" @click="handleClose">
<CloseOutlined />
</button>
</div>
</div>
<div v-show="!minimized" class="sub-window-body">
<slot />
</div>
<template v-if="!minimized">
<div
class="resize-handle resize-handle-nw"
@mousedown="onResizeMouseDown($event, 'resize-nw')"
/>
<div
class="resize-handle resize-handle-ne"
@mousedown="onResizeMouseDown($event, 'resize-ne')"
/>
<div
class="resize-handle resize-handle-sw"
@mousedown="onResizeMouseDown($event, 'resize-sw')"
/>
<div
class="resize-handle resize-handle-se"
@mousedown="onResizeMouseDown($event, 'resize-se')"
/>
</template>
</div>
</div>
</Teleport>
</template>
<style scoped>
.sub-window-root {
pointer-events: none;
}
.sub-window {
position: fixed;
pointer-events: auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.sub-window.is-dark {
background: #1f1f1f;
border-color: #424242;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
}
.sub-window-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
border-bottom: 1px solid #e5e7eb;
cursor: move;
user-select: none;
flex-shrink: 0;
}
.sub-window.is-dark .sub-window-header {
background: linear-gradient(135deg, #1a2e22, #1f1f1f);
border-bottom-color: #424242;
}
.sub-window-title {
font-size: 14px;
font-weight: 600;
color: #1d2129;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sub-window.is-dark .sub-window-title {
color: rgba(255, 255, 255, 0.85);
}
.sub-window-actions {
display: flex;
gap: 4px;
}
.action-btn {
width: 28px;
height: 28px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #666;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
.sub-window.is-dark .action-btn {
color: rgba(255, 255, 255, 0.65);
}
.sub-window.is-dark .action-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.sub-window-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
}
/* 四角透明缩放热区,无视觉提示 */
.resize-handle {
position: absolute;
width: 16px;
height: 16px;
z-index: 20;
pointer-events: auto;
background: transparent;
}
.resize-handle-nw {
left: 0;
top: 0;
cursor: nwse-resize;
}
.resize-handle-ne {
right: 0;
top: 0;
cursor: nesw-resize;
}
.resize-handle-sw {
left: 0;
bottom: 0;
cursor: nesw-resize;
}
.resize-handle-se {
right: 0;
bottom: 0;
cursor: nwse-resize;
}
</style>

View File

@@ -0,0 +1,10 @@
/** 子窗口默认 z-index */
export const DEFAULT_SUB_WINDOW_Z_INDEX = 10000;
/** 子窗口内 Ant Design 弹出层 inject key */
export const SUB_WINDOW_POPUP_Z_INDEX_KEY = Symbol('subWindowPopupZIndex');
/** 根据子窗口 z-index 计算弹出层层级 */
export function getSubWindowPopupZIndex(baseZIndex = DEFAULT_SUB_WINDOW_Z_INDEX) {
return baseZIndex + 100;
}

View File

@@ -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,
};
}

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -105,6 +105,15 @@ export const formOptions: VbenFormProps = {
fieldName: 'prescription_type',
label: '订单类型',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '药品名称/拼音首拼',
},
defaultValue: '',
fieldName: 'drug_keyword',
label: '药品',
},
{
component: 'RangePicker',
componentProps: {

View File

@@ -52,7 +52,7 @@ export const gridOptions: VxeGridProps<RowType> = {
{
field: 'salesperson',
title: '推广员',
width: 180,
width: 220,
slots: { default: 'salesperson' },
},
{

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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);
}

View File

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

View File

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

View File

@@ -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_doseSKU 用套餐总价 */
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>

View File

@@ -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[];
/** 当前查看分摊的 SKU0=单帖,在 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>

View File

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

View File

@@ -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,
};
},
},
},

View File

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

View File

@@ -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}剂包邮` : '始终包邮';
}

View File

@@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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 });
}

View File

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