Commit 16e80d5faa6774f1207a4a3efdf68539ae296740

Authored by zichun
1 parent 253aec06

feat(fe-shell): 主页 KPI 合并网格与空数据态 REQ-USR-003

frontend/src/pages/home/HomePage/HomePage.module.css 0 → 100644
  1 +/* REQ-USR-003: 主页 scoped 样式。语义色用 var(--color-*);网格线/底色经 token。 */
  2 +
  3 +.home {
  4 + display: grid;
  5 + grid-template-columns: 1fr 280px;
  6 + gap: 10px;
  7 + padding: 10px;
  8 + min-height: 100%;
  9 +}
  10 +
  11 +.mainCol {
  12 + display: flex;
  13 + flex-direction: column;
  14 + gap: 10px;
  15 + min-width: 0;
  16 +}
  17 +
  18 +.panel {
  19 + background: var(--color-form-bg-edit);
  20 + border: 1px solid var(--color-border);
  21 + border-radius: 2px;
  22 +}
  23 +
  24 +/* ===== KPI head bar ===== */
  25 +.kpiHead {
  26 + padding: 14px 18px;
  27 + display: flex;
  28 + align-items: center;
  29 + gap: 24px;
  30 + flex-wrap: wrap;
  31 +}
  32 +.kpiHeadTitle {
  33 + font-size: 15px;
  34 + font-weight: 500;
  35 + color: var(--color-text);
  36 + margin-right: 6px;
  37 +}
  38 +.kpiStat {
  39 + color: var(--color-text-secondary);
  40 +}
  41 +.kpiStat b {
  42 + color: var(--color-error);
  43 + font-weight: 500;
  44 + margin-left: 6px;
  45 + font-size: 14px;
  46 +}
  47 +.kpiStatBlue b {
  48 + color: var(--color-primary);
  49 +}
  50 +.kpiSep {
  51 + color: var(--color-border);
  52 +}
  53 +.aiBtn {
  54 + margin-left: auto;
  55 +}
  56 +
  57 +/* ===== three-col layout ===== */
  58 +.threeCol {
  59 + display: grid;
  60 + grid-template-columns: 280px 1fr;
  61 + gap: 10px;
  62 + min-height: 0;
  63 +}
  64 +.leftNav {
  65 + background: var(--color-form-bg-edit);
  66 + border: 1px solid var(--color-border);
  67 + overflow: auto;
  68 + max-height: 70vh;
  69 +}
  70 +.center {
  71 + display: flex;
  72 + flex-direction: column;
  73 + gap: 10px;
  74 + min-width: 0;
  75 +}
  76 +
  77 +/* ===== role / process tree ===== */
  78 +.tree {
  79 + padding: 6px 0;
  80 +}
  81 +.treeGroup {
  82 + padding: 8px 14px;
  83 + color: var(--color-text);
  84 + font-size: 13px;
  85 + font-weight: 500;
  86 + display: flex;
  87 + align-items: center;
  88 + gap: 6px;
  89 +}
  90 +.treeItem {
  91 + padding: 6px 14px 6px 36px;
  92 + display: flex;
  93 + align-items: center;
  94 + gap: 8px;
  95 + color: var(--color-text);
  96 + cursor: pointer;
  97 + font-size: 13px;
  98 + border: none;
  99 + background: transparent;
  100 + width: 100%;
  101 + text-align: left;
  102 +}
  103 +.treeItem:hover {
  104 + background: var(--color-table-row-bg-hover);
  105 +}
  106 +.treeItemActive {
  107 + background: var(--color-table-row-bg-selected);
  108 + color: var(--color-text);
  109 +}
  110 +
  111 +/* ===== KPI merged grid (复刻原型 kpi-body) ===== */
  112 +.kpiBoard {
  113 + display: grid;
  114 + grid-template-columns: 200px 90px 1fr 1fr 90px 90px 130px;
  115 + border-top: 1px solid var(--color-border);
  116 + overflow: auto;
  117 +}
  118 +.kpiBoard > div {
  119 + border-right: 1px solid var(--color-border);
  120 + border-bottom: 1px solid var(--color-border);
  121 + padding: 10px 12px;
  122 + font-size: 13px;
  123 + min-height: 38px;
  124 + display: flex;
  125 + align-items: center;
  126 +}
  127 +.kpiHeadCell {
  128 + background: var(--color-table-header-bg);
  129 + font-weight: 500;
  130 + color: var(--color-table-header-fg);
  131 + padding: 9px 12px;
  132 +}
  133 +.kpiNavType {
  134 + justify-content: center;
  135 +}
  136 +.kpiCellCenter {
  137 + justify-content: center;
  138 +}
  139 +.kpiLink {
  140 + color: var(--color-primary);
  141 + cursor: pointer;
  142 +}
  143 +.kpiLink:hover {
  144 + text-decoration: underline;
  145 +}
  146 +.kpiNum {
  147 + justify-content: center;
  148 +}
  149 +.kpiNumRed {
  150 + color: var(--color-error);
  151 + font-weight: 600;
  152 +}
  153 +.kpiSubProc {
  154 + writing-mode: vertical-rl;
  155 + text-orientation: upright;
  156 + color: var(--color-text);
  157 + font-weight: 500;
  158 + justify-content: center;
  159 +}
  160 +.kpiRowAlt {
  161 + background: var(--color-form-bg-readonly);
  162 +}
  163 +.kpiEmpty {
  164 + padding: 32px 0;
  165 + display: flex;
  166 + justify-content: center;
  167 +}
  168 +
  169 +/* ===== common ops ===== */
  170 +.commonOps {
  171 + padding: 14px 18px;
  172 + height: fit-content;
  173 +}
  174 +.commonOpsTitle {
  175 + font-size: 14px;
  176 + color: var(--color-text);
  177 + margin-bottom: 14px;
  178 + font-weight: 500;
  179 +}
  180 +.commonOpsLink {
  181 + display: block;
  182 + color: var(--color-primary);
  183 + padding: 8px 0;
  184 + font-size: 13px;
  185 + border: none;
  186 + background: transparent;
  187 + cursor: pointer;
  188 + text-align: left;
  189 + width: 100%;
  190 +}
  191 +.commonOpsLink:hover {
  192 + text-decoration: underline;
  193 +}
