yanhui 445347e664 feat: 启用运营分析模块,新增模块化库存监控与 MKH-BOM 资料
本次将此前被列入退役名单的运营分析相关模块恢复为可用,并新增「模块化库存监控」
一级菜单及其两个子页面。

主要变更:
1. 恢复启用:商品监控、激励看板、营销活动、推广模块、商品分析
   - retiredFeatures 移出对应退役名单与清表语句,改为正常保留
   - 补齐后端路由、前端页面与 API 封装、侧边栏配置、桌面客户端能力项
   - 同步更新 retiredFeatures / retiredRoutes / retiredNavigation / desktopSessionService 测试
2. 新增「模块化库存监控」一级菜单
   - 配件库存监控:仅取「简尚-崔桥工厂半成品包装项目仓」SKU 的实时库存、
     当日入库/出库、近 7 日日均发货量、库存可用天数与预警(库存可用天数 ≤ 7 预警)
   - MKH-BOM资料:按商品编码展示配件明细、数量、仓位、库存,支持 A4 纵向打印
3. 新增产品资料系统数据库(PostgreSQL)连接模块:读取系统库 database_configs 中
   name = product_information_database 的配置,凭据只从配置读取,不落代码
4. 新增系统设置页配置项:商品标签配置、部门商品标签配置、部门SKU负责人配置
5. 旧 /api/dashboard 接口迁移至 /api/productAnalysis,仪表盘预览页改用新接口,
   相应移除 dashboard 路由模块及其测试
6. 修复 retiredRoutes 测试在 Windows 下挂起超时的问题:该测试用
   parent.filename.endsWith('/api/planningUpload.js') 判断是否注入 mock,
   而 Windows 的 filename 为反斜杠路径,导致判断恒为 false、mock 全部失效、
   真实模块去连中央系统库而挂起(Linux CI 正常)。改为平台无关的路径匹配。
7. .gitignore 忽略本地验证产物(outputs/、.git-status.txt)

验证:
- 前端:lint / test(16 项)/ build(含 planning entry 校验)全部通过
- 后端:语法检查 149 个文件通过;测试 38/39 通过
- 已知未通过项:resilientSecretStore.test.js 断言密钥备份文件权限为 0o600,
  Windows 下 chmod 无法表达该权限(实际为 0o666),属既有的平台相关失败,
  与本次改动无关,本次未修改该文件
