1. 推广员
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
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-05-18 13:33:27 +08:00
parent 0b1b08c7d7
commit c978b978ec
12 changed files with 1306 additions and 130 deletions

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Input, Modal, message } from 'ant-design-vue';
import UploadImage from '#/components/form/components/upload-image.vue';
export interface SettlementPreviewInfo {
record_count: number;
order_count?: number;
amount: string;
}
const props = defineProps<{
confirmFn: (data: Record<string, any>) => Promise<unknown>;
}>();
const emit = defineEmits<{
success: [];
}>();
const visible = ref(false);
const confirmLoading = ref(false);
const modalTitle = ref('确认结算');
const preview = ref<SettlementPreviewInfo | null>(null);
const settleParams = ref<Record<string, any>>({});
const proofImages = ref<string[]>([]);
const remark = ref('');
function open(
title: string,
previewInfo: SettlementPreviewInfo,
params: Record<string, any>,
) {
modalTitle.value = title;
preview.value = previewInfo;
settleParams.value = { ...params };
proofImages.value = [];
remark.value = '';
visible.value = true;
}
function close() {
visible.value = false;
}
async function handleOk() {
if (!proofImages.value.length) {
message.warning('请上传至少 1 张结算凭证(如转账截图)');
return Promise.reject();
}
confirmLoading.value = true;
try {
await props.confirmFn({
...settleParams.value,
proof_images: proofImages.value,
remark: remark.value.trim(),
});
message.success('结算成功');
close();
emit('success');
} finally {
confirmLoading.value = false;
}
}
defineExpose({ open });
</script>
<template>
<Modal
v-model:open="visible"
:confirm-loading="confirmLoading"
:title="modalTitle"
ok-text="确认结算"
width="560"
@ok="handleOk"
>
<div v-if="preview" class="mb-4 text-sm text-gray-600">
<p> {{ preview.record_count }} 条明细</p>
<p v-if="preview.order_count != null">{{ preview.order_count }} 个订单</p>
<p class="font-medium text-gray-900">合计 ¥{{ preview.amount }}</p>
</div>
<div class="mb-4">
<div class="mb-2 text-sm">
<span class="text-red-500">*</span> 结算凭证转账截图等最多 9
</div>
<UploadImage v-model="proofImages" :max-count="9" />
</div>
<div>
<div class="mb-2 text-sm text-gray-600">备注选填</div>
<Input.TextArea
v-model:value="remark"
:maxlength="255"
:rows="2"
placeholder="可填写转账说明等"
show-count
/>
</div>
</Modal>
</template>

View File