... ...
frontend/src/pages/home/HomePage/KpiBoard.tsx 0 → 100644
  1 +// REQ-USR-003: KPI 合并网格(D5:CSS Grid gridRow span 复刻原型 kpi-body 合并)。
  2 +// 「导航类型」整列一格跨全部行;「角色」「子流程」按 roleSpan/subSpan 合并;空数据 → Empty。
  3 +import { Empty } from 'antd';
  4 +import type { KpiRow } from './dashboardData';
  5 +import { KPI_HEADERS } from './dashboardData';
  6 +import styles from './HomePage.module.css';
  7 +
  8 +interface KpiBoardProps {
  9 + rows: KpiRow[];
  10 + /** KPI 待处理事项/描述为纯展示(蓝色链接样式),点击不跳转;保留占位以示意无导航。 */
  11 + onNavigate?: (target: string) => void;
  12 +}
  13 +
  14 +export default function KpiBoard({ rows }: KpiBoardProps) {
  15 + if (!rows.length) {
  16 + return (
  17 + <div className={styles.kpiEmpty} data-testid="kpi-empty">
  18 + <Empty />
  19 + </div>
  20 + );
  21 + }
  22 +
  23 + const total = rows.length;
  24 + // 表头行 = 第 1 行;数据行从第 2 行起
  25 + const cells: React.ReactNode[] = [];
  26 +
  27 + // 表头 7 列
  28 + KPI_HEADERS.forEach((h, i) => {
  29 + cells.push(
  30 + <div key={`h-${i}`} className={styles.kpiHeadCell} style={{ gridColumn: i + 1, gridRow: 1 }}>
  31 + {h}
  32 + </div>,
  33 + );
  34 + });
  35 +
  36 + // 导航类型:整列一格,跨全部数据行(复刻原型 navCell '按角色')
  37 + cells.push(
  38 + <div
  39 + key="navtype"
  40 + className={styles.kpiNavType}
  41 + style={{ gridColumn: 1, gridRow: `2 / span ${total}` }}
  42 + >
  43 + 按角色
  44 + </div>,
  45 + );
  46 +
  47 + rows.forEach((row, idx) => {
  48 + const curRow = idx + 2;
  49 + const alt = idx % 2 === 1 ? styles.kpiRowAlt : '';
  50 +
  51 + // 角色(带 rowSpan)
  52 + if (row.role) {
  53 + const span = row.roleSpan ?? 1;
  54 + cells.push(
  55 + <div
  56 + key={`role-${idx}`}
  57 + className={`${styles.kpiCellCenter} ${alt}`}
  58 + style={{ gridColumn: 2, gridRow: `${curRow} / span ${span}` }}
  59 + >
  60 + {row.role}
  61 + </div>,
  62 + );
  63 + }
  64 +
  65 + // KPI 待处理事项(蓝色链接样式,纯展示)
  66 + cells.push(
  67 + <div
  68 + key={`item-${idx}`}
  69 + className={`${styles.kpiLink} ${alt}`}
  70 + style={{ gridColumn: 3, gridRow: curRow }}
  71 + >
  72 + {row.item}
  73 + </div>,
  74 + );
  75 +
  76 + // KPI 内容描述(蓝色链接样式,纯展示)
  77 + cells.push(
  78 + <div
  79 + key={`desc-${idx}`}
  80 + className={`${styles.kpiLink} ${alt}`}
  81 + style={{ gridColumn: 4, gridRow: curRow }}
  82 + >
  83 + {row.desc}
  84 + </div>,
  85 + );
  86 +
  87 + // 今日未处理
  88 + cells.push(
  89 + <div
  90 + key={`today-${idx}`}
  91 + className={`${styles.kpiNum} ${row.red ? styles.kpiNumRed : ''} ${alt}`}
  92 + style={{ gridColumn: 5, gridRow: curRow }}
  93 + >
  94 + {row.today}
  95 + </div>,
  96 + );
  97 +
  98 + // 未清总数
  99 + cells.push(
  100 + <div
  101 + key={`total-${idx}`}
  102 + className={`${styles.kpiNum} ${row.red ? styles.kpiNumRed : ''} ${alt}`}
  103 + style={{ gridColumn: 6, gridRow: curRow }}
  104 + >
  105 + {row.total}
  106 + </div>,
  107 + );
  108 +
  109 + // 子流程(带 rowSpan)
  110 + if (row.sub && row.subSpan) {
  111 + cells.push(
  112 + <div
  113 + key={`sub-${idx}`}
  114 + className={styles.kpiSubProc}
  115 + style={{ gridColumn: 7, gridRow: `${curRow} / span ${row.subSpan}` }}
  116 + >
  117 + {row.sub}
  118 + </div>,
  119 + );
  120 + }
  121 + });
  122 +
  123 + return (
  124 + <div
  125 + className={styles.kpiBoard}
  126 + style={{ gridTemplateRows: `38px repeat(${total}, minmax(38px, auto))` }}
  127 + >
  128 + {cells}
  129 + </div>
  130 + );
  131 +}
