← 所有 Blocks Dashboards 仪表盘

Analytics Console 销售分析看板

Pivot 透视 + 热力图 + 单元格 drill-down 到 Drawer 看明细。可切换聚合方式 / 行字段 / 列字段;点格子触发 modal.confirm 重置数据。把 0.2.0 新增的 Pivot 和 Drawer v2 串起来跑。

analytics-console source
AnalyticsConsole.vue vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
  CfButton,
  CfDrawer,
  CfMetricCard,
  CfPivot,
  CfSelect,
  CfTag,
  modal,
  pivotCompute,
  toast,
  type PivotAggregator,
} from '@chufix-design/vue';

interface Order {
  region: string;
  channel: string;
  product: string;
  amount: number;
  qty: number;
}

const REGIONS = ['华北', '华东', '华南', '西部'];
const CHANNELS = ['官网', '门店', 'App', '分销'];
const PRODUCTS = ['Pro', 'Standard', 'Lite'];

function seed(): Order[] {
  const rows: Order[] = [];
  let s = 42;
  function rnd() { s = (s * 1664525 + 1013904223) >>> 0; return (s >>> 8) / 0x1000000; }
  for (const r of REGIONS) {
    for (const c of CHANNELS) {
      for (const p of PRODUCTS) {
        const n = 4 + Math.floor(rnd() * 8);
        for (let i = 0; i < n; i++) {
          rows.push({ region: r, channel: c, product: p, amount: Math.round(800 + rnd() * 9000), qty: 1 + Math.floor(rnd() * 12) });
        }
      }
    }
  }
  return rows;
}

const data = ref<Order[]>(seed());

const aggOptions = [
  { value: 'sum', label: '求和' },
  { value: 'avg', label: '平均' },
  { value: 'count', label: '计数' },
  { value: 'max', label: '最大' },
];
const aggregator = ref<PivotAggregator>('sum');

const dimensionOptions = [
  { value: 'region', label: '区域' },
  { value: 'channel', label: '渠道' },
  { value: 'product', label: '产品' },
];
const rowField = ref<keyof Order>('region');
const colField = ref<keyof Order>('channel');

const totals = computed(() => {
  const r = pivotCompute(data.value, rowField.value as string, colField.value as string, 'amount', aggregator.value);
  return r;
});

const orderCount = computed(() => data.value.length);
const grandTotal = computed(() => totals.value.grandTotal);
const avgPerOrder = computed(() => orderCount.value ? Math.round(grandTotal.value / orderCount.value) : 0);

/* drill-down drawer */
const drawerOpen = ref(false);
const drillRows = ref<Order[]>([]);
const drillTitle = ref('');

function onCellClick(p: { row: string; col: string; value: number; rows: unknown[] }) {
  drillTitle.value = `${p.row} × ${p.col}`;
  drillRows.value = p.rows as Order[];
  drawerOpen.value = true;
}

async function reloadData() {
  const ok = await modal.confirm({
    title: '重新生成模拟数据?',
    description: '会丢弃当前看板里的所有调整。',
  });
  if (!ok) return;
  data.value = seed();
  toast({ type: 'success', message: '已生成新一批数据' });
}
</script>

