From ab8638de9d6a0758237f595a4348e8cd81ec1097 Mon Sep 17 00:00:00 2001 From: Eclipseic1848 <237380389+Eclipseic1848@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:43:01 -0700 Subject: [PATCH] feat: add yearly performance trend tooltips --- .../api/src/authorization.integration.test.ts | 12 +++++++++ apps/api/src/modules/performance.ts | 5 ++-- apps/web/e2e/full-stack.ts | 2 +- apps/web/e2e/routes.spec.ts | 10 +++++++- apps/web/src/pages/overview-page.tsx | 25 ++++++++++++++++--- apps/web/src/styles.css | 6 +++++ 6 files changed, 51 insertions(+), 9 deletions(-) diff --git a/apps/api/src/authorization.integration.test.ts b/apps/api/src/authorization.integration.test.ts index 5a8beef..10cc117 100644 --- a/apps/api/src/authorization.integration.test.ts +++ b/apps/api/src/authorization.integration.test.ts @@ -155,6 +155,18 @@ async function seedAuthorizationScenario(databaseUrl: string) { } } +test("总览趋势返回授权范围内全部历史月份", async () => { + await withMigratedTestDatabase(async (database) => { + await seedAuthorizationScenario(database.url); + await withTestApi(database.url, async (app) => { + const cookie = await loginCookie(app, "scope_alice"); + const response = await app.inject({ method: "GET", url: "/api/performance/dashboard?month=2025-08", headers: { cookie } }); + assert.equal(response.statusCode, 200, response.body); + assert.deepEqual(response.json().monthly, [{ month: "2026-08", total: "100.00" }]); + }); + }); +}); + test("纯系统管理员没有业务读取或导出权限", async () => { await withMigratedTestDatabase(async (database) => { await seedAuthorizationScenario(database.url); diff --git a/apps/api/src/modules/performance.ts b/apps/api/src/modules/performance.ts index b013550..373c964 100644 --- a/apps/api/src/modules/performance.ts +++ b/apps/api/src/modules/performance.ts @@ -1366,9 +1366,8 @@ export async function registerPerformance(app: FastifyInstance, db: Database, cl const monthly = await client.query<{ month: string; total: string }>( `select to_char(e.accounting_month, 'YYYY-MM') as month,sum(credit.attributed_amount)::text as total from performance_events e join performance_event_attributions credit on credit.event_id=e.id - where extract(year from e.accounting_month) = extract(year from $1::date) - and ${performanceAttributionScopeSql("credit", 2)} - group by e.accounting_month order by e.accounting_month`, [`${month}-01`, ...scopeValues]); + where ${performanceAttributionScopeSql("credit", 1)} + group by e.accounting_month order by e.accounting_month`, scopeValues); const groups = await client.query<{ id: string; name: string; total: string }>( `select coalesce(credit.group_unit_id::text,'legacy:'||credit.group_name) as id, string_agg(distinct credit.group_name,' / ' order by credit.group_name) as name, diff --git a/apps/web/e2e/full-stack.ts b/apps/web/e2e/full-stack.ts index d9950aa..d713de9 100644 --- a/apps/web/e2e/full-stack.ts +++ b/apps/web/e2e/full-stack.ts @@ -104,7 +104,7 @@ export const test = base.extend<{ database: FullStackDatabase; dismissOnboarding dismissOnboarding: [async ({ onboardingEnabled, page }, use) => { if (!onboardingEnabled) { await page.addLocatorHandler(page.locator(".onboarding-bubble"), async (dialog) => { - await dialog.getByRole("button", { name: "跳过全部引导" }).click(); + await dialog.getByRole("button", { name: "跳过全部引导" }).evaluate((button: HTMLButtonElement) => button.click()); }); } await use(); diff --git a/apps/web/e2e/routes.spec.ts b/apps/web/e2e/routes.spec.ts index 9f15cb4..c7277d5 100644 --- a/apps/web/e2e/routes.spec.ts +++ b/apps/web/e2e/routes.spec.ts @@ -181,7 +181,7 @@ test("总览失败后显示错误并可重试成功", async ({ database, page }) roleName: "销售助理", }); let fail=true;let allNegative=false; - await page.route("**/api/performance/dashboard",async(route)=>{if(fail){await route.fulfill({status:503,contentType:"application/json",body:'{"message":"总览暂不可用"}'});return;}const response=await route.fetch();const body=await response.json();await route.fulfill({response,json:{...body,month:"2026-09",monthly:allNegative?[{month:"2026-01",total:"-100"},{month:"2026-02",total:"-50"}]:[{month:"2026-01",total:"-100"},{month:"2026-02",total:"50"}],groups:[{id:"negative",name:"负向组",total:"-100"},{id:"positive",name:"正向组",total:"50"}]}});}); + await page.route("**/api/performance/dashboard",async(route)=>{if(fail){await route.fulfill({status:503,contentType:"application/json",body:'{"message":"总览暂不可用"}'});return;}const response=await route.fetch();const body=await response.json();await route.fulfill({response,json:{...body,month:"2026-09",monthly:allNegative?[{month:"2026-01",total:"-100"},{month:"2026-02",total:"-50"}]:[{month:"2025-01",total:"25"},{month:"2026-01",total:"-100"},{month:"2026-02",total:"50"}],groups:[{id:"negative",name:"负向组",total:"-100"},{id:"positive",name:"正向组",total:"50"}]}});}); await page.goto("/"); await login(page,"e2e_overview_retry"); await expect(page.getByRole("alert")).toHaveText("总览加载失败"); @@ -196,6 +196,14 @@ test("总览失败后显示错误并可重试成功", async ({ database, page }) const points=chart.locator("circle"); expect(Number(await points.nth(0).getAttribute("cy"))).toBeGreaterThan(zeroY); expect(Number(await points.nth(1).getAttribute("cy"))).toBeLessThan(zeroY); + await points.nth(1).hover(); + await expect(page.getByRole("tooltip")).toHaveText("2月 ¥50.00"); + await expect(page.getByLabel("趋势年份")).toHaveValue("2026"); + await page.getByLabel("趋势年份").selectOption("2025"); + const historicalChart=page.getByRole("img",{name:"2025年1月至12月业绩折线图"}); + await historicalChart.locator("circle").nth(0).focus(); + await expect(page.getByRole("tooltip")).toHaveText("1月 ¥25.00"); + await expect(historicalChart.locator("desc")).toContainText("1月 ¥25.00"); await expect(page.locator(".rank-row").nth(0)).toContainText("负向组"); await expect(page.locator(".rank-row").nth(0).locator("i")).toHaveClass(/negative/); allNegative=true; diff --git a/apps/web/src/pages/overview-page.tsx b/apps/web/src/pages/overview-page.tsx index 052a473..b078042 100644 --- a/apps/web/src/pages/overview-page.tsx +++ b/apps/web/src/pages/overview-page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { apiFetch, downloadApiFile, eventTypeName, formatMoney, readResponseJson } from "../app-api"; import type { DashboardData, DepartmentAchievement, DepartmentAchievementDrilldown, GroupAchievement, GroupAchievementDrilldown, GroupAchievementMember, PersonalAchievement, PersonalAchievementDrilldown, SalesAchievement, SalesAchievementDrilldown } from "../app-types"; import { Metric, Modal, PaginatedCollection, Status } from "../shared-ui"; @@ -9,6 +9,7 @@ export function Overview({ canEdit, canExport, onEnterOrders }: { canEdit: boole const [exportError,setExportError]=useState(""); const [exporting,setExporting]=useState(false); const [revision,setRevision]=useState(0); + const [trendYear,setTrendYear]=useState(null); const [showPersonalDetails,setShowPersonalDetails]=useState(false); const [personalDetailKind,setPersonalDetailKind]=useState<"actual"|"gap">("actual"); const [personalDetails,setPersonalDetails]=useState(null); @@ -53,6 +54,9 @@ export function Overview({ canEdit, canExport, onEnterOrders }: { canEdit: boole const departments=data?.departmentAchievements??[]; const organizationDepartments=organizationDetails?("departments" in organizationDetails?organizationDetails.departments:[organizationDetails]):[]; const dashboardDescription=sales?"销售组织目标与不可变业绩事件":departments.length?"个人、部门目标与不可变业绩事件":personal?"个人目标与不可变业绩事件":"原始账本,不代表正式绩效结果"; + const currentTrendYear=data?.month.slice(0,4)??""; + const trendYears=data?[...new Set([currentTrendYear,...data.monthly.map((item)=>item.month.slice(0,4))])].sort((left,right)=>right.localeCompare(left)):[]; + const displayedTrendYear=trendYear&&trendYears.includes(trendYear)?trendYear:currentTrendYear; async function exportOrders(){if(exporting)return;setExporting(true);setExportError("");try{await downloadApiFile("/api/exports/performance.csv");}catch(failure){setExportError(failure instanceof Error?failure.message:"导出失败,请重试。");}finally{setExporting(false);}} return

业绩账本总览

{data ? `${data.month.replace("-", " 年 ")} 月 · ${dashboardDescription}` : loadError?"总览暂时不可用":"正在加载真实业绩账本…"}

{canExport?:null}{canEdit?:null}
{exportError?

{exportError}

:null} @@ -68,7 +72,7 @@ export function Overview({ canEdit, canExport, onEnterOrders }: { canEdit: boole onClose={()=>{groupDetailsRequest.current+=1;setSelectedGroup(null);}} >{groupDetailsError?

{groupDetailsError}

:groupDetails?
:

正在读取小组业绩构成…

}:null}
-
{(pageItems)=><>{pageItems.map((group) => {const total=Number(group.total);return
{data.groups.indexOf(group)+1}
{group.name}
{total<0?"负向 ":"正向 "}{formatMoney(group.total)}
;})}}
+
年份}/>
{(pageItems)=><>{pageItems.map((group) => {const total=Number(group.total);return
{data.groups.indexOf(group)+1}
{group.name}
{total<0?"负向 ":"正向 "}{formatMoney(group.total)}
;})}}

最近业绩事件

入账后不可覆盖或删除

{(pageItems)=>{pageItems.map((event) => )}
订单编号业务员事件类型记账月金额组织归属状态
{event.orderNo}{event.salespersonName}{eventTypeName(event.eventType)}{event.month}{formatMoney(event.amount)}{event.groupName}已入账
}
:null}
; } @@ -102,6 +106,19 @@ function AchievementMembers({members}:{members:GroupAchievementMember[]}){ )}}:null; } -function PanelTitle({ title, note }: { title: string; note: string }) { return

{title}

{note}

; } +function PanelTitle({ title, note, action }: { title: string; note: string; action?: ReactNode }) { return

{title}

{note}

{action}
; } -function TrendChart({year,monthly}:{year:string;monthly:Array<{month:string;total:string}>}) { const totals=new Map(monthly.map((item)=>[item.month,item.total]));const raw=Array.from({length:12},(_,index)=>totals.get(`${year}-${String(index+1).padStart(2,"0")}`)??"0");const filled=raw.map(Number);const minimum=Math.min(0,...filled);const maximum=Math.max(0,...filled);const span=Math.max(1,maximum-minimum);const x=(index:number)=>44+index*(526/11);const y=(value:number)=>190-((value-minimum)/span)*150;const points=filled.map((value,index)=>`${x(index)},${y(value)}`).join(" ");const description=raw.map((value,index)=>`${index+1}月 ${formatMoney(value)}`).join(";");return
{description}{[40,90,140,190].map((lineY)=>)}{filled.map((value,index)=>)}
{Array.from({length:12},(_,index)=>{index+1}月)}
; } +function TrendChart({year,monthly}:{year:string;monthly:Array<{month:string;total:string}>}) { + const [activePoint,setActivePoint]=useState(null); + const totals=new Map(monthly.map((item)=>[item.month,item.total])); + const raw=Array.from({length:12},(_,index)=>totals.get(`${year}-${String(index+1).padStart(2,"0")}`)??"0"); + const filled=raw.map(Number); + const minimum=Math.min(0,...filled); + const maximum=Math.max(0,...filled); + const span=Math.max(1,maximum-minimum); + const x=(index:number)=>44+index*(526/11); + const y=(value:number)=>190-((value-minimum)/span)*150; + const points=filled.map((value,index)=>`${x(index)},${y(value)}`).join(" "); + const description=raw.map((value,index)=>`${index+1}月 ${formatMoney(value)}`).join(";"); + return
{description}{[40,90,140,190].map((lineY)=>)}{filled.map((value,index)=>setActivePoint(index)} onMouseLeave={()=>setActivePoint(null)} onFocus={()=>setActivePoint(index)} onBlur={()=>setActivePoint(null)}/>)}{activePoint!==null?
{activePoint+1}月 {formatMoney(raw[activePoint]!)}
:null}
{Array.from({length:12},(_,index)=>{index+1}月)}
; +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f509863..7ccb09c 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -244,11 +244,17 @@ form button:disabled { cursor: wait; opacity: .72; } .panel-title { display: flex; align-items: flex-start; justify-content: space-between; } .panel-title h2 { margin: 0; font-size: 16px; } .panel-title p { margin: 6px 0 0; color: var(--muted); font-size: 12px; } +.trend-year-filter { display: grid; gap: 4px; color: var(--muted); font-size: 11px; } +.trend-year-filter select { min-width: 104px; height: 34px; padding: 0 30px 0 10px; border: 1px solid var(--line); border-radius: 4px; background: #fff; color: var(--ink); } .chart { margin-top: 16px; } +.chart-plot { position: relative; } .chart svg { width: 100%; height: 210px; overflow: visible; } .grid-lines line { stroke: #e8edf2; stroke-width: 1; } .trend-zero-baseline { stroke: #738196; stroke-width: 1.5; stroke-dasharray: 5 4; } +.trend-point { cursor: pointer; } +.trend-point:focus-visible { outline: none; stroke-width: 5; } .trend-point-negative { stroke: #c84242; } +.trend-tooltip { position: absolute; z-index: 1; transform: translate(-50%, calc(-100% - 10px)); padding: 6px 8px; border-radius: 4px; background: #10233f; color: #fff; font-size: 12px; font-weight: 700; white-space: nowrap; pointer-events: none; box-shadow: 0 4px 12px rgb(15 35 65 / 18%); } .chart-labels { margin: -5px 2.5% 0 6.5%; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; } .rank-row { padding: 14px 0; border-bottom: 1px solid #edf0f4; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 10px; } .rank-row:last-child { border-bottom: 0; }