... ...
frontend/tests/unit/KpiBoard.test.tsx 0 → 100644
  1 +// REQ-USR-003: KpiBoard KPI 合并网格 + 空数据(BR11 / empty 态 / D5)
  2 +import { describe, it, expect, vi } from 'vitest';
  3 +import { render, screen } from '@testing-library/react';
  4 +import userEvent from '@testing-library/user-event';
  5 +import { ConfigProvider, App as AntdApp } from 'antd';
  6 +import zhCN from 'antd/locale/zh_CN';
  7 +import KpiBoard from '../../src/pages/home/HomePage/KpiBoard';
  8 +import { KPI_ROWS, KPI_HEADERS } from '../../src/pages/home/HomePage/dashboardData';
  9 +
  10 +function renderBoard(ui: React.ReactElement) {
  11 + return render(
  12 + <ConfigProvider locale={zhCN}>
  13 + <AntdApp>{ui}</AntdApp>
  14 + </ConfigProvider>,
  15 + );
  16 +}
  17 +
  18 +describe('KpiBoard', () => {
  19 + it('renders 7 column headers', () => {
  20 + renderBoard(<KpiBoard rows={KPI_ROWS} />);
  21 + KPI_HEADERS.forEach((h) => {
  22 + expect(screen.getByText(h)).toBeInTheDocument();
  23 + });
  24 + });
  25 +
  26 + it('renders all kpi rows with item/desc/today/total', () => {
  27 + renderBoard(<KpiBoard rows={KPI_ROWS} />);
  28 + expect(screen.getByText('01/04【新增】新报价单')).toBeInTheDocument();
  29 + expect(screen.getByText('02/04 审核后报价单->客户确认价格')).toBeInTheDocument();
  30 + // 红色统计数(red 行)
  31 + const red = screen.getAllByText('16');
  32 + expect(red.length).toBeGreaterThanOrEqual(1);
  33 + });
  34 +
  35 + it('renders Empty when rows is empty', () => {
  36 + renderBoard(<KpiBoard rows={[]} />);
  37 + expect(screen.getByTestId('kpi-empty')).toBeInTheDocument();
  38 + // AntD Empty 默认描述「暂无数据」(可能在 <p> 与 svg <title> 各出现一次)
  39 + expect(screen.getAllByText('暂无数据').length).toBeGreaterThanOrEqual(1);
  40 + });
  41 +
  42 + it('KPI item/desc rendered as link-styled text without navigation', async () => {
  43 + const onNav = vi.fn();
  44 + renderBoard(<KpiBoard rows={KPI_ROWS} onNavigate={onNav} />);
  45 + const item = screen.getByText('01/04【新增】新报价单');
  46 + await userEvent.click(item);
  47 + // 纯展示,不发生路由跳转
  48 + expect(onNav).not.toHaveBeenCalled();
  49 + });
  50 +});
... ...
frontend/tests/unit/renderShell.tsx
... ... @@ -5,6 +5,7 @@ import { render } from &#39;@testing-library/react&#39;;
5 5 import { Provider } from 'react-redux';
6 6 import { MemoryRouter } from 'react-router-dom';
7 7 import { App as AntdApp, ConfigProvider } from 'antd';
  8 +import zhCN from 'antd/locale/zh_CN';
8 9 import { configureStore } from '@reduxjs/toolkit';
9 10 import authReducer, { type AuthState } from '../../src/store/slices/authSlice';
10 11 import type { RootState } from '../../src/store/store';
... ... @@ -29,7 +30,7 @@ export function renderShell(ui: ReactElement, options?: RenderShellOptions) {
29 30 const store = options?.store ?? makeShellStore(options?.preloadedAuth);
30 31 const result = render(
31 32 <Provider store={store}>
32   - <ConfigProvider>
  33 + <ConfigProvider locale={zhCN}>
33 34 <AntdApp>
34 35 <MemoryRouter initialEntries={options?.initialEntries ?? ['/']}>
35 36 {ui}
... ...