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 18:08:38 +08:00
parent 3e09ba5225
commit 464159121c
6 changed files with 583 additions and 286 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import type { DashboardPeriod } from '../api';
/** 新版周期缓存 keyJSON 结构,支持自定义区间) */
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()];
}