@@ -89,3 +89,62 @@ export async function salespersonCreateApi(data) {
export async function salespersonUpdateApi(data) {
return requestClient.post<any>(`salesperson/update`, data);
}
export interface DrugCommissionListItem {
drug_id: number;
drug_name: string;
pinyin_simple?: string;
image?: string;
drug_spec: string;
buy_price: string;
price: string;
commission: string;
}
export interface DrugCommissionListResult {
items: DrugCommissionListItem[];
total: number;
page: number;
pageSize: number;
}
export async function getDrugCommissionListApi(params: Record<string, any>) {
return requestClient.get<DrugCommissionListResult>(
`salesperson/drug-commission-list`,
{ params },
);
}
export async function saveDrugCommissionApi(data: {
salesperson_id: number;
items: { drug_id: number; commission: string }[];
}) {
return requestClient.post<any>(`salesperson/save-drug-commission`, data);
}
export async function getDrugCommissionCopyFromApi(fromSalespersonId: number) {
return requestClient.get<{ items: { drug_id: number; commission: string }[] }>(
`salesperson/drug-commission-copy-from`,
{ params: { from_salesperson_id: fromSalespersonId } },
);
}
export async function getCommissionListApi(params: Record<string, any>) {
return requestClient.get<any>(`salesperson/commission-list`, { params });
}
export async function getSettlementPreviewApi(params: Record<string, any>) {
return requestClient.get<any>(`salesperson/settlement-preview`, { params });
}
export async function confirmSettlementApi(data: Record<string, any>) {
return requestClient.post<any>(`salesperson/settlement`, data);
}
export async function getSettlementListApi(params: Record<string, any>) {
return requestClient.get<any>(`salesperson/settlement-list`, { params });
}
export async function getSettlementDetailApi(params: { id: number }) {
return requestClient.get<any>(`salesperson/settlement-detail`, { params });
}

View File

@@ -1,128 +1,159 @@
<script setup lang="ts">
import {useVbenModal} from '@vben/common-ui';
import { ref } from 'vue';
import {Button, Card, message} from 'ant-design-vue';
import {PlusOutlined} from '@ant-design/icons-vue';
import { useVbenModal } from '@vben/common-ui';
import { Button, Card, Empty, Space, Tag } from 'ant-design-vue';
import { salespersonListApi } from '#/views/business/store/settings/api';
import QrCodePreview from '#/views/business/store/settings/components/SalespersonQrCodePreview.vue';
import salespersonCreateModal
from "#/views/business/store/settings/components/SalespersonCreateModal.vue";
import {ref} from "vue";
import {salespersonListApi} from "#/views/business/store/settings/api";
import SalespersonCommissionDrawer from '#/views/business/store/settings/components/SalespersonCommissionDrawer.vue';
import SalespersonCreateModal from '#/views/business/store/settings/components/SalespersonCreateModal.vue';
// defineProps<{
// data: any;
// }>();
const data = ref([]);
const listData = ref<{ total: number; items: any[] }>({
total: 0,
items: [],
});
const getList = () => {
salespersonListApi().then((res) => {
data.value = res;
})
}
listData.value = {
total: res?.total ?? 0,
items: res?.items ?? [],
};
});
};
getList();
const [SalespersonCreateModal, SalespersonCreateModalApi] = useVbenModal({
connectedComponent: salespersonCreateModal,
const [SalespersonCreateModalComponent, SalespersonCreateModalApi] = useVbenModal({
connectedComponent: SalespersonCreateModal,
});
const [QrCodePreviewModal, QrCodePreviewApi] = useVbenModal({
connectedComponent: QrCodePreview,
});
const openDoctorQrCode = (data) => {
QrCodePreviewApi.setData({
values: data,
const commissionDrawerRef = ref<InstanceType<typeof SalespersonCommissionDrawer>>();
function openCommissionDrawer(item: any) {
commissionDrawerRef.value?.open({
id: item.id,
nick_name: item.nick_name,
});
}
function openQrCode(item: any) {
QrCodePreviewApi.setData({ values: item });
QrCodePreviewApi.open();
};
const openSalespersonModal = () => {
}
function openCreateModal() {
SalespersonCreateModalApi.setData({ getList });
SalespersonCreateModalApi.open();
};
const openUpdateSalespersonModal = (values) => {
}
function openEditModal(item: any) {
SalespersonCreateModalApi.setData({
values,
values: item,
getList,
update: true
})
update: true,
});
SalespersonCreateModalApi.open();
};
}
function splitTypeLabel(type: number) {
return Number(type) === 1 ? '百分比' : '固定金额';
}
function splitTypeColor(type: number) {
return Number(type) === 1 ? 'blue' : 'orange';
}
</script>
<template>
<div>
<SalespersonCreateModal/>
<QrCodePreviewModal/>
<Card :title="`您拥有${data.total}个推广员`" class="w-full">
<template #extra>
<Button type="primary" @click="openSalespersonModal">新增推广员</Button>
</template>
<SalespersonCreateModalComponent />
<QrCodePreviewModal />
<SalespersonCommissionDrawer ref="commissionDrawerRef" @settled="getList" />
<div v-if="!listData.items.length" class="py-12">
<Empty description="暂无推广员">
<Button type="primary" @click="openCreateModal">新增推广员</Button>
</Empty>
</div>
<template v-else>
<div class="mb-4 flex items-center justify-between">
<span class="text-sm text-gray-600"
> {{ listData.total }} 位推广员</span
>
<Button type="primary" @click="openCreateModal">新增推广员</Button>
</div>
<div
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<Card
v-for="(item, index) in data.items"
:key="index"
class="rounded-lg shadow-sm"
>
<!-- 顶部信息栏 -->
<div class="mb-4 flex items-start justify-between">
<h3 class="text-lg font-semibold ">
{{ item.nick_name || '未知' }}
</h3>
</div>
<Card
v-for="item in listData.items"
:key="item.id"
class="rounded-lg shadow-sm"
>
<div class="mb-3 flex items-start justify-between gap-2">
<h3 class="text-lg font-semibold">{{ item.nick_name || '未知' }}</h3>
<Tag :color="splitTypeColor(item.split_type)">
{{ splitTypeLabel(item.split_type) }}
<template v-if="Number(item.split_type) === 1">
· {{ item.split }}%
</template>
</Tag>
</div>
<!-- 医生信息 -->
<div class="mb-4 space-y-2 text-sm">
<div class="flex justify-between">
<span>手机号:</span>
<span class="">{{ item.phone }}</span>
</div>
<div class="flex justify-between">
<span>加入时间:</span>
<span>{{
item.created_at
}}</span>
</div>
<div class="flex justify-between">
<span>累计获客:</span>
<span class="font-medium text-blue-600">{{ item.count.user_number }}</span>
</div>
<div class="flex justify-between">
<span>总佣金:</span>
<span class="font-medium text-blue-600">{{ item.count.money }}</span>
</div>
<div class="mb-4 space-y-1.5 text-sm text-gray-600">
<div>{{ item.phone }}</div>
<div>加入{{ item.created_at }}</div>
<div class="flex justify-between pt-1">
<span>累计获客</span>
<span class="font-medium text-blue-600">{{
item.count?.user_number ?? 0
}}</span>
</div>
<!-- 二维码区域 -->
<div class="border-t border-gray-100 pt-4">
<p class="mb-2 text-sm">联系二维码:</p>
<div
v-if="item.qr_code.qr_code"
class="cursor-pointer"
<div class="flex justify-between">
<span>待结算</span>
<span class="font-medium text-orange-600"
>¥{{ item.count?.pending_amount || '0.00' }}</span
>
<Button
class="w-full"
size="small"
type="primary"
@click="openDoctorQrCode(item)"
>
查看二维码
</Button>
</div>
<div v-else class="py-2 text-center text-sm text-gray-400">
暂无二维码
</div>
<Button @click="openUpdateSalespersonModal(item)" class="w-full mt-3" size="small"
type="primary">编辑
</Button>
</div>
</Card>
<div class="flex justify-between">
<span>已结算</span>
<span class="font-medium text-green-600"
>¥{{ item.count?.settled_amount || '0.00' }}</span
>
</div>
</div>
<Space wrap class="w-full">
<Button size="small" type="primary" @click="openCommissionDrawer(item)">
分成与结算
</Button>
<Button size="small" @click="openEditModal(item)">编辑</Button>
<Button
v-if="Number(item.split_type) === 0"
size="small"
type="primary"
@click="openEditModal(item)"
>
药品分成
</Button>
<Button
v-if="item.qr_code?.qr_code"
size="small"
@click="openQrCode(item)"
>
二维码
</Button>
</Space>
</Card>
</div>
</Card>
</template>
</div>
</template>

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
Button,
DatePicker,
Drawer,
Image,
Input,
message,
Table,
Tabs,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import SalespersonSettlementConfirmModal from '#/components/salesperson/SalespersonSettlementConfirmModal.vue';
import {
confirmSettlementApi,
getCommissionListApi,
getSettlementDetailApi,
getSettlementListApi,
getSettlementPreviewApi,
} from '#/views/business/store/settings/api';
const emit = defineEmits<{
settled: [];
}>();
const visible = ref(false);
const salespersonId = ref(0);
const salespersonName = ref('');
const activeTab = ref('pending');
const loading = ref(false);
const commissionData = ref<any[]>([]);
const commissionTotal = ref(0);
const page = ref(1);
const pageSize = ref(20);
const orderNo = ref('');
const dateRange = ref<any[]>([]);
const selectedOrderIds = ref<number[]>([]);
const selectedRowKeys = ref<number[]>([]);
const settlementList = ref<any[]>([]);
const settlementDetail = ref<any>(null);
const detailVisible = ref(false);
const periodRange = ref<any[]>([]);
const settlementConfirmRef = ref<InstanceType<typeof SalespersonSettlementConfirmModal>>();
const commissionColumns = [
{ title: '订单号', dataIndex: 'order_no', width: 160 },
{ title: '药品信息', dataIndex: 'drug_info', ellipsis: true },
{ title: '分成金额', dataIndex: 'commission_amount', width: 100 },
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 170 },
];
const settlementColumns = [
{ title: '结算单号', dataIndex: 'settlement_no', width: 160 },
{ title: '方式', dataIndex: 'settlement_type_text', width: 100 },
{ title: '金额', dataIndex: 'amount', width: 100 },
{ title: '明细数', dataIndex: 'record_count', width: 80 },
{ title: '时间', dataIndex: 'created_at_text', width: 170 },
{ title: '操作', key: 'action', width: 80 },
];
const status = computed(() =>
activeTab.value === 'pending' ? 0 : activeTab.value === 'settled' ? 1 : undefined,
);
const rowSelection = computed(() =>
activeTab.value === 'pending'
? {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[], rows: any[]) => {
selectedRowKeys.value = keys as number[];
selectedOrderIds.value = [...new Set(rows.map((r) => r.order_id))];
},
getCheckboxProps: () => ({ disabled: false }),
}
: undefined,
);
function open(salesperson: { id: number; nick_name?: string }) {
salespersonId.value = salesperson.id;
salespersonName.value = salesperson.nick_name || '';
selectedOrderIds.value = [];
selectedRowKeys.value = [];
orderNo.value = '';
dateRange.value = [];
periodRange.value = [];
page.value = 1;
activeTab.value = 'pending';
visible.value = true;
loadCommission();
}
function onPageChange(p: number) {
page.value = p;
loadCommission();
}
async function loadCommission() {
if (!salespersonId.value) return;
loading.value = true;
try {
const params: Record<string, any> = {
salesperson_id: salespersonId.value,
page: page.value,
pageSize: pageSize.value,
order_no: orderNo.value,
};
if (status.value !== undefined) params.status = status.value;
if (dateRange.value?.length === 2) {
params.start_at = dayjs(dateRange.value[0]).startOf('day').unix();
params.end_at = dayjs(dateRange.value[1]).endOf('day').unix();
}
const res = await getCommissionListApi(params);
commissionData.value = res?.items || [];
commissionTotal.value = res?.total || 0;
} finally {
loading.value = false;
}
}
async function loadSettlements() {
if (!salespersonId.value) return;
const res = await getSettlementListApi({
salesperson_id: salespersonId.value,
page: 1,
pageSize: 50,
});
settlementList.value = res?.items || [];
}
async function handleSettleByPeriod() {
if (!periodRange.value?.length || periodRange.value.length !== 2) {
message.warning('请选择结算时间段');
return;
}
const params = {
salesperson_id: salespersonId.value,
settlement_type: 1,
period_start: dayjs(periodRange.value[0]).startOf('day').unix(),
period_end: dayjs(periodRange.value[1]).endOf('day').unix(),
};
const preview = await getSettlementPreviewApi(params);
settlementConfirmRef.value?.open('确认按时间段结算', preview, params);
}
function onSettlementSuccess() {
selectedOrderIds.value = [];
selectedRowKeys.value = [];
loadCommission();
loadSettlements();
emit('settled');
}
async function handleSettleByOrders() {
if (!selectedOrderIds.value.length) {
message.warning('请勾选订单');
return;
}
const params = {
salesperson_id: salespersonId.value,
settlement_type: 2,
order_ids: selectedOrderIds.value,
};
const preview = await getSettlementPreviewApi(params);
settlementConfirmRef.value?.open('确认结算选中订单', preview, params);
}
async function showSettlementDetail(id: number) {
settlementDetail.value = await getSettlementDetailApi({ id });
detailVisible.value = true;
}
watch(activeTab, (tab) => {
page.value = 1;
if (tab === 'history') loadSettlements();
else loadCommission();
});
defineExpose({ open });
</script>
<template>
<Drawer
v-model:open="visible"
:title="`分成与结算 - ${salespersonName}`"
width="920"
destroy-on-close
>
<div class="mb-4 flex flex-wrap items-center gap-3">
<Input
v-model:value="orderNo"
allow-clear
placeholder="订单号"
style="width: 180px"
@press-enter="loadCommission"
/>
<DatePicker.RangePicker v-model:value="dateRange" />
<Button type="primary" @click="loadCommission">查询</Button>
</div>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="pending" tab="待结算">
<div class="mb-3 flex flex-wrap gap-2">
<DatePicker.RangePicker
v-model:value="periodRange"
placeholder="['开始','结束']"
/>
<Button type="primary" @click="handleSettleByPeriod">按时间段结算</Button>
<Button type="primary" ghost @click="handleSettleByOrders">
结算选中订单
</Button>
</div>
<Table
:columns="commissionColumns"
:data-source="commissionData"
:loading="loading"
:pagination="{
current: page,
pageSize,
total: commissionTotal,
onChange: onPageChange,
}"
:row-key="(r) => r.id"
:row-selection="rowSelection"
size="small"
/>
</Tabs.TabPane>
<Tabs.TabPane key="settled" tab="已结算">
<Table
:columns="commissionColumns"
:data-source="commissionData"
:loading="loading"
:pagination="{
current: page,
pageSize,
total: commissionTotal,
onChange: onPageChange,
}"
:row-key="(r) => r.id"
size="small"
/>
</Tabs.TabPane>
<Tabs.TabPane key="history" tab="结算记录">
<Table
:columns="settlementColumns"
:data-source="settlementList"
:row-key="(r) => r.id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="showSettlementDetail(record.id)">
详情
</Button>
</template>
</template>
</Table>
</Tabs.TabPane>
</Tabs>
<SalespersonSettlementConfirmModal
ref="settlementConfirmRef"
:confirm-fn="confirmSettlementApi"
@success="onSettlementSuccess"
/>
<Drawer v-model:open="detailVisible" title="结算单详情" width="720" destroy-on-close>
<div v-if="settlementDetail" class="mb-3">
<p>单号:{{ settlementDetail.settlement_no }}</p>
<p>
金额:¥{{ settlementDetail.amount }}{{ settlementDetail.record_count }} 条)
</p>
<p v-if="settlementDetail.remark">备注:{{ settlementDetail.remark }}</p>
<div v-if="settlementDetail.proof_images?.length" class="mt-3">
<p class="mb-2 text-sm text-gray-600">结算凭证</p>
<Image.PreviewGroup>
<Image
v-for="(url, idx) in settlementDetail.proof_images"
:key="idx"
:src="url"
:width="96"
:height="96"
class="mr-2 rounded object-cover"
/>
</Image.PreviewGroup>
</div>
</div>
<Table
:columns="commissionColumns"
:data-source="settlementDetail?.records || []"
:row-key="(r) => r.id"
size="small"
/>
</Drawer>
</Drawer>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
@@ -9,15 +9,38 @@ import { useVbenForm } from '#/adapter/form';
import {
salespersonCreateApi,
salespersonUpdateApi,
saveNavUrlApi
saveDrugCommissionApi,
} from '#/views/business/store/settings/api';
import SalespersonDrugCommissionEditor from '#/views/business/store/settings/components/SalespersonDrugCommissionEditor.vue';
import { salespersonModalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const splitType = ref(0);
const salespersonId = ref(0);
const editorRef = ref<InstanceType<typeof SalespersonDrugCommissionEditor>>();
const [Form, formApi] = useVbenForm(salespersonModalFormProps);
const isFixedAmount = computed(() => Number(splitType.value) === 0);
const modalClass = computed(() =>
isFixedAmount.value ? 'w-[960px]' : 'w-[480px]',
);
watch(splitType, (val) => {
if (Number(val) === 0) {
nextTick(() => editorRef.value?.loadList());
} else {
editorRef.value?.resetDirty();
}
});
const [Form, formApi] = useVbenForm({
...salespersonModalFormProps,
handleValuesChange(values: Record<string, any>) {
if (values.split_type !== undefined) {
splitType.value = Number(values.split_type);
}
},
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
@@ -26,43 +49,75 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
? salespersonUpdateApi
: salespersonCreateApi;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
const payload = { ...values };
if (Number(payload.split_type) === 0) {
payload.split = 0;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
const submitApi = isUpdate.value ? salespersonUpdateApi : salespersonCreateApi;
const res = await submitApi(payload);
const spId = isUpdate.value
? Number(payload.id)
: Number(res?.id ?? res);
if (Number(payload.split_type) === 0 && spId > 0) {
const dirtyItems = editorRef.value?.getDirtyItems() ?? [];
if (dirtyItems.length > 0) {
await saveDrugCommissionApi({
salesperson_id: spId,
items: dirtyItems,
});
}
}
});
await formApi.validateAndSubmitForm();
message.success('保存成功');
modalApi.getData()?.getList?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.getList : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
isUpdate.value = update;
formApi.setValues(values);
}
if (!isOpen) {
editorRef.value?.resetDirty();
return;
}
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (values) {
formApi.setValues(values);
splitType.value = Number(values.split_type ?? 0);
salespersonId.value = Number(values.id ?? 0);
} else {
formApi.resetForm();
splitType.value = 0;
salespersonId.value = 0;
}
},
});
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}推广员`"
class="w-[30%]"
>
<Modal :title="`${isUpdate ? '编辑' : '新增'}推广员`" :class="modalClass">
<Form />
<p
v-if="isFixedAmount && !isUpdate"
class="mb-2 text-sm text-gray-500"
>
可先选择已有推广员复制药品分成再微调后保存
</p>
<SalespersonDrugCommissionEditor
v-if="isFixedAmount"
ref="editorRef"
:salesperson-id="salespersonId"
:exclude-salesperson-id="salespersonId"
class="mt-4"
/>
</Modal>
</template>

View File

@@ -0,0 +1,267 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import {
Button,
Image,
Input,
InputNumber,
message,
Select,
Table,
} from 'ant-design-vue';
import {
getDrugCommissionCopyFromApi,
getDrugCommissionListApi,
salespersonListApi,
type DrugCommissionListItem,
} from '#/views/business/store/settings/api';
export interface DrugCommissionRow extends DrugCommissionListItem {}
const props = defineProps<{
salespersonId?: number;
excludeSalespersonId?: number;
}>();
const keyword = ref('');
const loading = ref(false);
const items = ref<DrugCommissionRow[]>([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const editingDrugId = ref<number | null>(null);
const dirtyMap = ref<Record<number, string>>({});
const salespersonOptions = ref<{ label: string; value: number }[]>([]);
const selectedCopyFromId = ref<number | undefined>();
const columns = [
{ title: '药品', key: 'drug_info', width: 280 },
{ title: '供货价', dataIndex: 'buy_price', width: 90, align: 'right' as const },
{ title: '售价', dataIndex: 'price', width: 90, align: 'right' as const },
{ title: '分成(元/件)', key: 'commission', width: 120, align: 'right' as const },
];
function formatMoney(val: string | number) {
const n = Number(val);
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '-';
}
function mergeRowCommission(row: DrugCommissionRow): DrugCommissionRow {
const dirty = dirtyMap.value[row.drug_id];
return dirty !== undefined ? { ...row, commission: dirty } : row;
}
async function loadList() {
loading.value = true;
try {
const res = await getDrugCommissionListApi({
salesperson_id: props.salespersonId || 0,
keyword: keyword.value,
page: page.value,
pageSize: pageSize.value,
});
items.value = (res?.items || []).map((row) => mergeRowCommission(row));
total.value = res?.total || 0;
} finally {
loading.value = false;
}
}
async function loadSalespersonOptions() {
const res = await salespersonListApi();
const list = res?.items || [];
salespersonOptions.value = list
.filter((sp: any) => Number(sp.id) !== Number(props.excludeSalespersonId || 0))
.map((sp: any) => ({
label: sp.nick_name || sp.phone || `推广员#${sp.id}`,
value: Number(sp.id),
}));
}
function onPageChange(p: number) {
page.value = p;
loadList();
}
function startEdit(drugId: number) {
editingDrugId.value = drugId;
}
function finishEdit(record: DrugCommissionRow) {
const val = String(record.commission ?? '0');
dirtyMap.value[record.drug_id] = val;
editingDrugId.value = null;
}
function applyCommissionItems(
commissionItems: { drug_id: number; commission: string }[],
) {
commissionItems.forEach((item) => {
dirtyMap.value[item.drug_id] = String(item.commission ?? '0');
});
items.value = items.value.map((row) => mergeRowCommission(row));
message.success(`已复制 ${commissionItems.length} 条药品分成`);
}
function getDirtyItems(): { drug_id: number; commission: string }[] {
return Object.entries(dirtyMap.value).map(([drugId, commission]) => ({
drug_id: Number(drugId),
commission,
}));
}
function resetDirty() {
dirtyMap.value = {};
selectedCopyFromId.value = undefined;
}
async function onCopyFromSelect(id: number | undefined) {
if (!id) return;
try {
const res = await getDrugCommissionCopyFromApi(id);
applyCommissionItems(res?.items || []);
} catch {
message.error('复制失败');
} finally {
selectedCopyFromId.value = undefined;
}
}
const dirtyCount = computed(() => Object.keys(dirtyMap.value).length);
watch(
() => props.salespersonId,
() => {
page.value = 1;
resetDirty();
loadList();
},
{ immediate: true },
);
watch(
() => props.excludeSalespersonId,
() => {
loadSalespersonOptions();
},
);
onMounted(() => {
loadSalespersonOptions();
});
defineExpose({
getDirtyItems,
resetDirty,
loadList,
applyCommissionItems,
});
</script>
<template>
<div class="salesperson-drug-editor">
<div class="mb-3 flex flex-wrap items-center gap-2">
<Input
v-model:value="keyword"
allow-clear
placeholder="药品名称 / 拼音首拼"
style="width: 220px"
@press-enter="loadList"
/>
<Button type="primary" @click="loadList">查询</Button>
<Select
v-model:value="selectedCopyFromId"
:options="salespersonOptions"
allow-clear
placeholder="复用推广员分成"
style="width: 200px"
@change="onCopyFromSelect"
/>
<span class="text-xs text-gray-500">
选择已有推广员可一键复制其药品分成不含中药点击分成可编辑待保存
{{ dirtyCount }}
</span>
</div>
<Table
:columns="columns"
:data-source="items"
:loading="loading"
:pagination="{ current: page, pageSize, total, onChange: onPageChange }"
:row-key="(r) => r.drug_id"
:scroll="{ y: 360 }"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'drug_info'">
<div class="flex items-center gap-2">
<Image
v-if="record.image"
:src="record.image"
:width="40"
:height="40"
class="shrink-0 rounded object-cover"
/>
<div
v-else
class="flex h-10 w-10 shrink-0 items-center justify-center rounded bg-gray-100 text-xs text-gray-400"
>
无图
</div>
<div class="min-w-0 flex-1">
<div class="truncate font-medium">{{ record.drug_name }}</div>
<div v-if="record.pinyin_simple" class="text-xs text-gray-400">
{{ record.pinyin_simple }}
</div>
<div v-if="record.drug_spec" class="truncate text-xs text-gray-500">
{{ record.drug_spec }}
</div>
</div>
</div>
</template>
<template v-else-if="column.key === 'commission'">
<InputNumber
v-if="editingDrugId === record.drug_id"
v-model:value="record.commission"
:min="0"
:precision="2"
class="w-full"
size="small"
autofocus
@blur="finishEdit(record)"
@press-enter="finishEdit(record)"
/>
<span
v-else
class="commission-cell"
@click="startEdit(record.drug_id)"
>
{{ formatMoney(record.commission) }}
</span>
</template>
<template v-else-if="column.dataIndex === 'buy_price'">
{{ formatMoney(record.buy_price) }}
</template>
<template v-else-if="column.dataIndex === 'price'">
{{ formatMoney(record.price) }}
</template>
</template>
</Table>
</div>
</template>
<style scoped>
.commission-cell {
display: inline-block;
min-width: 72px;
padding: 2px 8px;
color: #1677ff;
cursor: pointer;
border-radius: 4px;
}
.commission-cell:hover {
background: #e6f4ff;
}
</style>

