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:
@@ -9,7 +9,8 @@ export type DashboardPeriod =
|
||||
| 'last30days'
|
||||
| 'this_year'
|
||||
| 'last365days'
|
||||
| 'last_year';
|
||||
| 'last_year'
|
||||
| 'custom';
|
||||
|
||||
export type SalespersonDashboardTrendItem = {
|
||||
label: string;
|
||||
@@ -36,6 +37,24 @@ export type SalespersonDashboardRankingItem = {
|
||||
amount?: string;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardStoreAmountItem = {
|
||||
store_id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
type_label: string;
|
||||
amount: string;
|
||||
promoter_nick_name: string;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardInactiveStoreItem = {
|
||||
store_id: number;
|
||||
name: string;
|
||||
type: number;
|
||||
type_label: string;
|
||||
input_at: number;
|
||||
promoter_nick_name: string;
|
||||
};
|
||||
|
||||
export type SalespersonDashboardResult = {
|
||||
period: DashboardPeriod;
|
||||
start_at: number;
|
||||
@@ -58,8 +77,16 @@ export type SalespersonDashboardResult = {
|
||||
region_stats: SalespersonDashboardRegionItem[];
|
||||
input_ranking: SalespersonDashboardRankingItem[];
|
||||
amount_ranking: SalespersonDashboardRankingItem[];
|
||||
store_amount_ranking: SalespersonDashboardStoreAmountItem[];
|
||||
inactive_store_warnings: SalespersonDashboardInactiveStoreItem[];
|
||||
};
|
||||
|
||||
export async function getSalespersonDashboardApi(params: { period: DashboardPeriod }) {
|
||||
export type SalespersonDashboardParams = {
|
||||
period: DashboardPeriod;
|
||||
start_at?: number;
|
||||
end_at?: number;
|
||||
};
|
||||
|
||||
export async function getSalespersonDashboardApi(params: SalespersonDashboardParams) {
|
||||
return requestClient.get<SalespersonDashboardResult>(`${prefix}dashboard`, { params });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { Card, Empty, Tag } from 'ant-design-vue';
|
||||
|
||||
import type { SalespersonDashboardInactiveStoreItem } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: SalespersonDashboardInactiveStoreItem[];
|
||||
showPromoter?: boolean;
|
||||
emptyText?: string;
|
||||
}>();
|
||||
|
||||
function formatInputAt(timestamp: number) {
|
||||
if (!timestamp) return '—';
|
||||
return dayjs.unix(timestamp).format('YYYY-MM-DD');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :title="title" class="warning-card">
|
||||
<template v-if="subtitle" #extra>
|
||||
<span class="warning-card__subtitle">{{ subtitle }}</span>
|
||||
</template>
|
||||
|
||||
<Empty v-if="!items.length" :description="emptyText || '暂无警示门店'" />
|
||||
|
||||
<div v-else class="warning-card__list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.store_id"
|
||||
class="warning-item"
|
||||
>
|
||||
<div class="warning-item__indicator" />
|
||||
<div class="warning-item__main">
|
||||
<div class="warning-item__header">
|
||||
<div class="warning-item__name">{{ item.name }}</div>
|
||||
<Tag color="warning">本月零营业额</Tag>
|
||||
</div>
|
||||
<div class="warning-item__meta">
|
||||
<Tag size="small">{{ item.type_label }}</Tag>
|
||||
<span class="warning-item__time">录入时间:{{ formatInputAt(item.input_at) }}</span>
|
||||
<span v-if="showPromoter && item.promoter_nick_name" class="warning-item__promoter">
|
||||
业务员:{{ item.promoter_nick_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warning-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.warning-card__subtitle {
|
||||
max-width: 280px;
|
||||
font-size: 12px;
|
||||
font-weight: normal;
|
||||
color: #86909c;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.warning-card__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.warning-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffe7ba;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dark .warning-item {
|
||||
background: #2b2111;
|
||||
border-color: #594214;
|
||||
}
|
||||
|
||||
.warning-item__indicator {
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
background: #fa8c16;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.warning-item__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warning-item__header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.warning-item__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .warning-item__name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.warning-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.warning-item__promoter {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.dark .warning-item__promoter {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
</style>
|
||||
@@ -60,12 +60,8 @@ async function renderChart() {
|
||||
watch(
|
||||
() => props.data,
|
||||
() => renderChart(),
|
||||
{ deep: true, flush: 'post' },
|
||||
{ deep: true, immediate: true, flush: 'post' },
|
||||
);
|
||||
|
||||
watch(showChart, (visible) => {
|
||||
if (visible) renderChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Card, Empty, Progress, Tag } from 'ant-design-vue';
|
||||
|
||||
import type { SalespersonDashboardStoreAmountItem } from '../api';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
items: SalespersonDashboardStoreAmountItem[];
|
||||
showPromoter?: boolean;
|
||||
emptyText?: string;
|
||||
}>();
|
||||
|
||||
const maxValue = computed(() => {
|
||||
if (!props.items.length) return 0;
|
||||
return Math.max(...props.items.map((item) => Number(item.amount || 0)));
|
||||
});
|
||||
|
||||
function formatValue(item: SalespersonDashboardStoreAmountItem) {
|
||||
return `¥${Number(item.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function progressPercent(item: SalespersonDashboardStoreAmountItem) {
|
||||
if (maxValue.value <= 0) return 0;
|
||||
return Math.round((Number(item.amount || 0) / 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.store_id"
|
||||
class="ranking-item"
|
||||
>
|
||||
<div class="ranking-item__badge" :class="rankClass(index)">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
|
||||
<div class="ranking-item__main">
|
||||
<div class="ranking-item__header">
|
||||
<div class="ranking-item__store">
|
||||
<div class="ranking-item__name">{{ item.name }}</div>
|
||||
<div class="ranking-item__meta">
|
||||
<Tag size="small">{{ item.type_label }}</Tag>
|
||||
<span v-if="showPromoter && item.promoter_nick_name" class="ranking-item__promoter">
|
||||
{{ item.promoter_nick_name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ranking-item__store {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ranking-item__name {
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dark .ranking-item__name {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.ranking-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ranking-item__promoter {
|
||||
font-size: 12px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.ranking-item__value {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1677ff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
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 { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Card, DatePicker, Radio, Spin, Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
|
||||
import { Card, Radio, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
|
||||
import { ROLE_SALESPERSON } from '#/views/system/admin/_shared/role-meta';
|
||||
|
||||
import CompareTrendChart from './components/CompareTrendChart.vue';
|
||||
|
||||
import DashboardStatGrid from './components/DashboardStatGrid.vue';
|
||||
|
||||
import InactiveStoreWarningList from './components/InactiveStoreWarningList.vue';
|
||||
import InputCountTrendChart from './components/InputCountTrendChart.vue';
|
||||
|
||||
import RankingList from './components/RankingList.vue';
|
||||
|
||||
import RegionDistributionChart from './components/RegionDistributionChart.vue';
|
||||
|
||||
import StoreAmountRankingList from './components/StoreAmountRankingList.vue';
|
||||
import {
|
||||
|
||||
getSalespersonDashboardApi,
|
||||
|
||||
type DashboardPeriod,
|
||||
|
||||
type SalespersonDashboardResult,
|
||||
|
||||
} from './api';
|
||||
|
||||
|
||||
|
||||
const PERIOD_STORAGE_KEY = 'salesperson_dashboard_period';
|
||||
import {
|
||||
defaultCustomRange,
|
||||
loadPeriodFilter,
|
||||
savePeriodFilter,
|
||||
} from './utils/periodStorage';
|
||||
|
||||
const ALL_PERIOD_VALUES: DashboardPeriod[] = [
|
||||
'today',
|
||||
@@ -60,485 +45,338 @@ const ALL_PERIOD_VALUES: DashboardPeriod[] = [
|
||||
'this_year',
|
||||
'last365days',
|
||||
'last_year',
|
||||
'custom',
|
||||
];
|
||||
|
||||
/** 从 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 storedFilter = loadPeriodFilter();
|
||||
const period = ref<DashboardPeriod>(storedFilter.period);
|
||||
const customRange = ref<[Dayjs, Dayjs]>(
|
||||
storedFilter.customRange ?? defaultCustomRange(),
|
||||
);
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const dashboardData = ref<SalespersonDashboardResult | null>(null);
|
||||
|
||||
const userStore = useUserStore();
|
||||
const isSalespersonView = computed(() => {
|
||||
const roleId = Number(userStore.userInfo?.role_id ?? userStore.userInfo?.roles?.id);
|
||||
return roleId === ROLE_SALESPERSON;
|
||||
});
|
||||
|
||||
/** 当前生效的时间区间文案(预设用 API 返回,自定义用本地选择) */
|
||||
const activeRangeText = computed(() => {
|
||||
if (period.value === 'custom') {
|
||||
return `${customRange.value[0].format('YYYY-MM-DD')} ~ ${customRange.value[1].format('YYYY-MM-DD')}`;
|
||||
}
|
||||
if (!dashboardData.value) return '';
|
||||
const start = dayjs.unix(dashboardData.value.start_at).format('YYYY-MM-DD');
|
||||
const end = dayjs.unix(dashboardData.value.end_at).format('YYYY-MM-DD');
|
||||
return `${start} ~ ${end}`;
|
||||
});
|
||||
|
||||
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 排行
|
||||
|
||||
* 拉取看板数据;自定义周期附带 start_at/end_at(当天起止 unix 秒)
|
||||
*/
|
||||
|
||||
async function loadDashboard() {
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const params: {
|
||||
period: DashboardPeriod;
|
||||
start_at?: number;
|
||||
end_at?: number;
|
||||
} = { period: period.value };
|
||||
|
||||
dashboardData.value = await getSalespersonDashboardApi({ period: period.value });
|
||||
if (period.value === 'custom') {
|
||||
params.start_at = customRange.value[0].startOf('day').unix();
|
||||
params.end_at = customRange.value[1].endOf('day').unix();
|
||||
}
|
||||
|
||||
dashboardData.value = await getSalespersonDashboardApi(params);
|
||||
} finally {
|
||||
|
||||
loading.value = false;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
function persistFilter() {
|
||||
savePeriodFilter({
|
||||
period: period.value,
|
||||
customRange: period.value === 'custom' ? customRange.value : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
watch(period, (val) => {
|
||||
localStorage.setItem(PERIOD_STORAGE_KEY, val);
|
||||
if (!ALL_PERIOD_VALUES.includes(val)) {
|
||||
period.value = 'today';
|
||||
return;
|
||||
}
|
||||
persistFilter();
|
||||
loadDashboard();
|
||||
});
|
||||
|
||||
|
||||
watch(
|
||||
customRange,
|
||||
() => {
|
||||
if (period.value !== 'custom') return;
|
||||
persistFilter();
|
||||
loadDashboard();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
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 v-if="activeRangeText" class="dashboard-filter__range">
|
||||
统计区间:{{ activeRangeText }}
|
||||
</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.Button value="custom">自定义</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<DatePicker.RangePicker
|
||||
v-if="period === 'custom'"
|
||||
v-model:value="customRange"
|
||||
class="dashboard-filter__picker"
|
||||
:allow-clear="false"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</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 v-if="!isSalespersonView" 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>
|
||||
|
||||
<div class="dashboard-two-col mt-4">
|
||||
|
||||
<RankingList
|
||||
|
||||
<StoreAmountRankingList
|
||||
class="dashboard-two-col__item"
|
||||
|
||||
title="录入门店数量排行"
|
||||
|
||||
:items="dashboardData.input_ranking"
|
||||
|
||||
value-key="count"
|
||||
|
||||
title="门店营业额排行"
|
||||
subtitle="当前筛选周期内各门店有效支付金额(诊所+药店)"
|
||||
:items="dashboardData.store_amount_ranking"
|
||||
:show-promoter="!isSalespersonView"
|
||||
/>
|
||||
|
||||
<RankingList
|
||||
|
||||
<InactiveStoreWarningList
|
||||
class="dashboard-two-col__item"
|
||||
|
||||
title="绑定门店营业额排行"
|
||||
|
||||
subtitle="统计业务员名下门店的有效支付金额(已支付且无退款)"
|
||||
|
||||
:items="dashboardData.amount_ranking"
|
||||
|
||||
value-key="amount"
|
||||
|
||||
title="长期未活跃警示"
|
||||
subtitle="本月(自然月)营业额为 0 的绑定门店"
|
||||
:items="dashboardData.inactive_store_warnings"
|
||||
:show-promoter="!isSalespersonView"
|
||||
/>
|
||||
|
||||
</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__range {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
}
|
||||
|
||||
.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-filter__picker {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.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) {
|
||||
|
||||
.dashboard-two-col :deep(.ranking-card),
|
||||
.dashboard-two-col :deep(.warning-card) {
|
||||
width: 100%;
|
||||
|
||||
min-width: 0;
|
||||
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import type { DashboardPeriod } from '../api';
|
||||
|
||||
/** 新版周期缓存 key(JSON 结构,支持自定义区间) */
|
||||
const KEY_PERIOD_V2 = 'salesperson_dashboard_period_v2';
|
||||
/** 旧版纯字符串 key,读取后自动迁移 */
|
||||
const KEY_PERIOD_LEGACY = 'salesperson_dashboard_period';
|
||||
|
||||
const ALL_PERIOD_VALUES: DashboardPeriod[] = [
|
||||
'today',
|
||||
'last7days',
|
||||
'this_month',
|
||||
'last30days',
|
||||
'this_year',
|
||||
'last365days',
|
||||
'last_year',
|
||||
'custom',
|
||||
];
|
||||
|
||||
export type PeriodFilterState = {
|
||||
period: DashboardPeriod;
|
||||
customRange?: [Dayjs, Dayjs];
|
||||
};
|
||||
|
||||
function isValidPeriod(value: string): value is DashboardPeriod {
|
||||
return ALL_PERIOD_VALUES.includes(value as DashboardPeriod);
|
||||
}
|
||||
|
||||
function parseCustomRange(start?: string, end?: string): [Dayjs, Dayjs] | null {
|
||||
if (!start || !end) return null;
|
||||
const rangeStart = dayjs(start);
|
||||
const rangeEnd = dayjs(end);
|
||||
if (!rangeStart.isValid() || !rangeEnd.isValid()) return null;
|
||||
return [rangeStart, rangeEnd];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复周期筛选状态。
|
||||
* 兼容旧版纯字符串缓存,并自动迁移到 v2 JSON 格式。
|
||||
*/
|
||||
export function loadPeriodFilter(): PeriodFilterState {
|
||||
const rawV2 = localStorage.getItem(KEY_PERIOD_V2);
|
||||
if (rawV2) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawV2) as { period?: string; start?: string; end?: string };
|
||||
if (parsed?.period && isValidPeriod(parsed.period)) {
|
||||
if (parsed.period === 'custom') {
|
||||
const customRange = parseCustomRange(parsed.start, parsed.end);
|
||||
if (customRange) {
|
||||
return { period: 'custom', customRange };
|
||||
}
|
||||
return { period: 'today' };
|
||||
}
|
||||
return { period: parsed.period };
|
||||
}
|
||||
} catch {
|
||||
// 解析失败则走默认
|
||||
}
|
||||
}
|
||||
|
||||
const legacy = localStorage.getItem(KEY_PERIOD_LEGACY);
|
||||
if (legacy && isValidPeriod(legacy) && legacy !== 'custom') {
|
||||
const state: PeriodFilterState = { period: legacy };
|
||||
savePeriodFilter(state);
|
||||
localStorage.removeItem(KEY_PERIOD_LEGACY);
|
||||
return state;
|
||||
}
|
||||
|
||||
return { period: 'today' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化周期筛选状态,自定义区间仅存 YYYY-MM-DD 便于跨刷新恢复。
|
||||
*/
|
||||
export function savePeriodFilter(state: PeriodFilterState) {
|
||||
if (state.period === 'custom' && state.customRange) {
|
||||
const payload = {
|
||||
period: 'custom',
|
||||
start: state.customRange[0].format('YYYY-MM-DD'),
|
||||
end: state.customRange[1].format('YYYY-MM-DD'),
|
||||
};
|
||||
localStorage.setItem(KEY_PERIOD_V2, JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(KEY_PERIOD_V2, JSON.stringify({ period: state.period }));
|
||||
}
|
||||
|
||||
export function defaultCustomRange(): [Dayjs, Dayjs] {
|
||||
return [dayjs().startOf('month'), dayjs()];
|
||||
}
|
||||
Reference in New Issue
Block a user