- 说明:本机 frontend/ 下存在未纳入版本库的旧依赖备份目录 node_modules_old_*,
  会污染 ESLint 扫描(十余万条第三方报错),故 lint 步骤以
  --ignore-pattern node_modules_old_*/** 排除后执行,其余步骤与仓库 check 一致

未提交内容:本地验证截图 outputs/ 与 .git-status.txt 已被 .gitignore 排除。
2026-09-15 16:53:12 +08:00

599 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import {
AppstoreOutlined,
BarChartOutlined,
BulbOutlined,
CalendarOutlined,
LineChartOutlined,
RiseOutlined,
FullscreenOutlined,
LeftOutlined,
RightOutlined,
TableOutlined,
} from '@ant-design/icons';
import { Alert, DatePicker, Select, Skeleton } from 'antd';
import dayjs from 'dayjs';
import {
SycmDashboardMetric,
SycmGrowthFactorCard,
SycmInsightNotice,
SycmMetricStrip,
SycmRecommendationCard,
SycmRealtimeTrendChart,
SycmSegmentedSwitch,
SycmSimpleTable,
SycmSparkline,
} from '../../components/Common';
import { getDevelopmentDashboard, getProductAnalysisMeta } from '../../api/productAnalysis';
import './stylePreview.css';
const overviewMetrics = [
{ key: 'payAmount', label: '支付金额', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up', featured: true },
{ key: 'payOrderCount', label: '支付订单数', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'visitorCount', label: '访客数', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'buyerCount', label: '支付买家数', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'conversionRate', label: '支付转化率', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'payGoodsQty', label: '支付件数', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'favoriteCount', label: '收藏人数', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
{ key: 'goodsPv', label: '商品浏览量', value: '暂无数据', change: '无可比', compare: '暂无数据', tone: 'up' },
];
const topOverviewMetrics = overviewMetrics.slice(0, 8);
const growthFactors = [
{ key: 'payAmount', label: '支付金额', trendKey: 'payAmount' },
{ key: 'payOrderCount', label: '支付订单数', trendKey: 'payOrderCount' },
{ key: 'payGoodsQty', label: '支付件数', trendKey: 'payGoodsQty' },
{ key: 'favoriteCount', label: '收藏人数', trendKey: 'favoriteCount' },
];
const adColumns = [
{ key: 'scene', title: '经营指标', primary: true },
{ key: 'spend', title: '当前周期' },
{ key: 'click', title: '上一周期' },
{ key: 'roi', title: '环比变化' },
];
const suggestions = [
{
tag: '经营提示',
title: '关注核心经营指标的周期变化',
desc: '结合支付金额、支付订单数、支付件数与访客变化,判断当前周期的增长质量。',
action: '查看指标',
tone: 'default',
},
];
const guidelineItems = [
{ icon: AppstoreOutlined, label: '白底卡片', value: '1px 边框 + 轻阴影' },
{ icon: BarChartOutlined, label: '12px 密度', value: '表格/辅助信息优先 12px' },
{ icon: LineChartOutlined, label: '业务图表', value: '3px 线宽 + 弱网格' },
{ icon: TableOutlined, label: '模块即规范', value: '新子页面以本页验收' },
];
const formatToneIcon = (tone) => (tone === 'down' ? '↓' : '↑');
const formatValueParts = (value) => {
const match = value.match(/^(.+?\.)(\d+%?)$/);
return match ? { main: match[1], decimal: match[2] } : { main: value, decimal: '' };
};
const renderValueParts = (value) => {
const parts = formatValueParts(value);
return (
<>
<span>{parts.main}</span>
{parts.decimal ? <small>{parts.decimal}</small> : null}
</>
);
};
const PERIOD_MAP = { 日: 'day', 周: 'week', 月: 'month' };
const PERIOD_PICKER_MAP = { 日: 'date', 周: 'week', 月: 'month' };
const { RangePicker } = DatePicker;
const DASHBOARD_REQUEST_TIMEOUT_MS = Math.max(
Number(import.meta.env.VITE_DASHBOARD_REQUEST_TIMEOUT_MS) || Number(import.meta.env.VITE_API_REQUEST_TIMEOUT_MS) || 180000,
15000
);
const numberFormatter = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 });
const moneyFormatter = new Intl.NumberFormat('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const formatDashboardValue = (value, format) => {
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '暂无数据';
if (format === 'money') return moneyFormatter.format(Number(value));
if (format === 'percent') return `${Number(value).toFixed(2)}%`;
return numberFormatter.format(Number(value));
};
const formatChange = (value) => (
value === null || value === undefined || !Number.isFinite(Number(value))
? '无可比'
: `${Math.abs(Number(value)).toFixed(2)}%`
);
const formatShortDate = (value) => (value ? value.slice(5) : '暂无');
const formatCompactMoney = (value) => {
if (!Number.isFinite(Number(value))) return '暂无数据';
const amount = Number(value);
return amount >= 10000 ? `${(amount / 10000).toFixed(2)}万` : moneyFormatter.format(amount);
};
const resolvePeriodDateRange = (value, period) => {
const selected = value.startOf('day');
if (period === '月') {
return [selected.startOf('month'), selected.endOf('month').startOf('day')];
}
if (period === '周') {
const weekday = selected.day();
const monday = selected.add(weekday === 0 ? -6 : 1 - weekday, 'day');
return [monday, monday.add(6, 'day')];
}
return [selected, selected];
};
const buildTrendChart = (rows = []) => {
if (!rows.length) {
return { xLabels: [], yLabels: [], path: '', areaPath: '', points: [] };
}
const displayed = rows.length <= 6
? rows
: Array.from({ length: 6 }, (_, index) => rows[Math.round((rows.length - 1) * index / 5)]);
const maxValue = Math.max(...displayed.map((row) => Number(row.payAmount) || 0), 1);
const points = displayed.map((row, index) => {
const x = displayed.length === 1 ? 356 : 76 + (560 * index) / (displayed.length - 1);
const y = 392 - ((Number(row.payAmount) || 0) / maxValue) * 300;
return { ...row, x, primaryY: y, compareY: null, primaryValue: formatCompactMoney(row.payAmount), compareValue: '无对比', label: row.date };
});
const path = points.map((point, index) => `${index ? 'L' : 'M'}${point.x.toFixed(1)} ${point.primaryY.toFixed(1)}`).join(' ');
return {
xLabels: displayed.map((row) => formatShortDate(row.date)),
yLabels: [1, 0.75, 0.5, 0.25].map((ratio, index) => ({ label: formatCompactMoney(maxValue * ratio), y: 92 + index * 78 })),
path,
areaPath: path ? `${path} L${points.at(-1).x.toFixed(1)} 392 L${points[0].x.toFixed(1)} 392 Z` : '',
points,
};
};
const activateWithKeyboard = (callback) => (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
callback();
}
};
const DevelopmentStylePreview = () => {
const [suggestionIndex, setSuggestionIndex] = useState(0);
const [selectedMetricIndex, setSelectedMetricIndex] = useState(0);
const [selectedGrowthIndex, setSelectedGrowthIndex] = useState(0);
const [activePeriod, setActivePeriod] = useState('周');
const [periodPickerType, setPeriodPickerType] = useState('周');
const [periodPickerOpen, setPeriodPickerOpen] = useState(false);
const [customStartDate, setCustomStartDate] = useState('');
const [customEndDate, setCustomEndDate] = useState('');
const [selectedAdScene, setSelectedAdScene] = useState('支付金额');
const [recommendationFeedback, setRecommendationFeedback] = useState({});
const [selectedJourney, setSelectedJourney] = useState('曝光人数');
const [selectedGuideline, setSelectedGuideline] = useState(guidelineItems[0].label);
const [dashboard, setDashboard] = useState(null);
const [dashboardLoading, setDashboardLoading] = useState(true);
const [dashboardError, setDashboardError] = useState('');
const [shopName, setShopName] = useState('');
const [shopOptions, setShopOptions] = useState([{ value: '', label: '全部店铺' }]);
const [shopOptionsLoading, setShopOptionsLoading] = useState(true);
useEffect(() => {
let active = true;
getProductAnalysisMeta().then((response) => {
if (!active) return;
const shops = Array.isArray(response?.data?.shops) ? response.data.shops : [];
setShopOptions([
{ value: '', label: '全部店铺' },
...shops.map((name) => ({ value: name, label: name })),
]);
}).catch(() => {
if (active) setShopOptions([{ value: '', label: '全部店铺' }]);
}).finally(() => {
if (active) setShopOptionsLoading(false);
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const controller = new AbortController();
const params = customStartDate && customEndDate
? { startDate: customStartDate, endDate: customEndDate, shopName: shopName || undefined }
: { period: PERIOD_MAP[activePeriod] || 'week', shopName: shopName || undefined };
getDevelopmentDashboard(params, { signal: controller.signal, timeout: DASHBOARD_REQUEST_TIMEOUT_MS }).then((response) => {
const payload = response?.data || {};
setDashboard(payload);
}).catch((error) => {
if (error?.code !== 'ERR_CANCELED') {
setDashboardError(error?.response?.data?.error || error.message || '首页数据加载失败');
}
}).finally(() => {
if (!controller.signal.aborted) setDashboardLoading(false);
});
return () => controller.abort();
}, [activePeriod, customStartDate, customEndDate, shopName]);
const applyDateRange = (startDate, endDate, period) => {
setDashboardLoading(true);
setDashboardError('');
setActivePeriod(period);
setCustomStartDate(startDate.format('YYYY-MM-DD'));
setCustomEndDate(endDate.format('YYYY-MM-DD'));
setPeriodPickerOpen(false);
};
const handlePeriodChange = (period) => {
setPeriodPickerType(period);
setPeriodPickerOpen(true);
};
const handlePeriodDateChange = (value) => {
if (!value) {
setPeriodPickerOpen(false);
return;
}
const [startDate, endDate] = resolvePeriodDateRange(value, periodPickerType);
applyDateRange(startDate, endDate, periodPickerType);
};
const handleCustomDateChange = (value) => {
if (!Array.isArray(value) || value.length !== 2 || !value[0] || !value[1]) {
setDashboardLoading(true);
setDashboardError('');
setActivePeriod('周');
setPeriodPickerType('周');
setCustomStartDate('');
setCustomEndDate('');
return;
}
applyDateRange(value[0], value[1], '自定义');
};
const handleShopChange = (value) => {
setShopName(value || '');
setDashboardLoading(true);
setDashboardError('');
};
const displayedDateRange = customStartDate && customEndDate
? [dayjs(customStartDate), dayjs(customEndDate)]
: dashboard?.startDate && dashboard?.latestDate
? [dayjs(dashboard.startDate), dayjs(dashboard.latestDate)]
: null;
const periodPickerValue = customStartDate || dashboard?.latestDate
? dayjs(customStartDate || dashboard.latestDate)
: null;
const displayedOverviewMetrics = topOverviewMetrics.map((fallback) => {
const metric = dashboard?.metrics?.find((item) => item.key === fallback.key);
if (!metric) return fallback;
const tone = Number(metric.change) < 0 ? 'down' : 'up';
return {
...fallback,
label: metric.label || fallback.label,
value: formatDashboardValue(metric.value, metric.format),
compare: formatDashboardValue(metric.previous, metric.format),
change: formatChange(metric.change),
tone,
};
});
const trendChart = buildTrendChart(dashboard?.trend || []);
const dashboardMetricMap = new Map((dashboard?.metrics || []).map((item) => [item.key, item]));
const displayedGrowthFactors = growthFactors.map((item) => {
const metric = dashboardMetricMap.get(item.key) || {};
return {
...item,
value: formatDashboardValue(metric.value, metric.format),
change: formatChange(metric.change),
benchmark: formatDashboardValue(metric.previous, metric.format),
series: (dashboard?.trend || []).map((row) => ({
label: row.date,
value: Number(row[item.trendKey]) || 0,
display: formatDashboardValue(row[item.trendKey], metric.format),
})),
};
});
const efficiencyMetricKeys = ['payAmount', 'payOrderCount', 'payGoodsQty'];
const displayedAdStats = efficiencyMetricKeys.map((key) => {
const metric = dashboardMetricMap.get(key) || {};
const tone = Number(metric.change) < 0 ? 'down' : 'up';
const change = formatChange(metric.change);
return {
label: metric.label || key,
value: formatDashboardValue(metric.value, metric.format),
trend: change === '无可比' ? change : `${formatToneIcon(tone)} ${change}`,
tone,
};
});
const displayedAdRows = efficiencyMetricKeys.map((key) => {
const metric = dashboardMetricMap.get(key) || {};
const tone = Number(metric.change) < 0 ? 'down' : 'up';
const change = formatChange(metric.change);
return {
scene: metric.label || key,
spend: formatDashboardValue(metric.value, metric.format),
click: formatDashboardValue(metric.previous, metric.format),
roi: change === '无可比' ? change : `${formatToneIcon(tone)} ${change}`,
};
});
const currentTotals = (dashboard?.trend || []).reduce((result, row) => ({
impressionCount: result.impressionCount + (Number(row.impressionCount) || 0),
}), { impressionCount: 0 });
const journeySteps = [
{ label: '曝光人数', value: formatDashboardValue(currentTotals.impressionCount, 'integer'), sub: '商品曝光人数' },
{ label: '访客人数', value: formatDashboardValue(dashboardMetricMap.get('visitorCount')?.value, 'integer'), sub: '进入商品详情' },
{ label: '商品浏览', value: formatDashboardValue(dashboardMetricMap.get('goodsPv')?.value, 'integer'), sub: '商品浏览次数' },
{ label: '收藏人数', value: formatDashboardValue(dashboardMetricMap.get('favoriteCount')?.value, 'integer'), sub: '产生收藏行为' },
{ label: '支付买家', value: formatDashboardValue(dashboardMetricMap.get('buyerCount')?.value, 'integer'), sub: '完成支付买家' },
];
const suggestion = suggestions[suggestionIndex];
const moveSuggestion = (offset) => {
setSuggestionIndex((current) => (current + offset + suggestions.length) % suggestions.length);
};
return (
<div className="development-style-preview">
<main className="preview-workbench">
<section className="preview-hero-grid">
<section className="preview-overview-panel">
<header className="preview-overview-header">
<div className="preview-overview-title">
<span className="preview-overview-title__mark" />
<h1>数据概览</h1>
</div>
<span className="preview-overview-time">
{dashboard?.latestDate ? `统计日期 ${dashboard.startDate} 至 ${dashboard.latestDate}` : '统计日期加载中'}
{dashboardLoading && dashboard ? '(更新中)' : ''}
</span>
<div className="preview-overview-actions">
<Select
className="preview-shop-select"
value={shopName}
options={shopOptions}
loading={shopOptionsLoading}
onChange={handleShopChange}
showSearch
optionFilterProp="label"
placeholder="选择店铺"
aria-label="筛选店铺"
popupMatchSelectWidth={260}
/>
<div className="preview-period-selector">
<SycmSegmentedSwitch
items={['日', '周', '月']}
activeKey={periodPickerOpen ? periodPickerType : activePeriod}
onChange={handlePeriodChange}
className="preview-period-switch"
/>
<DatePicker
className="preview-period-picker-anchor"
popupClassName="preview-period-picker-popup"
picker={PERIOD_PICKER_MAP[periodPickerType]}
value={periodPickerValue}
open={periodPickerOpen}
onChange={handlePeriodDateChange}
onOpenChange={(open) => {
if (!open) setPeriodPickerOpen(false);
}}
allowClear={false}
inputReadOnly
disabledDate={(current) => current?.isAfter(dayjs(), 'day')}
/>
</div>
<RangePicker
className={`preview-custom-date${activePeriod === '自定义' ? ' is-active' : ''}`}
value={displayedDateRange}
onChange={handleCustomDateChange}
allowClear
format="YYYY-MM-DD"
placeholder={['开始日期', '结束日期']}
suffixIcon={<CalendarOutlined />}
disabledDate={(current, info) => {
if (current?.isAfter(dayjs(), 'day')) return true;
return info?.from
? Math.abs(current.startOf('day').diff(info.from.startOf('day'), 'day')) > 89
: false;
}}
/>
<button type="button" className="preview-primary-action"><FullscreenOutlined />全部指标</button>
</div>
</header>
{dashboardError ? <Alert type="error" showIcon message={dashboardError} className="preview-dashboard-alert" /> : null}
<div className="preview-overview-body">
<section className="preview-metrics" aria-label="核心经营指标">
{dashboardLoading && !dashboard ? <Skeleton active paragraph={{ rows: 8 }} /> : displayedOverviewMetrics.map((item, index) => (
<SycmDashboardMetric
key={item.label}
label={item.label}
value={renderValueParts(item.value)}
trend={item.change === '无可比' ? item.change : `${formatToneIcon(item.tone)} ${item.change}`}
trendTone={item.tone}
footerLabel="上一周期"
footerValue={item.compare}
selected={selectedMetricIndex === index}
className={`preview-metric-card${index === 0 ? ' is-featured' : ''}`}
slotPrefix="preview-metric-card"
role="button"
tabIndex={0}
aria-pressed={selectedMetricIndex === index}
onClick={() => setSelectedMetricIndex(index)}
onKeyDown={activateWithKeyboard(() => setSelectedMetricIndex(index))}
/>
))}
</section>
<SycmRealtimeTrendChart
aria-label="选定周期成交金额趋势"
className="preview-realtime-chart-card"
legendClassName="preview-realtime-legend"
plotClassName="preview-realtime-chart"
gridClassName="preview-realtime-chart__grid"
yAxisClassName="preview-realtime-chart__y-axis"
areaClassName="preview-realtime-chart__area"
lineClassName="preview-realtime-chart__line"
axisClassName="preview-realtime-chart__axis"
gradientId="previewRealtimeArea"
legendItems={[{ label: '成交金额', tone: 'today' }]}
yAxisLabels={trendChart.yLabels}
xAxisLabels={trendChart.xLabels}
primaryAreaPath={trendChart.areaPath}
primaryPath={trendChart.path}
tooltipItems={trendChart.points}
/>
</div>
</section>
</section>
<section className="preview-growth-section">
<header className="preview-growth-section__header">
<h2>增长因子</h2>
</header>
<div className="preview-growth-grid">
{displayedGrowthFactors.map((item, index) => (
<SycmGrowthFactorCard
key={item.label}
title={item.label}
value={renderValueParts(item.value)}
selected={selectedGrowthIndex === index}
className="preview-growth-card"
slotPrefix="preview-growth-card"
role="button"
tabIndex={0}
aria-pressed={selectedGrowthIndex === index}
onClick={() => setSelectedGrowthIndex(index)}
onKeyDown={activateWithKeyboard(() => setSelectedGrowthIndex(index))}
sparkline={(
<SycmSparkline
data={item.series}
seriesName={item.label}
aria-label={`${item.label}近7日趋势`}
/>
)}
rows={[
<>较上一周期 <b>{item.change}</b></>,
<>上一周期 <em>{item.benchmark}</em></>,
]}
/>
))}
</div>
<div className="preview-ad-grid">
<SycmInsightNotice
className="preview-insight-card"
slotPrefix="preview-insight-card"
icon={<BulbOutlined />}
title="拼多多核心经营数据"
description="支付金额、支付订单数、支付件数、访客和收藏数据均来自拼多多商品日维度业务表。"
/>
</div>
<div className="preview-business-grid">
<SycmMetricStrip className="preview-ad-stats" slotPrefix="preview-ad-stats" items={displayedAdStats} />
<SycmSimpleTable
className="preview-table-card"
slotPrefix="preview-table-card"
title="经营效率详情"
extra={dashboard?.latestDate || '加载中'}
columns={adColumns}
rows={displayedAdRows}
rowKey="scene"
selectedRowKey={selectedAdScene}
onRowClick={(_, key) => setSelectedAdScene(key)}
/>
<section className="preview-suggestion-module" aria-label="投放建议轮播">
<header>
<h2>投放建议</h2>
<div className="preview-suggestion-pager">
<span>{suggestionIndex + 1}/{suggestions.length}</span>
<button type="button" aria-label="上一条投放建议" onClick={() => moveSuggestion(-1)}><LeftOutlined /></button>
<button type="button" aria-label="下一条投放建议" onClick={() => moveSuggestion(1)}><RightOutlined /></button>
</div>
</header>
<div className="preview-suggestion-carousel">
<SycmRecommendationCard
key={suggestion.title}
className="preview-suggestion-card"
slotPrefix="preview-suggestion-card"
tone={suggestion.tone}
tag={suggestion.tag}
title={suggestion.title}
description={suggestion.desc}
actions={[
{
label: '有用',
selected: recommendationFeedback[suggestion.title] === '有用',
onClick: () => setRecommendationFeedback((current) => ({ ...current, [suggestion.title]: '有用' })),
},
{
label: '无用',
selected: recommendationFeedback[suggestion.title] === '无用',
onClick: () => setRecommendationFeedback((current) => ({ ...current, [suggestion.title]: '无用' })),
},
{
label: suggestion.action,
primary: true,
selected: recommendationFeedback[suggestion.title] === suggestion.action,
onClick: () => setRecommendationFeedback((current) => ({ ...current, [suggestion.title]: suggestion.action })),
},
]}
/>
</div>
</section>
</div>
</section>
<section className="preview-card preview-journey-card">
<div className="preview-card__header">
<div>
<h2>客户旅程</h2>
<p>从潜客到支付的漏斗式业务模块,保持横向扫描效率</p>
</div>
<RiseOutlined />
</div>
<div className="preview-journey">
{journeySteps.map((step, index) => (
<article
key={step.label}
className={selectedJourney === step.label ? 'is-selected' : undefined}
role="button"
tabIndex={0}
aria-pressed={selectedJourney === step.label}
onClick={() => setSelectedJourney(step.label)}
onKeyDown={activateWithKeyboard(() => setSelectedJourney(step.label))}
>
<span>{step.label}</span>
<strong>{step.value}</strong>
<em>{step.sub}</em>
{index < journeySteps.length - 1 ? <b aria-hidden="true" /> : null}
</article>
))}
</div>
</section>
<section className="preview-guidelines" aria-label="UI 规范基准">
{guidelineItems.map((item) => {
const Icon = item.icon;
return (
<div
key={item.label}
className={selectedGuideline === item.label ? 'is-selected' : undefined}
role="button"
tabIndex={0}
aria-pressed={selectedGuideline === item.label}
onClick={() => setSelectedGuideline(item.label)}
onKeyDown={activateWithKeyboard(() => setSelectedGuideline(item.label))}
>
<Icon />
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
);
})}
</section>
</main>
</div>
);
};
export default DevelopmentStylePreview;