View File

@@ -82,10 +82,24 @@ export const salespersonModalFormProps: VbenFormProps = {
defaultValue: 0,
},
{
component: 'Input',
component: 'InputNumber',
fieldName: 'split',
label: '分成',
rules: 'required',
label: '分成比例(%',
componentProps: {
min: 0,
max: 100,
precision: 2,
class: 'w-full',
},
dependencies: {
show(values) {
return Number(values?.split_type) === 1;
},
rules(values) {
return Number(values?.split_type) === 1 ? 'required' : '';
},
triggerFields: ['split_type'],
},
},
// {
// component: 'UploadImage',

View File

@@ -728,8 +728,16 @@ const handleSwitchClinicType = () => {
</ImagePreviewGroup>
</Card>
<Card v-else-if="activeTabBar === 6" title="推广员">
<SalespersonCard :data="data" />
<Card
v-else-if="activeTabBar === 6"
title="推广员管理"
>
<template #extra>
<span class="text-sm text-gray-500"
>固定金额需配置各药品每件佣金</span
>
</template>
<SalespersonCard />
</Card>
</Page>
</template>

View File

@@ -0,0 +1,35 @@
import { requestClient } from '#/api/request';
const prefix = 'salesperson/';
export function getSalespersonList(params: { store_id: number; page?: number; pageSize?: number }) {
return requestClient.get(`${prefix}list`, { params });
}
export function getCommissionList(params: Record<string, any>) {
return requestClient.get(`${prefix}commission-list`, { params });
}
export function getSettlementPreview(params: Record<string, any>) {
return requestClient.get(`${prefix}settlement-preview`, { params });
}
export function confirmSettlement(data: Record<string, any>) {
return requestClient.post(`${prefix}settlement`, data);
}
export function getSettlementList(params: Record<string, any>) {
return requestClient.get(`${prefix}settlement-list`, { params });
}
export function getSettlementDetail(params: { id: number; store_id: number }) {
return requestClient.get(`${prefix}settlement-detail`, { params });
}
export function getDrugCommissionList(params: Record<string, any>) {
return requestClient.get(`${prefix}drug-commission-list`, { params });
}
export function saveDrugCommission(data: Record<string, any>) {
return requestClient.post(`${prefix}save-drug-commission`, data);
}

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
Button,
DatePicker,
Drawer,
Image,
Input,
message,
Select,
Table,
Tabs,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import SalespersonSettlementConfirmModal from '#/components/salesperson/SalespersonSettlementConfirmModal.vue';
import {
confirmSettlement,
getCommissionList,
getSalespersonList,
getSettlementDetail,
getSettlementList,
getSettlementPreview,
} from '../api/salesperson';
const visible = ref(false);
const storeId = ref(0);
const storeName = ref('');
const salespersonId = ref<number | undefined>();
const salespersonOptions = ref<any[]>([]);
const activeTab = ref('pending');
const loading = ref(false);
const commissionData = ref<any[]>([]);
const commissionTotal = ref(0);
const page = ref(1);
const pageSize = ref(20);
const orderNo = ref('');
const dateRange = ref<any[]>([]);
const selectedOrderIds = ref<number[]>([]);
const selectedRowKeys = ref<number[]>([]);
const settlementList = ref<any[]>([]);
const settlementDetail = ref<any>(null);
const detailVisible = ref(false);
const periodRange = ref<any[]>([]);
const settlementConfirmRef = ref<InstanceType<typeof SalespersonSettlementConfirmModal>>();
const commissionColumns = [
{ title: '订单号', dataIndex: 'order_no', width: 160 },
{ title: '药品信息', dataIndex: 'drug_info', ellipsis: true },
{ title: '分成金额', dataIndex: 'commission_amount', width: 100 },
{ title: '分成时间', dataIndex: 'order_pay_at_text', width: 170 },
];
const settlementColumns = [
{ title: '结算单号', dataIndex: 'settlement_no', width: 160 },
{ title: '方式', dataIndex: 'settlement_type_text', width: 100 },
{ title: '金额', dataIndex: 'amount', width: 100 },
{ title: '明细数', dataIndex: 'record_count', width: 80 },
{ title: '时间', dataIndex: 'created_at_text', width: 170 },
{ title: '操作', key: 'action', width: 80 },
];
const status = computed(() => (activeTab.value === 'pending' ? 0 : activeTab.value === 'settled' ? 1 : undefined));
const rowSelection = computed(() =>
activeTab.value === 'pending'
? {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys: number[], rows: any[]) => {
selectedRowKeys.value = keys as number[];
selectedOrderIds.value = [...new Set(rows.map((r) => r.order_id))];
},
getCheckboxProps: () => ({ disabled: false }),
}
: undefined,
);
function open(row: { id: number; name: string }) {
storeId.value = row.id;
storeName.value = row.name;
salespersonId.value = undefined;
selectedOrderIds.value = [];
selectedRowKeys.value = [];
visible.value = true;
activeTab.value = 'pending';
loadSalespersons();
}
function onPageChange(p: number) {
page.value = p;
loadCommission();
}
async function loadSalespersons() {
const res = await getSalespersonList({ store_id: storeId.value, page: 1, pageSize: 100 });
salespersonOptions.value = res?.items || [];
if (!salespersonId.value && salespersonOptions.value.length) {
salespersonId.value = salespersonOptions.value[0].id;
}
loadCommission();
}
async function loadCommission() {
if (!storeId.value || !salespersonId.value) return;
loading.value = true;
try {
const params: any = {
store_id: storeId.value,
salesperson_id: salespersonId.value,
page: page.value,
pageSize: pageSize.value,
order_no: orderNo.value,
};
if (status.value !== undefined) params.status = status.value;
if (dateRange.value?.length === 2) {
params.start_at = dayjs(dateRange.value[0]).startOf('day').unix();
params.end_at = dayjs(dateRange.value[1]).endOf('day').unix();
}
const res = await getCommissionList(params);
commissionData.value = res?.items || [];
commissionTotal.value = res?.total || 0;
} finally {
loading.value = false;
}
}
async function loadSettlements() {
if (!storeId.value || !salespersonId.value) return;
const res = await getSettlementList({
store_id: storeId.value,
salesperson_id: salespersonId.value,
page: 1,
pageSize: 50,
});
settlementList.value = res?.items || [];
}
async function handleSettleByPeriod() {
if (!periodRange.value?.length || periodRange.value.length !== 2) {
message.warning('请选择结算时间段');
return;
}
const params = {
store_id: storeId.value,
salesperson_id: salespersonId.value,
settlement_type: 1,
period_start: dayjs(periodRange.value[0]).startOf('day').unix(),
period_end: dayjs(periodRange.value[1]).endOf('day').unix(),
};
const preview = await getSettlementPreview(params);
settlementConfirmRef.value?.open('确认按时间段结算', preview, params);
}
function onSettlementSuccess() {
selectedOrderIds.value = [];
selectedRowKeys.value = [];
loadCommission();
loadSettlements();
}
async function handleSettleByOrders() {
if (!selectedOrderIds.value.length) {
message.warning('请勾选订单');
return;
}
const params = {
store_id: storeId.value,
salesperson_id: salespersonId.value,
settlement_type: 2,
order_ids: selectedOrderIds.value,
};
const preview = await getSettlementPreview(params);
settlementConfirmRef.value?.open('确认结算选中订单', preview, params);
}
async function showSettlementDetail(id: number) {
settlementDetail.value = await getSettlementDetail({ id, store_id: storeId.value });
detailVisible.value = true;
}
watch(activeTab, (tab) => {
page.value = 1;
if (tab === 'history') loadSettlements();
else loadCommission();
});
watch(salespersonId, () => {
page.value = 1;
loadCommission();
if (activeTab.value === 'history') loadSettlements();
});
defineExpose({ open });
</script>
<template>
<Drawer v-model:open="visible" :title="`推广员分成 - ${storeName}`" width="920" destroy-on-close>
<div class="mb-4 flex flex-wrap items-center gap-3">
<span>推广员</span>
<Select
v-model:value="salespersonId"
:options="salespersonOptions.map((s) => ({ label: s.nick_name, value: s.id }))"
style="width: 200px"
/>
<Input v-model:value="orderNo" allow-clear placeholder="订单号" style="width: 180px" @press-enter="loadCommission" />
<DatePicker.RangePicker v-model:value="dateRange" />
<Button type="primary" @click="loadCommission">查询</Button>
</div>
<Tabs v-model:activeKey="activeTab">
<Tabs.TabPane key="pending" tab="待结算">
<div class="mb-3 flex flex-wrap gap-2">
<DatePicker.RangePicker v-model:value="periodRange" placeholder="['开始','结束']" />
<Button type="primary" @click="handleSettleByPeriod">按时间段结算</Button>
<Button type="primary" ghost @click="handleSettleByOrders">结算选中订单</Button>
</div>
<Table
:columns="commissionColumns"
:data-source="commissionData"
:loading="loading"
:pagination="{ current: page, pageSize, total: commissionTotal, onChange: onPageChange }"
:row-key="(r) => r.id"
:row-selection="rowSelection"
size="small"
/>
</Tabs.TabPane>
<Tabs.TabPane key="settled" tab="已结算">
<Table
:columns="commissionColumns"
:data-source="commissionData"
:loading="loading"
:pagination="{ current: page, pageSize, total: commissionTotal, onChange: onPageChange }"
:row-key="(r) => r.id"
size="small"
/>
</Tabs.TabPane>
<Tabs.TabPane key="history" tab="结算记录">
<Table :columns="settlementColumns" :data-source="settlementList" :row-key="(r) => r.id" size="small">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="showSettlementDetail(record.id)">详情</Button>
</template>
</template>
</Table>
</Tabs.TabPane>
</Tabs>
<SalespersonSettlementConfirmModal
ref="settlementConfirmRef"
:confirm-fn="confirmSettlement"
@success="onSettlementSuccess"
/>
<Drawer v-model:open="detailVisible" title="结算单详情" width="720" destroy-on-close>
<div v-if="settlementDetail" class="mb-3">
<p>单号:{{ settlementDetail.settlement_no }}</p>
<p>金额:¥{{ settlementDetail.amount }}{{ settlementDetail.record_count }} 条)</p>
<p v-if="settlementDetail.remark">备注:{{ settlementDetail.remark }}</p>
<div v-if="settlementDetail.proof_images?.length" class="mt-3">
<p class="mb-2 text-sm text-gray-600">结算凭证</p>
<Image.PreviewGroup>
<Image
v-for="(url, idx) in settlementDetail.proof_images"
:key="idx"
:src="url"
:width="96"
:height="96"
class="mr-2 rounded object-cover"
/>
</Image.PreviewGroup>
</div>
</div>
<Table
:columns="commissionColumns"
:data-source="settlementDetail?.records || []"
:row-key="(r) => r.id"
size="small"
/>
</Drawer>
</Drawer>
</template>

