diff --git a/frontend/src/pages/home/HomePage/HomePage.module.css b/frontend/src/pages/home/HomePage/HomePage.module.css new file mode 100644 index 0000000..4b3a4e2 --- /dev/null +++ b/frontend/src/pages/home/HomePage/HomePage.module.css @@ -0,0 +1,193 @@ +/* REQ-USR-003: 主页 scoped 样式。语义色用 var(--color-*);网格线/底色经 token。 */ + +.home { + display: grid; + grid-template-columns: 1fr 280px; + gap: 10px; + padding: 10px; + min-height: 100%; +} + +.mainCol { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.panel { + background: var(--color-form-bg-edit); + border: 1px solid var(--color-border); + border-radius: 2px; +} + +/* ===== KPI head bar ===== */ +.kpiHead { + padding: 14px 18px; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} +.kpiHeadTitle { + font-size: 15px; + font-weight: 500; + color: var(--color-text); + margin-right: 6px; +} +.kpiStat { + color: var(--color-text-secondary); +} +.kpiStat b { + color: var(--color-error); + font-weight: 500; + margin-left: 6px; + font-size: 14px; +} +.kpiStatBlue b { + color: var(--color-primary); +} +.kpiSep { + color: var(--color-border); +} +.aiBtn { + margin-left: auto; +} + +/* ===== three-col layout ===== */ +.threeCol { + display: grid; + grid-template-columns: 280px 1fr; + gap: 10px; + min-height: 0; +} +.leftNav { + background: var(--color-form-bg-edit); + border: 1px solid var(--color-border); + overflow: auto; + max-height: 70vh; +} +.center { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +/* ===== role / process tree ===== */ +.tree { + padding: 6px 0; +} +.treeGroup { + padding: 8px 14px; + color: var(--color-text); + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; +} +.treeItem { + padding: 6px 14px 6px 36px; + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text); + cursor: pointer; + font-size: 13px; + border: none; + background: transparent; + width: 100%; + text-align: left; +} +.treeItem:hover { + background: var(--color-table-row-bg-hover); +} +.treeItemActive { + background: var(--color-table-row-bg-selected); + color: var(--color-text); +} + +/* ===== KPI merged grid (复刻原型 kpi-body) ===== */ +.kpiBoard { + display: grid; + grid-template-columns: 200px 90px 1fr 1fr 90px 90px 130px; + border-top: 1px solid var(--color-border); + overflow: auto; +} +.kpiBoard > div { + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + padding: 10px 12px; + font-size: 13px; + min-height: 38px; + display: flex; + align-items: center; +} +.kpiHeadCell { + background: var(--color-table-header-bg); + font-weight: 500; + color: var(--color-table-header-fg); + padding: 9px 12px; +} +.kpiNavType { + justify-content: center; +} +.kpiCellCenter { + justify-content: center; +} +.kpiLink { + color: var(--color-primary); + cursor: pointer; +} +.kpiLink:hover { + text-decoration: underline; +} +.kpiNum { + justify-content: center; +} +.kpiNumRed { + color: var(--color-error); + font-weight: 600; +} +.kpiSubProc { + writing-mode: vertical-rl; + text-orientation: upright; + color: var(--color-text); + font-weight: 500; + justify-content: center; +} +.kpiRowAlt { + background: var(--color-form-bg-readonly); +} +.kpiEmpty { + padding: 32px 0; + display: flex; + justify-content: center; +} + +/* ===== common ops ===== */ +.commonOps { + padding: 14px 18px; + height: fit-content; +} +.commonOpsTitle { + font-size: 14px; + color: var(--color-text); + margin-bottom: 14px; + font-weight: 500; +} +.commonOpsLink { + display: block; + color: var(--color-primary); + padding: 8px 0; + font-size: 13px; + border: none; + background: transparent; + cursor: pointer; + text-align: left; + width: 100%; +} +.commonOpsLink:hover { + text-decoration: underline; +} diff --git a/frontend/src/pages/home/HomePage/KpiBoard.tsx b/frontend/src/pages/home/HomePage/KpiBoard.tsx new file mode 100644 index 0000000..09d27bf --- /dev/null +++ b/frontend/src/pages/home/HomePage/KpiBoard.tsx @@ -0,0 +1,131 @@ +// REQ-USR-003: KPI 合并网格(D5:CSS Grid gridRow span 复刻原型 kpi-body 合并)。 +// 「导航类型」整列一格跨全部行;「角色」「子流程」按 roleSpan/subSpan 合并;空数据 → Empty。 +import { Empty } from 'antd'; +import type { KpiRow } from './dashboardData'; +import { KPI_HEADERS } from './dashboardData'; +import styles from './HomePage.module.css'; + +interface KpiBoardProps { + rows: KpiRow[]; + /** KPI 待处理事项/描述为纯展示(蓝色链接样式),点击不跳转;保留占位以示意无导航。 */ + onNavigate?: (target: string) => void; +} + +export default function KpiBoard({ rows }: KpiBoardProps) { + if (!rows.length) { + return ( +
+ +
+ ); + } + + const total = rows.length; + // 表头行 = 第 1 行;数据行从第 2 行起 + const cells: React.ReactNode[] = []; + + // 表头 7 列 + KPI_HEADERS.forEach((h, i) => { + cells.push( +
+ {h} +
, + ); + }); + + // 导航类型:整列一格,跨全部数据行(复刻原型 navCell '按角色') + cells.push( +
+ 按角色 +
, + ); + + rows.forEach((row, idx) => { + const curRow = idx + 2; + const alt = idx % 2 === 1 ? styles.kpiRowAlt : ''; + + // 角色(带 rowSpan) + if (row.role) { + const span = row.roleSpan ?? 1; + cells.push( +
+ {row.role} +
, + ); + } + + // KPI 待处理事项(蓝色链接样式,纯展示) + cells.push( +
+ {row.item} +
, + ); + + // KPI 内容描述(蓝色链接样式,纯展示) + cells.push( +
+ {row.desc} +
, + ); + + // 今日未处理 + cells.push( +
+ {row.today} +
, + ); + + // 未清总数 + cells.push( +
+ {row.total} +
, + ); + + // 子流程(带 rowSpan) + if (row.sub && row.subSpan) { + cells.push( +
+ {row.sub} +
, + ); + } + }); + + return ( +
+ {cells} +
+ ); +} diff --git a/frontend/tests/unit/KpiBoard.test.tsx b/frontend/tests/unit/KpiBoard.test.tsx new file mode 100644 index 0000000..10f7fda --- /dev/null +++ b/frontend/tests/unit/KpiBoard.test.tsx @@ -0,0 +1,50 @@ +// REQ-USR-003: KpiBoard KPI 合并网格 + 空数据(BR11 / empty 态 / D5) +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ConfigProvider, App as AntdApp } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import KpiBoard from '../../src/pages/home/HomePage/KpiBoard'; +import { KPI_ROWS, KPI_HEADERS } from '../../src/pages/home/HomePage/dashboardData'; + +function renderBoard(ui: React.ReactElement) { + return render( + + {ui} + , + ); +} + +describe('KpiBoard', () => { + it('renders 7 column headers', () => { + renderBoard(); + KPI_HEADERS.forEach((h) => { + expect(screen.getByText(h)).toBeInTheDocument(); + }); + }); + + it('renders all kpi rows with item/desc/today/total', () => { + renderBoard(); + expect(screen.getByText('01/04【新增】新报价单')).toBeInTheDocument(); + expect(screen.getByText('02/04 审核后报价单->客户确认价格')).toBeInTheDocument(); + // 红色统计数(red 行) + const red = screen.getAllByText('16'); + expect(red.length).toBeGreaterThanOrEqual(1); + }); + + it('renders Empty when rows is empty', () => { + renderBoard(); + expect(screen.getByTestId('kpi-empty')).toBeInTheDocument(); + // AntD Empty 默认描述「暂无数据」(可能在

