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

This commit is contained in:
李琦
2026-07-11 17:20:57 +08:00
parent 28ed398581
commit daafdcaed7
13 changed files with 1573 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
/** 右上角装饰 IconVbenIcon 支持的图标名或组件) */
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>

View File

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

View File

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

View File

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

View File

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

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

View File

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