diff --git a/yunkes/src/composables/__tests__/useManagementFilters.spec.ts b/yunkes/src/composables/__tests__/useManagementFilters.spec.ts new file mode 100644 index 0000000..6984091 --- /dev/null +++ b/yunkes/src/composables/__tests__/useManagementFilters.spec.ts @@ -0,0 +1,119 @@ +// useManagementFilters 单测(vitest)。本文件不进 app 构建/类型检查 +// (tsconfig.app.json exclude __tests__,vitest 自行解析类型), +// `npm run test` 执行。 +// +// composable 依赖 vue-router 注入,仓库无先例;node 环境(无 DOM), +// 选用 createApp + app.use(router) + app.runWithContext() 提供真实 +// route/router 注入,不 mock、不挂载组件。 +import { describe, expect, it, vi } from 'vitest' +import { createApp } from 'vue' +import { createMemoryHistory, createRouter } from 'vue-router' +import { presetRange, useManagementFilters } from '../useManagementFilters' + +/** 建一个带 memory 路由的 filters 实例;query 为初始 URL query。 */ +async function setup(query: Record = {}) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/', component: { render: () => null } }], + }) + const app = createApp({ render: () => null }) + app.use(router) + await router.push({ path: '/', query }) + const filters = app.runWithContext(() => useManagementFilters()) + return { router, filters } +} + +const D = (ymdNum: number) => new Date(Math.floor(ymdNum / 10000), (Math.floor(ymdNum / 100) % 100) - 1, ymdNum % 100) + +describe('presetRange(注入 today,确定性日期)', () => { + it('last7 → 含今天共 7 天(2026-09-10 → 20260904..20260910)', () => { + expect(presetRange('last7', D(20260910))).toEqual({ start: 20260904, end: 20260910 }) + }) + + it('last30 → 30 天窗(2026-09-10 → 20260812..20260910)', () => { + expect(presetRange('last30', D(20260910))).toEqual({ start: 20260812, end: 20260910 }) + }) + + it('thisMonth → 月初到今天(2026-09-10 → 20260901..20260910)', () => { + expect(presetRange('thisMonth', D(20260910))).toEqual({ start: 20260901, end: 20260910 }) + }) + + it('lastMonth → 上月整月(2026-08-15 → 20260701..20260731,31 天月)', () => { + expect(presetRange('lastMonth', D(20260815))).toEqual({ start: 20260701, end: 20260731 }) + }) + + it('lastMonth 30 天月(2026-07-15 → 20260601..20260630)', () => { + expect(presetRange('lastMonth', D(20260715))).toEqual({ start: 20260601, end: 20260630 }) + }) + + it('lastMonth 跨年(2026-01-15 → 20251201..20251231)', () => { + expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 }) + }) + + it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => { + expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 }) + }) +}) + +describe('useManagementFilters(URL query 为真源)', () => { + it('空 query → channel=all / start,end=last30 默认 / compare=previous', async () => { + const { filters } = await setup() + expect(filters.channel.value).toBe('all') + expect(filters.compare.value).toBe('previous') + // 默认值与 presetRange('last30')(真实今天)同源,同日执行必一致 + expect(filters.start.value).toBe(presetRange('last30').start) + expect(filters.end.value).toBe(presetRange('last30').end) + }) + + it('合法 query → 原样读出(wechat / 日期 / team_median)', async () => { + const { filters } = await setup({ + channel: 'wechat', + start: '20250101', + end: '20250131', + compare: 'team_median', + }) + expect(filters.channel.value).toBe('wechat') + expect(filters.start.value).toBe(20250101) + expect(filters.end.value).toBe(20250131) + expect(filters.compare.value).toBe('team_median') + }) + + it('非法 query 值 → 回退默认(channel=xx / start=abc / end=19990101 早于下限 / compare=foo)', async () => { + const { filters } = await setup({ channel: 'xx', start: 'abc', end: '19990101', compare: 'foo' }) + expect(filters.channel.value).toBe('all') + expect(filters.compare.value).toBe('previous') + expect(filters.start.value).toBe(presetRange('last30').start) + expect(filters.end.value).toBe(presetRange('last30').end) + }) + + it('apply({channel}) → query 更新且其余参数保留', async () => { + const { router, filters } = await setup({ compare: 'team_median' }) + filters.apply({ channel: 'whatsapp' }) + await vi.waitFor(() => expect(router.currentRoute.value.query.channel).toBe('whatsapp')) + expect(filters.channel.value).toBe('whatsapp') + // 未 patch 的 compare 原样保留 + expect(router.currentRoute.value.query.compare).toBe('team_median') + expect(filters.compare.value).toBe('team_median') + }) + + it('applyPreset(预设) → 清掉 query 里的 start/end,默认值(仍为 last30)生效', async () => { + const { router, filters } = await setup({ start: '20250101', end: '20250131', channel: 'wechat' }) + filters.applyPreset('last7') + await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBeUndefined()) + expect(router.currentRoute.value.query.end).toBeUndefined() + // channel 不在清理范围,保留 + expect(filters.channel.value).toBe('wechat') + // 清掉后回退默认(last30,与真实今天同源) + expect(filters.start.value).toBe(presetRange('last30').start) + expect(filters.end.value).toBe(presetRange('last30').end) + }) + + it("applyPreset('custom', {...}) → query 带具体日期", async () => { + const { router, filters } = await setup() + filters.applyPreset('custom', { start: 20260101, end: 20260131 }) + await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBe('20260101')) + expect(router.currentRoute.value.query.end).toBe('20260131') + expect(filters.start.value).toBe(20260101) + expect(filters.end.value).toBe(20260131) + }) +}) diff --git a/yunkes/src/composables/useManagementFilters.ts b/yunkes/src/composables/useManagementFilters.ts new file mode 100644 index 0000000..5df4828 --- /dev/null +++ b/yunkes/src/composables/useManagementFilters.ts @@ -0,0 +1,78 @@ +// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用; +// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。 +import { computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom' +export const CHANNELS = [ + { value: 'all', label: '全部渠道' }, + { value: 'wechat', label: '微信' }, + { value: 'whatsapp', label: 'WhatsApp' }, +] as const + +function ymd(d: Date): number { + return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() +} +function addDays(d: Date, n: number): Date { + const x = new Date(d) + x.setDate(x.getDate() + n) + return x +} + +/** 预设 → [start,end] yyyymmdd。 */ +export function presetRange(p: Preset, today = new Date()): { start: number; end: number } { + const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + switch (p) { + case 'last7': + return { start: ymd(addDays(t0, -6)), end: ymd(t0) } + case 'last30': + return { start: ymd(addDays(t0, -29)), end: ymd(t0) } + case 'thisMonth': + return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) } + case 'lastMonth': { + const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1) + const last = new Date(t0.getFullYear(), t0.getMonth(), 0) + return { start: ymd(first), end: ymd(last) } + } + } + // custom/未知:今天单日 + return { start: ymd(t0), end: ymd(t0) } +} + +export function useManagementFilters() { + const route = useRoute() + const router = useRouter() + + const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => { + const v = route.query.channel + return v === 'wechat' || v === 'whatsapp' ? v : 'all' + }) + const start = computed(() => { + const v = Number(route.query.start) + return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').start + }) + const end = computed(() => { + const v = Number(route.query.end) + return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').end + }) + const compare = computed(() => { + const v = route.query.compare + return v === 'personal_baseline' || v === 'team_median' ? v : 'previous' + }) + + function apply(patch: Record) { + const q: Record = {} + for (const [k, v] of Object.entries({ ...route.query, ...patch })) { + if (v !== undefined && v !== null && v !== '') q[k] = String(v) + } + router.replace({ query: q }) + } + + // 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期 + function applyPreset(p: Preset, custom?: { start: number; end: number }) { + if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end }) + else apply({ start: undefined, end: undefined }) + } + + return { channel, start, end, compare, apply, applyPreset, presetRange } +}