与 svg 各出现一次) + expect(screen.getAllByText('暂无数据').length).toBeGreaterThanOrEqual(1); + }); + + it('KPI item/desc rendered as link-styled text without navigation', async () => { + const onNav = vi.fn(); + renderBoard(<KpiBoard rows={KPI_ROWS} onNavigate={onNav} />); + const item = screen.getByText('01/04【新增】新报价单'); + await userEvent.click(item); + // 纯展示,不发生路由跳转 + expect(onNav).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/renderShell.tsx b/frontend/tests/unit/renderShell.tsx index 1a8ac40..f6c0b01 100644 --- a/frontend/tests/unit/renderShell.tsx +++ b/frontend/tests/unit/renderShell.tsx @@ -5,6 +5,7 @@ import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { App as AntdApp, ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; import { configureStore } from '@reduxjs/toolkit'; import authReducer, { type AuthState } from '../../src/store/slices/authSlice'; import type { RootState } from '../../src/store/store'; @@ -29,7 +30,7 @@ export function renderShell(ui: ReactElement, options?: RenderShellOptions) { const store = options?.store ?? makeShellStore(options?.preloadedAuth); const result = render( <Provider store={store}> - <ConfigProvider> + <ConfigProvider locale={zhCN}> <AntdApp> <MemoryRouter initialEntries={options?.initialEntries ?? ['/']}> {ui}