<template>
  <div class="ac">
    <header class="ac__head">
      <div>
        <h1 class="ac__title">销售分析看板</h1>
        <p class="ac__sub">
          <CfTag tone="info" size="sm">live</CfTag>
          点击任意单元格查看明细
        </p>
      </div>
      <CfButton variant="tertiary" @click="reloadData">重新生成数据</CfButton>
    </header>

    <div class="ac__kpis">
      <CfMetricCard label="订单数" :value="orderCount" />
      <CfMetricCard label="总额" :value="grandTotal" prefix="¥" />
      <CfMetricCard label="客单均价" :value="avgPerOrder" prefix="¥" />
      <CfMetricCard label="活跃区域" :value="totals.rowKeys.length" hint="非零行数" />
    </div>

    <section class="ac__controls">
      <label class="ac__field">
        <span>聚合方式</span>
        <CfSelect v-model="aggregator" :options="aggOptions" size="sm" style="max-width: 140px;" />
      </label>
      <label class="ac__field">
        <span>行</span>
        <CfSelect v-model="rowField" :options="dimensionOptions" size="sm" style="max-width: 140px;" />
      </label>
      <label class="ac__field">
        <span>列</span>
        <CfSelect v-model="colField" :options="dimensionOptions" size="sm" style="max-width: 140px;" />
      </label>
    </section>

    <CfPivot
      :data="data"
      :row-field="rowField"
      :col-field="colField"
      value-field="amount"
      :aggregator="aggregator"
      heatmap
      :format="(v: number) => '¥' + v.toLocaleString()"
      :on-cell-click="onCellClick"
    />

    <CfDrawer
      v-model:open="drawerOpen"
      placement="right"
      size="md"
      tone="info"
      :title="`明细:${drillTitle}`"
      :description="`命中 ${drillRows.length} 条原始记录`"
    >
      <div class="ac__detail">
        <div v-for="(r, i) in drillRows" :key="i" class="ac__detail-row">
          <span class="ac__detail-product">{{ r.product }}</span>
          <span class="ac__detail-amount">¥{{ r.amount.toLocaleString() }}</span>
          <span class="ac__detail-qty">×{{ r.qty }}</span>
        </div>
      </div>
    </CfDrawer>
  </div>
</template>

<style scoped>
.ac {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 16px;
  background: var(--bg-1);
  font-family: var(--font-sans);
}
.ac__head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
}
.ac__title {
  margin: 0;
  font-size: var(--t-22);
  font-weight: var(--w-semibold);
  color: var(--fg-1);
}
.ac__sub {
  margin: 4px 0 0;
  display: inline-flex;
  align-items: center;
  gap: 8px;
  font-size: var(--t-12);
  color: var(--fg-3);
}
.ac__kpis {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
}
@media (max-width: 720px) {
  .ac__kpis { grid-template-columns: repeat(2, 1fr); }
}
.ac__controls {
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  padding: 10px 12px;
  background: var(--bg-2);
  border-radius: 6px;
}
.ac__field {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--fg-3);
}
.ac__detail {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.ac__detail-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  gap: 12px;
  padding: 6px 8px;
  background: var(--bg-2);
  border-radius: 4px;
  font-size: 12px;
}
.ac__detail-product { font-weight: var(--w-medium); }
.ac__detail-amount { color: var(--accent-1); font-variant-numeric: tabular-nums; }
.ac__detail-qty { color: var(--fg-3); font-variant-numeric: tabular-nums; }
</style>
AnalyticsConsole.tsx tsx
import { useMemo, useState } from 'react';
import {
  CfButton,
  CfDrawer,
  CfMetricCard,
  CfPivot,
  CfSelect,
  CfTag,
  modal,
  pivotCompute,
  toast,
  type PivotAggregator,
} from '@chufix-design/react';

interface Order {
  region: string;
  channel: string;
  product: string;
  amount: number;
  qty: number;
}

const REGIONS = ['华北', '华东', '华南', '西部'];
const CHANNELS = ['官网', '门店', 'App', '分销'];
const PRODUCTS = ['Pro', 'Standard', 'Lite'];

function seed(): Order[] {
  const rows: Order[] = [];
  let s = 42;
  function rnd() { s = (s * 1664525 + 1013904223) >>> 0; return (s >>> 8) / 0x1000000; }
  for (const r of REGIONS) {
    for (const c of CHANNELS) {
      for (const p of PRODUCTS) {
        const n = 4 + Math.floor(rnd() * 8);
        for (let i = 0; i < n; i++) {
          rows.push({ region: r, channel: c, product: p, amount: Math.round(800 + rnd() * 9000), qty: 1 + Math.floor(rnd() * 12) });
        }
      }
    }
  }
  return rows;
}

