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
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
This commit is contained in:
@@ -1,14 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { openRouteInNewWindow } from '@vben/utils';
|
||||
|
||||
import { Avatar, Descriptions, Spin } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Avatar, Descriptions, Empty, message, Spin, Table } from 'ant-design-vue';
|
||||
|
||||
import { getPromoterDetailApi } from '#/views/system/store-input/api';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
type PromoterStoreItem = {
|
||||
store_id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
type_label: string;
|
||||
input_at: number;
|
||||
total_amount: string;
|
||||
month_amount: string;
|
||||
};
|
||||
|
||||
type PromoterDetail = {
|
||||
id: number;
|
||||
nick_name: string;
|
||||
@@ -16,18 +29,74 @@ type PromoterDetail = {
|
||||
code: string;
|
||||
phone: string;
|
||||
role_name: string;
|
||||
clinic_input_count: number;
|
||||
pharmacy_input_count: number;
|
||||
clinic_store_count: number;
|
||||
pharmacy_store_count: number;
|
||||
store_total_count: number;
|
||||
store_list: PromoterStoreItem[];
|
||||
total_valid_amount: string;
|
||||
month_valid_amount: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const detail = ref<PromoterDetail | null>(null);
|
||||
|
||||
const storeColumns = [
|
||||
{ title: '门店名称', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type_label', key: 'type_label', width: 72 },
|
||||
{ title: '录入时间', dataIndex: 'input_at', key: 'input_at', width: 160 },
|
||||
{ title: '总营业额', dataIndex: 'total_amount', key: 'total_amount', width: 120, align: 'right' as const },
|
||||
{ title: '本月营业额', dataIndex: 'month_amount', key: 'month_amount', width: 120, align: 'right' as const },
|
||||
];
|
||||
|
||||
/** 解析商品订单列表路由(菜单动态注册,path 含 product-order) */
|
||||
function findProductOrderListPath(): string | null {
|
||||
const routes = router.getRoutes();
|
||||
const hit = routes.find(
|
||||
(r) =>
|
||||
typeof r.path === 'string' &&
|
||||
r.path.length > 0 &&
|
||||
!r.redirect &&
|
||||
/product-order/i.test(r.path),
|
||||
);
|
||||
return hit?.path ?? null;
|
||||
}
|
||||
|
||||
/** 金额格式化为 ¥ + 2 位小数 */
|
||||
function formatMoney(value: number | string | undefined) {
|
||||
const num = Number(value || 0);
|
||||
return `¥${num.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
|
||||
/** Unix 时间戳格式化为可读录入时间 */
|
||||
function formatInputAt(timestamp?: number) {
|
||||
if (!timestamp) return '-';
|
||||
return dayjs.unix(timestamp).format('YYYY-MM-DD HH:mm');
|
||||
}
|
||||
|
||||
/** 新标签页打开商品订单,按门店及时间范围筛选 */
|
||||
function openStoreOrders(storeId: number, timeScope: 'all' | 'month') {
|
||||
const path = findProductOrderListPath();
|
||||
if (!path) {
|
||||
message.warning('未找到商品订单菜单路由,请从左侧菜单进入商品订单');
|
||||
return;
|
||||
}
|
||||
const resolved = router.resolve({
|
||||
path,
|
||||
query: {
|
||||
store_id: String(storeId),
|
||||
time_scope: timeScope,
|
||||
},
|
||||
});
|
||||
openRouteInNewWindow(resolved.fullPath);
|
||||
}
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
title: '业务员详情',
|
||||
class: 'w-[480px]',
|
||||
class: 'w-[860px]',
|
||||
footer: false,
|
||||
onOpenChange(isOpen) {
|
||||
if (!isOpen) {
|
||||
@@ -63,24 +132,59 @@ async function loadDetail(id?: number, code?: string) {
|
||||
<div class="promoter-detail__title">{{ detail.nick_name || '-' }}</div>
|
||||
<div class="promoter-detail__role">{{ detail.role_name || '业务员' }}</div>
|
||||
</div>
|
||||
<Descriptions bordered :column="1" size="small" class="promoter-detail__desc">
|
||||
<Descriptions bordered :column="2" size="small" class="promoter-detail__desc">
|
||||
<Descriptions.Item label="推广码">{{ detail.code || '-' }}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">
|
||||
<SensitiveText :record="detail" field="phone" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="录入诊所">
|
||||
{{ detail.clinic_input_count ?? 0 }} 家
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="录入药店">
|
||||
{{ detail.pharmacy_input_count ?? 0 }} 家
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="正式诊所">
|
||||
<Descriptions.Item label="绑定诊所">
|
||||
{{ detail.clinic_store_count ?? 0 }} 家
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="正式药店">
|
||||
<Descriptions.Item label="绑定药店">
|
||||
{{ detail.pharmacy_store_count ?? 0 }} 家
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="绑定门店总营业额">
|
||||
{{ formatMoney(detail.total_valid_amount) }}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="本月营业额">
|
||||
{{ formatMoney(detail.month_valid_amount) }}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div class="promoter-detail__section-title">正式门店列表</div>
|
||||
<Table
|
||||
v-if="detail.store_list?.length"
|
||||
:columns="storeColumns"
|
||||
:data-source="detail.store_list"
|
||||
:pagination="false"
|
||||
:scroll="{ y: 320 }"
|
||||
row-key="store_id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'input_at'">
|
||||
{{ formatInputAt(record.input_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'total_amount'">
|
||||
<button
|
||||
type="button"
|
||||
class="promoter-detail__amount-link"
|
||||
@click="openStoreOrders(record.store_id, 'all')"
|
||||
>
|
||||
{{ formatMoney(record.total_amount) }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'month_amount'">
|
||||
<button
|
||||
type="button"
|
||||
class="promoter-detail__amount-link"
|
||||
@click="openStoreOrders(record.store_id, 'month')"
|
||||
>
|
||||
{{ formatMoney(record.month_amount) }}
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Empty v-else class="promoter-detail__empty" description="暂无绑定正式门店" />
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
@@ -110,11 +214,41 @@ async function loadDetail(id?: number, code?: string) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dark .promoter-detail__title {
|
||||
.promoter-detail__section-title {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.promoter-detail__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.promoter-detail__amount-link {
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: #1677ff;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.promoter-detail__amount-link:hover {
|
||||
color: #4096ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dark .promoter-detail__title,
|
||||
.dark .promoter-detail__section-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.dark .promoter-detail__role {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.dark .promoter-detail__amount-link {
|
||||
color: #60a5fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import {
|
||||
AnalysisOverview,
|
||||
@@ -16,6 +17,8 @@ import { SvgCakeIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Image, message, Modal as AntdModal, Popconfirm, Popover, Space, Switch, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||
@@ -47,6 +50,7 @@ import { gridOptions } from './config/table';
|
||||
import { isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const canViewInviterCommission = isPlatformSuperAdmin(userStore.userInfo);
|
||||
|
||||
@@ -103,18 +107,48 @@ const gridEvents: VxeGridListeners<any> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 从路由 query 生成带默认筛选的表单配置
|
||||
* VxeGrid 首屏 autoLoad 使用 latestSubmissionValues,需在挂载前写入 schema defaultValue
|
||||
*/
|
||||
function buildFormOptionsFromRoute(): VbenFormProps {
|
||||
const rawStoreId = route.query.store_id;
|
||||
const storeId = Array.isArray(rawStoreId) ? rawStoreId[0] : rawStoreId;
|
||||
if (!storeId) {
|
||||
return formOptions;
|
||||
}
|
||||
const rawTimeScope = route.query.time_scope;
|
||||
const timeScope = Array.isArray(rawTimeScope) ? rawTimeScope[0] : rawTimeScope;
|
||||
const schema = (formOptions.schema ?? []).map((item) => {
|
||||
if (item.fieldName === 'store_id') {
|
||||
return { ...item, defaultValue: Number(storeId) };
|
||||
}
|
||||
if (item.fieldName === 'search_time') {
|
||||
if (timeScope === 'month') {
|
||||
return { ...item, defaultValue: [dayjs().startOf('month'), dayjs()] };
|
||||
}
|
||||
if (timeScope === 'all') {
|
||||
return { ...item, defaultValue: null };
|
||||
}
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return { ...formOptions, schema };
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
formOptions: buildFormOptionsFromRoute(),
|
||||
gridOptions,
|
||||
gridEvents,
|
||||
});
|
||||
|
||||
const initTableAjax = () => {
|
||||
gridApi.setGridOptions({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
saleAmount();
|
||||
saleAmount(formValues);
|
||||
return await getOrderList({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
@@ -171,9 +205,12 @@ const collapseAll = () => {
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([]);
|
||||
const income = ref(0);
|
||||
const total = ref(0);
|
||||
const saleAmount = () => {
|
||||
/** 按当前列表筛选条件刷新顶部销售金额/收益(门店参数后端字段名为 id) */
|
||||
const saleAmount = (formValues?: Record<string, any>) => {
|
||||
const values = formValues ?? gridApi.formApi.latestSubmissionValues ?? {};
|
||||
saleAmountApi({
|
||||
search_time: gridApi.formApi.latestSubmissionValues.search_time,
|
||||
search_time: values.search_time,
|
||||
id: values.store_id,
|
||||
}).then((res) => {
|
||||
income.value = res.income;
|
||||
total.value = res.total;
|
||||
|
||||
@@ -27,6 +27,7 @@ import { formatAddressDisplay } from '#/util/address-index';
|
||||
import { createAdminGridOptions } from './table-config';
|
||||
import { getRoleMeta } from './role-meta';
|
||||
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
|
||||
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
||||
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
|
||||
|
||||
function formatAdminRegion(row: {
|
||||
@@ -243,6 +244,18 @@ const copyToClipboard = async (text: string) => {
|
||||
<template #avatar="{ row }">
|
||||
<Image :src="row.avatar" height="30" width="30" />
|
||||
</template>
|
||||
<template #promoter_info="{ row }">
|
||||
<PromoterInfoCard
|
||||
:admin="{
|
||||
id: Number(row.id),
|
||||
nick_name: row.nick_name,
|
||||
avatar: row.avatar,
|
||||
phone: row.phone,
|
||||
code: row.code,
|
||||
}"
|
||||
size="compact"
|
||||
/>
|
||||
</template>
|
||||
<template #nick_name="{ row }">
|
||||
<Button
|
||||
v-if="meta.formType === 'doctor' && row.doctor_id"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||
import { getAdminList } from '#/views/system/admin/api';
|
||||
|
||||
import type { AdminFormType } from './role-meta';
|
||||
import { ROLE_SALESPERSON } from './role-meta';
|
||||
|
||||
interface RowType {
|
||||
id: string;
|
||||
@@ -29,21 +30,41 @@ interface RowType {
|
||||
city_id?: number;
|
||||
}
|
||||
|
||||
function buildColumns(formType: AdminFormType) {
|
||||
function buildColumns(formType: AdminFormType, roleId: number) {
|
||||
const isSalespersonList = roleId === ROLE_SALESPERSON;
|
||||
const cols: VxeGridProps<RowType>['columns'] = [
|
||||
{ type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||
{ field: 'nick_name', align: 'left', title: '名称', ...(formType === 'doctor' ? { slots: { default: 'nick_name' } } : {}) },
|
||||
{
|
||||
field: 'avatar',
|
||||
align: 'left',
|
||||
title: '头像',
|
||||
slots: { default: 'avatar' },
|
||||
width: 130,
|
||||
},
|
||||
{ field: 'open_id', title: 'Open ID' },
|
||||
];
|
||||
|
||||
if (isSalespersonList) {
|
||||
cols.push({
|
||||
field: 'nick_name',
|
||||
align: 'left',
|
||||
title: '业务员',
|
||||
minWidth: 180,
|
||||
slots: { default: 'promoter_info' },
|
||||
});
|
||||
} else {
|
||||
cols.push(
|
||||
{
|
||||
field: 'nick_name',
|
||||
align: 'left',
|
||||
title: '名称',
|
||||
...(formType === 'doctor' ? { slots: { default: 'nick_name' } } : {}),
|
||||
},
|
||||
{
|
||||
field: 'avatar',
|
||||
align: 'left',
|
||||
title: '头像',
|
||||
slots: { default: 'avatar' },
|
||||
width: 130,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cols.push({ field: 'open_id', title: 'Open ID' });
|
||||
|
||||
if (formType === 'address' || formType === 'supplier') {
|
||||
cols.push({ field: 'code', title: '业务推广码' });
|
||||
}
|
||||
@@ -119,7 +140,7 @@ export function createAdminGridOptions(
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
columns: buildColumns(formType),
|
||||
columns: buildColumns(formType, roleId),
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'store-input/';
|
||||
|
||||
export type DashboardPeriod =
|
||||
| 'today'
|
||||
| 'last7days'
|
||||
| 'this_month'
|
||||
| 'last30days'
|
||||
| 'this_year'
|
||||
| 'last365days'
|
||||
| 'last_year';
|
||||
|
||||
export type SalespersonDashboardTrendItem = {
|
||||
label: string;
|
||||
input_total: number;
|
||||
clinic_count: number;
|
||||
pharmacy_count: number;
|
||||
valid_amount: string;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardRegionItem = {
|
||||
region_id: number;
|
||||
region_name: string;
|
||||
input_count: number;
|
||||
clinic_count: number;
|
||||
pharmacy_count: number;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardRankingItem = {
|
||||
id: number;
|
||||
nick_name: string;
|
||||
avatar: string;
|
||||
code: string;
|
||||
count?: number;
|
||||
amount?: string;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardResult = {
|
||||
period: DashboardPeriod;
|
||||
start_at: number;
|
||||
end_at: number;
|
||||
prev_start_at: number;
|
||||
prev_end_at: number;
|
||||
summary: {
|
||||
input_total: number;
|
||||
clinic_input_count: number;
|
||||
pharmacy_input_count: number;
|
||||
approved_count: number;
|
||||
valid_amount_total: string;
|
||||
prev_input_total: number;
|
||||
prev_valid_amount_total: string;
|
||||
input_mom_rate: number | null;
|
||||
amount_mom_rate: number | null;
|
||||
};
|
||||
trend: SalespersonDashboardTrendItem[];
|
||||
compare_trend: SalespersonDashboardTrendItem[];
|
||||
region_stats: SalespersonDashboardRegionItem[];
|
||||
input_ranking: SalespersonDashboardRankingItem[];
|
||||
amount_ranking: SalespersonDashboardRankingItem[];
|
||||
};
|
||||
|
||||
export async function getSalespersonDashboardApi(params: { period: DashboardPeriod }) {
|
||||
return requestClient.get<SalespersonDashboardResult>(`${prefix}dashboard`, { params });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
|
||||
EchartsUI,
|
||||
|
||||
type EchartsUIType,
|
||||
|
||||
useEcharts,
|
||||
|
||||
} from '@vben/plugins/echarts';
|
||||
|
||||
|
||||
|
||||
import type { SalespersonDashboardTrendItem } from '../api';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
/** 本周期 trend,用于环比柱状图「本周期」系列 */
|
||||
|
||||
current: SalespersonDashboardTrendItem[];
|
||||
|
||||
/** 上一等长周期 compare_trend,用于「上周期」系列 */
|
||||
|
||||
previous: SalespersonDashboardTrendItem[];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
|
||||
const { renderEcharts, resize } = useEcharts(chartRef);
|
||||
|
||||
|
||||
|
||||
const maxLen = computed(() => Math.max(props.current.length, props.previous.length));
|
||||
|
||||
|
||||
|
||||
function resolveInputTotal(item: SalespersonDashboardTrendItem) {
|
||||
|
||||
return item.input_total ?? item.clinic_count + item.pharmacy_count;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 按序号对齐本周期与上周期录入总数,tooltip 展示真实时间标签 */
|
||||
|
||||
async function renderChart() {
|
||||
|
||||
const labels = Array.from({ length: maxLen.value }, (_, i) => `T${i + 1}`);
|
||||
|
||||
const currentData = props.current.map((item) => resolveInputTotal(item));
|
||||
|
||||
const previousData = props.previous.map((item) => resolveInputTotal(item));
|
||||
|
||||
|
||||
|
||||
await renderEcharts({
|
||||
|
||||
tooltip: {
|
||||
|
||||
trigger: 'axis',
|
||||
|
||||
formatter(params: any) {
|
||||
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
|
||||
const idx = list[0]?.dataIndex ?? 0;
|
||||
|
||||
const cur = props.current[idx];
|
||||
|
||||
const prev = props.previous[idx];
|
||||
|
||||
const lines = [`序号 ${idx + 1}`];
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { VbenIcon } from '@vben/common-ui';
|
||||
|
||||
import { Card } from 'ant-design-vue';
|
||||
|
||||
/** 统计卡片数值展示类型:count 整数 / money 金额 / percent 百分比 */
|
||||
export type DashboardStatValueType = 'count' | 'money' | 'percent';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 指标标题,如「录入总数」 */
|
||||
title: string;
|
||||
/** 主数值 */
|
||||
value: number | string;
|
||||
/** 数值格式化类型,默认 count 不显示小数 */
|
||||
valueType?: DashboardStatValueType;
|
||||
/** 底部说明文字,如「环比」 */
|
||||
footerLabel?: string;
|
||||
/** 底部辅助值,如上一周期数量或环比百分比 */
|
||||
footerValue?: number | string;
|
||||
/** 右上角装饰 Icon(VbenIcon 支持的图标名或组件) */
|
||||
decorationIcon?: string | Component;
|
||||
/** 右上角装饰背景图 URL(半透明,不遮挡主文字) */
|
||||
decorationImage?: string;
|
||||
}>(),
|
||||
{
|
||||
valueType: 'count',
|
||||
},
|
||||
);
|
||||
|
||||
defineOptions({
|
||||
name: 'DashboardStatCard',
|
||||
});
|
||||
|
||||
/**
|
||||
* 按 valueType 格式化主数值,避免 AnalysisOverview 统一 decimals=3 的问题
|
||||
*/
|
||||
const displayValue = computed(() => {
|
||||
const raw = props.value;
|
||||
if (raw === null || raw === undefined || raw === '') return '—';
|
||||
if (props.valueType === 'percent') {
|
||||
if (typeof raw === 'string') return raw;
|
||||
if (raw === null) return '—';
|
||||
const prefix = Number(raw) > 0 ? '+' : '';
|
||||
return `${prefix}${Number(raw).toFixed(1)}%`;
|
||||
}
|
||||
const num = Number(raw);
|
||||
if (Number.isNaN(num)) return String(raw);
|
||||
if (props.valueType === 'money') {
|
||||
return `¥${num.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
return num.toLocaleString(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
});
|
||||
|
||||
/** 底部辅助值格式化(与主值规则一致,footer 多为 count 或 percent 文本) */
|
||||
const displayFooterValue = computed(() => {
|
||||
if (props.footerValue === null || props.footerValue === undefined || props.footerValue === '') {
|
||||
return '';
|
||||
}
|
||||
if (typeof props.footerValue === 'string') return props.footerValue;
|
||||
const num = Number(props.footerValue);
|
||||
if (Number.isNaN(num)) return String(props.footerValue);
|
||||
if (props.valueType === 'money') {
|
||||
return `¥${num.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
}
|
||||
return num.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="stat-card w-full" :bordered="true">
|
||||
<!-- 右上角装饰:优先背景图,其次 Icon -->
|
||||
<div v-if="decorationImage || decorationIcon" class="stat-card__decoration">
|
||||
<img
|
||||
v-if="decorationImage"
|
||||
:src="decorationImage"
|
||||
alt=""
|
||||
class="stat-card__decoration-img"
|
||||
/>
|
||||
<VbenIcon
|
||||
v-else-if="decorationIcon"
|
||||
:icon="decorationIcon"
|
||||
class="stat-card__decoration-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="stat-card__body">
|
||||
<div class="stat-card__title">{{ title }}</div>
|
||||
<div class="stat-card__value">{{ displayValue }}</div>
|
||||
<div v-if="footerLabel" class="stat-card__footer">
|
||||
<span class="stat-card__footer-label">{{ footerLabel }}</span>
|
||||
<span v-if="displayFooterValue" class="stat-card__footer-value">
|
||||
{{ displayFooterValue }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card :deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-card__decoration {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
pointer-events: none;
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.stat-card__decoration-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.stat-card__decoration-img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stat-card__body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-card__title {
|
||||
font-size: 14px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.dark .stat-card__title {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-card__value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.dark .stat-card__value {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.stat-card__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.stat-card__footer-value {
|
||||
font-weight: 500;
|
||||
color: #4e5969;
|
||||
}
|
||||
|
||||
.dark .stat-card__footer-value {
|
||||
color: #d1d5db;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { DashboardStatValueType } from './DashboardStatCard.vue';
|
||||
|
||||
import DashboardStatCard from './DashboardStatCard.vue';
|
||||
|
||||
/** 看板统计卡片配置项 */
|
||||
export interface DashboardStatItem {
|
||||
title: string;
|
||||
value: number | string;
|
||||
valueType?: DashboardStatValueType;
|
||||
footerLabel?: string;
|
||||
footerValue?: number | string;
|
||||
decorationIcon?: string | Component;
|
||||
decorationImage?: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
items: DashboardStatItem[];
|
||||
}>();
|
||||
|
||||
defineOptions({
|
||||
name: 'DashboardStatGrid',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-stat-grid">
|
||||
<DashboardStatCard
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
v-bind="item"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 内置响应式栅格:1 → 2 → 5 列,不依赖页面级 :deep(.grid) 覆盖 */
|
||||
.dashboard-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dashboard-stat-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.dashboard-stat-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import {
|
||||
|
||||
EchartsUI,
|
||||
|
||||
type EchartsUIType,
|
||||
|
||||
useEcharts,
|
||||
|
||||
} from '@vben/plugins/echarts';
|
||||
|
||||
|
||||
|
||||
import type { SalespersonDashboardTrendItem } from '../api';
|
||||
|
||||
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
/** 来自 dashboardData.trend:按时间桶聚合的录入趋势 */
|
||||
|
||||
data: SalespersonDashboardTrendItem[];
|
||||
|
||||
}>();
|
||||
|
||||
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
|
||||
const { renderEcharts, resize } = useEcharts(chartRef);
|
||||
|
||||
|
||||
|
||||
/** 录入总数兜底:兼容旧接口未返回 input_total 的情况 */
|
||||
|
||||
function resolveInputTotal(item: SalespersonDashboardTrendItem) {
|
||||
|
||||
return item.input_total ?? item.clinic_count + item.pharmacy_count;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 将 trend 数据映射为 ECharts series
|
||||
|
||||
* - 堆叠柱:录入诊所 / 录入药店
|
||||
|
||||
* - 折线:录入总数
|
||||
|
||||
*/
|
||||
|
||||
async function renderChart() {
|
||||
|
||||
const labels = props.data.map((item) => item.label);
|
||||
|
||||
const clinicData = props.data.map((item) => item.clinic_count);
|
||||
|
||||
const pharmacyData = props.data.map((item) => item.pharmacy_count);
|
||||
|
||||
const totalData = props.data.map((item) => resolveInputTotal(item));
|
||||
|
||||
|
||||
|
||||
await renderEcharts({
|
||||
|
||||
tooltip: { trigger: 'axis' },
|
||||
|
||||
legend: { data: ['录入诊所', '录入药店', '录入总数'] },
|
||||
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
|
||||
xAxis: {
|
||||
|
||||
type: 'category',
|
||||
|
||||
data: labels,
|
||||
|
||||
axisLabel: { rotate: labels.length > 10 ? 45 : 0 },
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Card, Empty, Progress } from 'ant-design-vue';
|
||||
|
||||
import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue';
|
||||
|
||||
import type { SalespersonDashboardRankingItem } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: SalespersonDashboardRankingItem[];
|
||||
valueKey: 'count' | 'amount';
|
||||
emptyText?: string;
|
||||
}>();
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (!props.items.length) return 0;
|
||||
if (props.valueKey === 'amount') {
|
||||
return Math.max(...props.items.map((item) => Number(item.amount || 0)));
|
||||
}
|
||||
return Math.max(...props.items.map((item) => Number(item.count || 0)));
|
||||
});
|
||||
|
||||
function formatValue(item: SalespersonDashboardRankingItem) {
|
||||
if (props.valueKey === 'amount') {
|
||||
return `¥${Number(item.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
return `${item.count ?? 0} 家`;
|
||||
}
|
||||
|
||||
function progressPercent(item: SalespersonDashboardRankingItem) {
|
||||
if (maxValue.value <= 0) return 0;
|
||||
const val = props.valueKey === 'amount' ? Number(item.amount || 0) : Number(item.count || 0);
|
||||
return Math.round((val / maxValue.value) * 100);
|
||||
}
|
||||
|
||||
function rankClass(index: number) {
|
||||
if (index === 0) return 'ranking-item__badge--gold';
|
||||
if (index === 1) return 'ranking-item__badge--silver';
|
||||
if (index === 2) return 'ranking-item__badge--bronze';
|
||||
return 'ranking-item__badge--normal';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :title="title" class="ranking-card">
|
||||
<template v-if="subtitle" #extra>
|
||||
<span class="ranking-card__subtitle">{{ subtitle }}</span>
|
||||
</template>
|
||||
|
||||
<Empty v-if="!items.length" :description="emptyText || '暂无数据'" />
|
||||
|
||||
<div v-else class="ranking-card__list">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="`${item.id}-${item.code}`"
|
||||
class="ranking-item"
|
||||
>
|
||||
<div class="ranking-item__badge" :class="rankClass(index)">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
|
||||
<div class="ranking-item__main">
|
||||
<div class="ranking-item__header">
|
||||
<PromoterInfoCard
|
||||
:admin="{
|
||||
id: item.id,
|
||||
nick_name: item.nick_name,
|
||||
avatar: item.avatar,
|
||||
code: item.code,
|
||||
}"
|
||||
size="compact"
|
||||
/>
|
||||
<div class="ranking-item__value">{{ formatValue(item) }}</div>
|
||||
</div>
|
||||
<Progress
|
||||
:percent="progressPercent(item)"
|
||||
:show-info="false"
|
||||
stroke-color="#1677ff"
|
||||
:stroke-width="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ranking-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ranking-card__subtitle {
|
||||
max-width: 280px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: #86909c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ranking-card__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ranking-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dark .ranking-item {
|
||||
background: #1f1f1f;
|
||||
}
|
||||
|
||||
.ranking-item:hover {
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.dark .ranking-item:hover {
|
||||
background: #111a2c;
|
||||
}
|
||||
|
||||
.ranking-item__badge {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.ranking-item__badge--gold {
|
||||
background: linear-gradient(135deg, #ffd666, #fa8c16);
|
||||
}
|
||||
|
||||
.ranking-item__badge--silver {
|
||||
background: linear-gradient(135deg, #f0f0f0, #bfbfbf);
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.ranking-item__badge--bronze {
|
||||
background: linear-gradient(135deg, #ffbb96, #d4380d);
|
||||
}
|
||||
|
||||
.ranking-item__badge--normal {
|
||||
background: #d9d9d9;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.ranking-item__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ranking-item__header {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ranking-item__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1677ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
EchartsUI,
|
||||
type EchartsUIType,
|
||||
useEcharts,
|
||||
} from '@vben/plugins/echarts';
|
||||
|
||||
import { Card, Empty, Statistic } from 'ant-design-vue';
|
||||
|
||||
import type { SalespersonDashboardRegionItem } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
/** 来自 dashboardData.region_stats:按角色维度(省/市)聚合的录入分布 */
|
||||
data: SalespersonDashboardRegionItem[];
|
||||
}>();
|
||||
|
||||
const chartRef = ref<EchartsUIType>();
|
||||
const { renderEcharts, resize } = useEcharts(chartRef);
|
||||
|
||||
/** 无数据时展示空态 */
|
||||
const showEmpty = computed(() => !props.data.length);
|
||||
/** 市经理仅一条本市数据时展示 Statistic 卡片 */
|
||||
const isSingleCity = computed(() => props.data.length === 1);
|
||||
/** 多地区时展示横向柱状图(用 v-show 保持 ECharts 实例,避免切换时间段后组件丢失) */
|
||||
const showChart = computed(() => props.data.length > 1);
|
||||
|
||||
/** 多地区横向柱状图:Y 轴地区名,X 轴录入总数 */
|
||||
async function renderChart() {
|
||||
if (!showChart.value) return;
|
||||
|
||||
const names = props.data.map((item) => item.region_name);
|
||||
const counts = props.data.map((item) => item.input_count);
|
||||
|
||||
// 等待 v-show 切换完成后再渲染,避免 chartRef 尚未挂载
|
||||
await nextTick();
|
||||
await renderEcharts({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '8%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'value', minInterval: 1 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
inverse: true,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: counts,
|
||||
itemStyle: { color: '#13c2c2' },
|
||||
label: { show: true, position: 'right' },
|
||||
},
|
||||
],
|
||||
});
|
||||
await nextTick();
|
||||
resize();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => renderChart(),
|
||||
{ deep: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(showChart, (visible) => {
|
||||
if (visible) renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Empty
|
||||
v-if="showEmpty"
|
||||
class="region-empty"
|
||||
description="暂无地区录入数据"
|
||||
/>
|
||||
<div v-else-if="isSingleCity" class="region-single">
|
||||
<Card size="small" class="region-single__card">
|
||||
<Statistic
|
||||
:title="data[0]?.region_name || '本市'"
|
||||
:value="data[0]?.input_count || 0"
|
||||
suffix="家录入"
|
||||
/>
|
||||
<div class="region-single__sub">
|
||||
诊所 {{ data[0]?.clinic_count || 0 }} 家 · 药店 {{ data[0]?.pharmacy_count || 0 }} 家
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<!-- 始终挂载图表容器,用 v-show 控制显隐,防止时间段切换时 v-if 销毁 ECharts -->
|
||||
<EchartsUI v-show="showChart" ref="chartRef" class="dashboard-chart" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-chart {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
.region-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.region-single {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.region-single__card {
|
||||
min-width: 240px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.region-single__sub {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
</style>
|
||||
544
apps/web-antd/src/views/system/salesperson-dashboard/index.vue
Normal file
544
apps/web-antd/src/views/system/salesperson-dashboard/index.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { DashboardStatItem } from './components/DashboardStatGrid.vue';
|
||||
|
||||
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
|
||||
|
||||
import { AnalysisChartCard, Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
|
||||
SvgBellIcon,
|
||||
|
||||
SvgCakeIcon,
|
||||
|
||||
SvgCardIcon,
|
||||
|
||||
SvgDownloadIcon,
|
||||
|
||||
} from '@vben/icons';
|
||||
|
||||
|
||||
|
||||
import { Card, Radio, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
|
||||
|
||||
import CompareTrendChart from './components/CompareTrendChart.vue';
|
||||
|
||||
import DashboardStatGrid from './components/DashboardStatGrid.vue';
|
||||
|
||||
import InputCountTrendChart from './components/InputCountTrendChart.vue';
|
||||
|
||||
import RankingList from './components/RankingList.vue';
|
||||
|
||||
import RegionDistributionChart from './components/RegionDistributionChart.vue';
|
||||
|
||||
import {
|
||||
|
||||
getSalespersonDashboardApi,
|
||||
|
||||
type DashboardPeriod,
|
||||
|
||||
type SalespersonDashboardResult,
|
||||
|
||||
} from './api';
|
||||
|
||||
|
||||
|
||||
const PERIOD_STORAGE_KEY = 'salesperson_dashboard_period';
|
||||
|
||||
const ALL_PERIOD_VALUES: DashboardPeriod[] = [
|
||||
'today',
|
||||
'last7days',
|
||||
'this_month',
|
||||
'last30days',
|
||||
'this_year',
|
||||
'last365days',
|
||||
'last_year',
|
||||
];
|
||||
|
||||
/** 从 localStorage 恢复上次选中的时间维度,非法值回退今日 */
|
||||
function readStoredPeriod(): DashboardPeriod {
|
||||
const stored = localStorage.getItem(PERIOD_STORAGE_KEY);
|
||||
if (stored && ALL_PERIOD_VALUES.includes(stored as DashboardPeriod)) {
|
||||
return stored as DashboardPeriod;
|
||||
}
|
||||
return 'today';
|
||||
}
|
||||
|
||||
const shortPeriodOptions: { label: string; value: DashboardPeriod }[] = [
|
||||
|
||||
{ label: '今日', value: 'today' },
|
||||
|
||||
{ label: '近7天', value: 'last7days' },
|
||||
|
||||
{ label: '本月', value: 'this_month' },
|
||||
|
||||
{ label: '近30天', value: 'last30days' },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const longPeriodOptions: { label: string; value: DashboardPeriod }[] = [
|
||||
|
||||
{ label: '今年', value: 'this_year' },
|
||||
|
||||
{ label: '近一年', value: 'last365days' },
|
||||
|
||||
{ label: '去年', value: 'last_year' },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const period = ref<DashboardPeriod>(readStoredPeriod());
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const dashboardData = ref<SalespersonDashboardResult | null>(null);
|
||||
|
||||
|
||||
|
||||
function formatMomRate(rate: number | null | undefined) {
|
||||
|
||||
if (rate === null || rate === undefined) return '—';
|
||||
|
||||
const prefix = rate > 0 ? '+' : '';
|
||||
|
||||
return `${prefix}${rate}%`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function momTagColor(rate: number | null | undefined) {
|
||||
|
||||
if (rate === null || rate === undefined || rate === 0) return 'default';
|
||||
|
||||
return rate > 0 ? 'success' : 'error';
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 顶部 5 张统计卡配置:count 整数展示,money 保留 2 位小数 */
|
||||
|
||||
const statItems = computed<DashboardStatItem[]>(() => {
|
||||
|
||||
const summary = dashboardData.value?.summary;
|
||||
|
||||
if (!summary) return [];
|
||||
|
||||
|
||||
|
||||
return [
|
||||
|
||||
{
|
||||
|
||||
title: '录入总数',
|
||||
|
||||
value: summary.input_total,
|
||||
|
||||
valueType: 'count',
|
||||
|
||||
footerLabel: '环比',
|
||||
|
||||
footerValue: formatMomRate(summary.input_mom_rate),
|
||||
|
||||
decorationIcon: SvgCardIcon,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '录入诊所',
|
||||
|
||||
value: summary.clinic_input_count,
|
||||
|
||||
valueType: 'count',
|
||||
|
||||
footerLabel: '当前周期',
|
||||
|
||||
footerValue: summary.clinic_input_count,
|
||||
|
||||
decorationIcon: SvgCakeIcon,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '录入药店',
|
||||
|
||||
value: summary.pharmacy_input_count,
|
||||
|
||||
valueType: 'count',
|
||||
|
||||
footerLabel: '当前周期',
|
||||
|
||||
footerValue: summary.pharmacy_input_count,
|
||||
|
||||
decorationIcon: SvgDownloadIcon,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '审核通过',
|
||||
|
||||
value: summary.approved_count,
|
||||
|
||||
valueType: 'count',
|
||||
|
||||
footerLabel: '当前周期',
|
||||
|
||||
footerValue: summary.approved_count,
|
||||
|
||||
decorationIcon: SvgBellIcon,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
title: '绑定门店营业额',
|
||||
|
||||
value: Number(summary.valid_amount_total || 0),
|
||||
|
||||
valueType: 'money',
|
||||
|
||||
footerLabel: '环比',
|
||||
|
||||
footerValue: formatMomRate(summary.amount_mom_rate),
|
||||
|
||||
decorationIcon: SvgCardIcon,
|
||||
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
||||
* 拉取看板数据并赋给 dashboardData
|
||||
|
||||
* API 返回:summary 顶部卡片 / trend 录入趋势 / compare_trend 环比对比 / region_stats 地区分布 / input_ranking、amount_ranking 排行
|
||||
|
||||
*/
|
||||
|
||||
async function loadDashboard() {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
|
||||
dashboardData.value = await getSalespersonDashboardApi({ period: period.value });
|
||||
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
watch(period, (val) => {
|
||||
localStorage.setItem(PERIOD_STORAGE_KEY, val);
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
loadDashboard();
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<template>
|
||||
|
||||
<Page auto-content-height title="业务员看板">
|
||||
|
||||
<Spin :spinning="loading">
|
||||
|
||||
<div class="salesperson-dashboard">
|
||||
|
||||
<Card class="mb-4">
|
||||
|
||||
<div class="dashboard-filter">
|
||||
|
||||
<div>
|
||||
|
||||
<div class="dashboard-filter__title">门店录入业绩分析</div>
|
||||
|
||||
<div v-if="dashboardData" class="dashboard-filter__tags">
|
||||
|
||||
<Tag :color="momTagColor(dashboardData.summary.input_mom_rate)">
|
||||
|
||||
录入环比 {{ formatMomRate(dashboardData.summary.input_mom_rate) }}
|
||||
|
||||
</Tag>
|
||||
|
||||
<Tag :color="momTagColor(dashboardData.summary.amount_mom_rate)">
|
||||
|
||||
营业额环比 {{ formatMomRate(dashboardData.summary.amount_mom_rate) }}
|
||||
|
||||
</Tag>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="dashboard-filter__groups">
|
||||
|
||||
<Radio.Group v-model:value="period" button-style="solid" size="small">
|
||||
|
||||
<Radio.Button
|
||||
|
||||
v-for="item in shortPeriodOptions"
|
||||
|
||||
:key="item.value"
|
||||
|
||||
:value="item.value"
|
||||
|
||||
>
|
||||
|
||||
{{ item.label }}
|
||||
|
||||
</Radio.Button>
|
||||
|
||||
</Radio.Group>
|
||||
|
||||
<Radio.Group v-model:value="period" button-style="solid" size="small">
|
||||
|
||||
<Radio.Button
|
||||
|
||||
v-for="item in longPeriodOptions"
|
||||
|
||||
:key="item.value"
|
||||
|
||||
:value="item.value"
|
||||
|
||||
>
|
||||
|
||||
{{ item.label }}
|
||||
|
||||
</Radio.Button>
|
||||
|
||||
</Radio.Group>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</Card>
|
||||
|
||||
|
||||
|
||||
<DashboardStatGrid :items="statItems" />
|
||||
|
||||
|
||||
|
||||
<!-- 数据就绪后再挂载图表,避免 Spin 期间容器高度为 0 导致 ECharts 初始化失败 -->
|
||||
|
||||
<template v-if="dashboardData">
|
||||
|
||||
<AnalysisChartCard class="mt-4" title="录入门店数量趋势">
|
||||
|
||||
<InputCountTrendChart :data="dashboardData.trend" />
|
||||
|
||||
</AnalysisChartCard>
|
||||
|
||||
|
||||
|
||||
<div class="dashboard-two-col mt-4">
|
||||
|
||||
<AnalysisChartCard class="dashboard-two-col__item" title="录入环比对比">
|
||||
|
||||
<CompareTrendChart
|
||||
|
||||
:current="dashboardData.trend"
|
||||
|
||||
:previous="dashboardData.compare_trend"
|
||||
|
||||
/>
|
||||
|
||||
</AnalysisChartCard>
|
||||
|
||||
<AnalysisChartCard class="dashboard-two-col__item" title="地区录入分布">
|
||||
|
||||
<RegionDistributionChart :data="dashboardData.region_stats" />
|
||||
|
||||
</AnalysisChartCard>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="dashboard-two-col mt-4">
|
||||
|
||||
<RankingList
|
||||
|
||||
class="dashboard-two-col__item"
|
||||
|
||||
title="录入门店数量排行"
|
||||
|
||||
:items="dashboardData.input_ranking"
|
||||
|
||||
value-key="count"
|
||||
|
||||
/>
|
||||
|
||||
<RankingList
|
||||
|
||||
class="dashboard-two-col__item"
|
||||
|
||||
title="绑定门店营业额排行"
|
||||
|
||||
subtitle="统计业务员名下门店的有效支付金额(已支付且无退款)"
|
||||
|
||||
:items="dashboardData.amount_ranking"
|
||||
|
||||
value-key="amount"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
</Spin>
|
||||
|
||||
</Page>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
.dashboard-filter {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 16px;
|
||||
|
||||
align-items: flex-start;
|
||||
|
||||
justify-content: space-between;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-filter__title {
|
||||
|
||||
margin-bottom: 8px;
|
||||
|
||||
font-size: 16px;
|
||||
|
||||
font-weight: 600;
|
||||
|
||||
color: #1d2129;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dark .dashboard-filter__title {
|
||||
|
||||
color: #f3f4f6;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-filter__tags {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: wrap;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-filter__groups {
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
|
||||
gap: 8px;
|
||||
|
||||
align-items: flex-end;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 图表区、排行区双栏布局:xl 屏宽下各占 50% */
|
||||
|
||||
.dashboard-two-col {
|
||||
|
||||
display: grid;
|
||||
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
gap: 16px;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
|
||||
.dashboard-two-col {
|
||||
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-two-col__item {
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.dashboard-two-col :deep(.ranking-card) {
|
||||
|
||||
width: 100%;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -50,6 +50,16 @@ export async function getPromoterOptions() {
|
||||
}[]>(`${prefix}promoter-options`);
|
||||
}
|
||||
|
||||
export type PromoterStoreListItem = {
|
||||
store_id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
type_label: string;
|
||||
input_at: number;
|
||||
total_amount: string;
|
||||
month_amount: string;
|
||||
};
|
||||
|
||||
export async function getPromoterDetailApi(params: { id?: number; code?: string }) {
|
||||
return requestClient.get<{
|
||||
id: number;
|
||||
@@ -58,10 +68,12 @@ export async function getPromoterDetailApi(params: { id?: number; code?: string
|
||||
code: string;
|
||||
phone: string;
|
||||
role_name: string;
|
||||
clinic_input_count: number;
|
||||
pharmacy_input_count: number;
|
||||
clinic_store_count: number;
|
||||
pharmacy_store_count: number;
|
||||
store_total_count: number;
|
||||
store_list: PromoterStoreListItem[];
|
||||
total_valid_amount: string;
|
||||
month_valid_amount: string;
|
||||
}>(`${prefix}promoter-detail`, { params });
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user