View File

@@ -77,8 +77,8 @@ export const gridOptions: VxeGridProps<RowType> = {
},
// { field: 'shouzimu', title: '诊所首字母' },
{ field: 'position', title: '详细地址' },
{ field: 'new_admin.nick_name', title: '业务员' },
// { field: 'contact', title: '联系人' },
{ field: 'new_admin.nick_name', title: '业务员', slots: { default: 'sales_admin' } },
{ field: 'store_admin.phone', title: '管理员手机', slots: { default: 'store_admin_phone' } },
{ field: 'mobile', title: '联系人、电话', slots: { default: 'mobile' } },
{
field: 'start_time',

View File

@@ -28,6 +28,7 @@ import FormModalDemo from './components/modal.vue';
import DrugPriceModal from './components/DrugPriceModal.vue';
import BindConsultationModal from './components/BindConsultationModal.vue';
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
@@ -177,6 +178,12 @@ function copyText(source) {
}
const { copy } = useClipboard({ legacy: true });
const salespersonDrawerRef = ref<InstanceType<typeof SalespersonCommissionDrawer>>();
const openSalespersonCommission = (row: { id: number; name: string }) => {
salespersonDrawerRef.value?.open(row);
};
/**
* 更新包邮状态
*/
@@ -255,6 +262,7 @@ const handleSwitchClinicType = (row: any) => {
<DrugPriceModalComponent />
<BindConsultationModalComponent />
<StoreExternalFieldModalComponent />
<SalespersonCommissionDrawer ref="salespersonDrawerRef" />
<Grid>
<template #toolbar-buttons>
<TableAction
@@ -306,6 +314,15 @@ const handleSwitchClinicType = (row: any) => {
</template>
</TableAction>
</template>
<template #sales_admin="{ row }">
<div>
<div>{{ row.new_admin?.nick_name || '-' }}</div>
<div v-if="row.new_admin?.phone" class="text-xs text-gray-500">{{ row.new_admin.phone }}</div>
</div>
</template>
<template #store_admin_phone="{ row }">
{{ row.store_admin?.phone || '-' }}
</template>
<template #mobile="{ row }">
<div>
诊所联系人{{ row.contact }}
@@ -486,6 +503,14 @@ const handleSwitchClinicType = (row: any) => {
ifShow: isPlatformAdmin,
onClick: showDrugPriceModal.bind(null, row),
},
{
label: '推广员分成',
type: 'link',
icon: 'ant-design:account-book-outlined',
size: 'small',
auth: ['Super Admin', 'Admin'],
onClick: () => openSalespersonCommission(row),
},
]"
/>
</template>