const aggOptions = [
  { value: 'sum', label: '求和' },
  { value: 'avg', label: '平均' },
  { value: 'count', label: '计数' },
  { value: 'max', label: '最大' },
];

const dimensionOptions = [
  { value: 'region', label: '区域' },
  { value: 'channel', label: '渠道' },
  { value: 'product', label: '产品' },
];

export function AnalyticsConsole() {
  const [data, setData] = useState<Order[]>(seed);
  const [aggregator, setAggregator] = useState<PivotAggregator>('sum');
  const [rowField, setRowField] = useState<keyof Order>('region');
  const [colField, setColField] = useState<keyof Order>('channel');
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [drillRows, setDrillRows] = useState<Order[]>([]);
  const [drillTitle, setDrillTitle] = useState('');

  const totals = useMemo(
    () => pivotCompute(data, rowField, colField, 'amount', aggregator),
    [data, rowField, colField, aggregator],
  );
  const orderCount = data.length;
  const grandTotal = totals.grandTotal;
  const avgPerOrder = orderCount ? Math.round(grandTotal / orderCount) : 0;

  async function reloadData() {
    const ok = await modal.confirm({
      title: '重新生成模拟数据?',
      description: '会丢弃当前看板里的所有调整。',
    });
    if (!ok) return;
    setData(seed());
    toast({ type: 'success', message: '已生成新一批数据' });
  }

  return (
    <div className="ac">
      <header className="ac__head">
        <div>
          <h1 className="ac__title">销售分析看板</h1>
          <p className="ac__sub">
            <CfTag tone="info" size="sm">
              live
            </CfTag>
            点击任意单元格查看明细
          </p>
        </div>
        <CfButton variant="tertiary" onClick={reloadData}>
          重新生成数据
        </CfButton>
      </header>

      <div className="ac__kpis">
        <CfMetricCard label="订单数" value={orderCount} />
        <CfMetricCard label="总额" value={grandTotal} prefix="¥" />
        <CfMetricCard label="客单均价" value={avgPerOrder} prefix="¥" />
        <CfMetricCard label="活跃区域" value={totals.rowKeys.length} hint="非零行数" />
      </div>

      <section className="ac__controls">
        <label className="ac__field">
          <span>聚合方式</span>
          <CfSelect
            value={aggregator}
            options={aggOptions}
            size="sm"
            onChange={(v) => setAggregator(v as PivotAggregator)}
          />
        </label>
        <label className="ac__field">
          <span>行</span>
          <CfSelect
            value={rowField}
            options={dimensionOptions}
            size="sm"
            onChange={(v) => setRowField(v as keyof Order)}
          />
        </label>
        <label className="ac__field">
          <span>列</span>
          <CfSelect
            value={colField}
            options={dimensionOptions}
            size="sm"
            onChange={(v) => setColField(v as keyof Order)}
          />
        </label>
      </section>

      <CfPivot<Order>
        data={data}
        rowField={rowField}
        colField={colField}
        valueField="amount"
        aggregator={aggregator}
        heatmap
        format={(v) => '¥' + v.toLocaleString()}
        onCellClick={(p) => {
          setDrillTitle(`${p.row} × ${p.col}`);
          setDrillRows(p.rows as Order[]);
          setDrawerOpen(true);
        }}
      />

      <CfDrawer
        open={drawerOpen}
        onOpenChange={setDrawerOpen}
        placement="right"
        size="md"
        tone="info"
        title={`明细:${drillTitle}`}
        description={`命中 ${drillRows.length} 条原始记录`}
      >
        <div className="ac__detail">
          {drillRows.map((r, i) => (
            <div key={i} className="ac__detail-row">
              <span className="ac__detail-product">{r.product}</span>
              <span className="ac__detail-amount">¥{r.amount.toLocaleString()}</span>
              <span className="ac__detail-qty">×{r.qty}</span>
            </div>
          ))}
        </div>
      </CfDrawer>
    </div>
  );
}