Commit c34e54a1 authored by Andy-bubu's avatar Andy-bubu

feat: sync dedicated pie chart palette

parent 4a3e0202
......@@ -50,6 +50,7 @@ Native HeroUI 静态架构门禁已通过:`src/xinyuan-portable.css` 已移除
# 共享 UI 架构补充
`XinyuanEChart` 是图表唯一渲染适配器,页面仅提供业务数据和 ECharts option;适配器统一主题、Tooltip、坐标轴和 Pie 扇区样式。相同分类维度的多个数据项使用 `createResolvedXinyuanChartColorMap` 和 `XINYUAN_CHART_CATEGORY_PALETTE` 按稳定顺序分配颜色,顺序为品牌红深浅 → 暖土色 → 中性灰紫 → 灰蓝,禁止绿色、荧光色和彩虹式跳色;单一指标或多指标系列使用 `XINYUAN_CHART_SEMANTIC` 语义色,单系列固定使用 `#DA241A`,页面不得写入硬编码颜色。业务源码禁止直接导入 Recharts 或 HeroUI Pro 图表组件;`recharts` 依赖仅因当前 HeroUI Pro `KPI` 包的静态运行时解析而保留,不作为应用图表渲染器。`XinyuanModal` 统一使用 `bg-background` Dialog Surface。党风廉政台账筛选由 HeroUI `form` 手动提交,字段值先保留在页面草稿,右侧操作固定为重置图标、`查询` 主按钮和 `展开/收起` Disclosure;查询时复用原有 `reload` 请求参数,不改变接口协议。列表分页由 `DataTablePagination` 统一承载容量选择器与页码状态:容量使用 HeroUI Autocomplete 受控单选,标准选项为 `20 / 50 / 100 条/页`、默认值必须属于选项集合,固定少量选项时关闭 `showSearch` 与 `isClearable`,切换容量回到第 1 页。
- Pie/Donut 在共享适配器中独立使用 `--rc-pie-chart-*`;适配器按完整稳定分类 key 生成确定性颜色映射并覆盖旧页面 `itemStyle.color`,只改变扇区颜色,不改变半径、间隙、圆角、标签、图例、Tooltip、尺寸或回调。普通 Bar/Line/Ranking 继续使用 `--rc-chart-*`,不得复用 Pie 专用色板。
长列表 DataGrid 的 `.data-table-scroll` 只隔离横向滚动;纵向在首行/末行继续滚动时必须允许滚动链传递到 `.app-shell-content-scroll`,保证筛选、列表和 Pagination 可以连续浏览,不增加页面级滚轮监听或改变数据请求时机。
驾驶舱模块标题沿用“审计项目管理”基准的 HeroUI `Card.Header` 左对齐横向结构;`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色)与 `min-w-0 flex-1 text-left`,操作或描述信息放在标题右侧,不通过页面私有 CSS 改写字号、颜色或字重。
......
......@@ -87,5 +87,6 @@
- [x] Pagination 页容量 20/50/100;默认值属于选项集合,选择器关闭清除图标和关键词搜索
- [x] Modal 背景与页面 `bg-background` 一致
- [x] 资源统计统一 ECharts,饼图扇区 gap/radius
- [x] 饼图/环形图独立使用 `--xinyuan-pie-chart-*` / `--rc-pie-chart-*`:前六色固定、稳定分类 key 映射、冲突消解,无绿色/青绿色/荧光/彩虹色;普通 Bar/Line/Ranking 色板保持不变
- [x] 驾驶舱模块标题统一为 HeroUI `Card.Title` + `.rc-module-title`:16px/600、24px 行高、主题前景色,标题左对齐,右侧描述与操作保持同一行
- [x] 业务 API、权限、保存回调未改动
......@@ -68,7 +68,8 @@
- Shell 采用固定深色 Sidebar + 浅色内容区,移除主题切换入口。
- 分页支持 20/50/100 条/页选择,切换容量回到第一页。
- 图表统一通过 `XinyuanEChart`(ECharts)渲染,饼图使用扇区间隙与圆角;同一分类维度的多个数据项(例如饼图扇区、模块柱图)必须通过 `createResolvedXinyuanChartColorMap` 按稳定顺序使用 `XINYUAN_CHART_CATEGORY_PALETTE` 映射,顺序为品牌红深浅 → 暖土色 → 中性灰紫 → 灰蓝,禁止绿色、荧光色和彩虹式跳色。单一指标或不同指标系列使用与基准一致的 `XINYUAN_CHART_SEMANTIC` 语义色,不得把单一指标的每根柱子人为染成不同颜色;单系列统一使用品牌红 `#DA241A`。年度分布柱图保留单一主色,并显示顶部真实数值、紧凑柱宽和规范圆角,单年份数据也要保持视觉平衡。
- 图表统一通过 `XinyuanEChart`(ECharts)渲染,饼图使用扇区间隙与圆角;普通分类图表(例如模块柱图、排名图)必须通过 `createResolvedXinyuanChartColorMap` 按稳定顺序使用 `XINYUAN_CHART_CATEGORY_PALETTE` 映射,顺序为品牌红深浅 → 暖土色 → 中性灰紫 → 灰蓝,禁止绿色、荧光色和彩虹式跳色。单一指标或不同指标系列使用与基准一致的 `XINYUAN_CHART_SEMANTIC` 语义色,不得把单一指标的每根柱子人为染成不同颜色;单系列统一使用品牌红 `#DA241A`。年度分布柱图保留单一主色,并显示顶部真实数值、紧凑柱宽和规范圆角,单年份数据也要保持视觉平衡。
- 饼图 / 环形图是普通图表色板之外的独立例外,仅由共享 `XinyuanEChart` 读取 `--xinyuan-pie-chart-1` 至 `--xinyuan-pie-chart-16`(别名 `--rc-pie-chart-*`)分配扇区颜色。前六个颜色固定为 `#DA241A`、`#3F5F86`、`#8A668F`、`#C28A3D`、`#8A6253`、`#667080`;其余为低饱和灰蓝、灰紫、暖棕和暖灰扩展色。分类 key 先稳定排序并做冲突消解,同一分类在筛选、排序、Tab 切换和浅深主题下保持同色;禁止绿色、青绿色、荧光色、彩虹色和页面级 `itemStyle.color` 覆盖。该例外不改变普通 Bar/Line/Ranking、KPI、品牌主色、布局或业务逻辑。
- 品牌主色 Token 固定为 `#DA241A`:HeroUI `accent`、`focus`、`primary` 及主按钮默认态均使用该值,Hover 使用 `#C91F17`,Pressed 使用 `#B51B14`。`primary-50` 至 `primary-950` 只能由该品牌红与黑/白按比例派生,浅色和深色主题不得切换到蓝、紫或其他色相。
- Modal/Dialog 使用 `bg-background`,详情查看态不再包裹大面积 secondary Surface。
......
......@@ -91,5 +91,6 @@
- 共享壳层、Sidebar 选中态、Navbar 标题层级已对齐基准。
- 台账 Pagination 已接入 20/50/100 页容量选择器,默认 20;选择器关闭清除图标和关键词搜索,并在切换后回到第 1 页。
- 资源统计图表已迁移到共享 ECharts 适配器;Pie gap/radius 与 Tooltip 由适配器统一管理。
- 2026-09-08 饼图专项审计:`/resource/stat` 的“台账分类分布图”为唯一 Pie 调用点,已从普通 `--rc-chart-*` 分类色板切换至共享 `--rc-pie-chart-*` 专用色板;页面不再提供 `color`/`itemStyle.color` 覆盖。年度分布与年度开展次数为 Bar 图,继续使用普通图表 Token,未受影响。
- 资源统计各模块标题已与“审计项目管理”基准统一:HeroUI `Card.Header` 左对齐横向结构,`Card.Title` 使用共享 `.rc-module-title`(16px/600、24px 行高、主题前景色),右侧描述/操作与标题垂直居中。
- 详情与导入预览 Modal 使用页面背景层级,去除查看态大块灰色 Surface。
import { getXinyuanEChartsTheme } from '@/design-system/chartPalette';
import {
createResolvedXinyuanPieChartColorMap,
getXinyuanEChartsTheme,
} from '@/design-system/chartPalette';
import ReactECharts from 'echarts-for-react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
......@@ -20,8 +23,53 @@ export default function XinyuanEChart({ ariaLabel, className = '', height = 320,
useEffect(() => { window.addEventListener('resize', hideTooltip); return () => window.removeEventListener('resize', hideTooltip); }, [hideTooltip]);
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applySeries = (series) => series?.type === 'pie' ? ({ ...series, itemStyle: { borderColor: theme.sectorGap, borderRadius: theme.sectorRadius, borderWidth: 2, ...series.itemStyle }, padAngle: series.padAngle ?? 1 }) : series;
const series = Array.isArray(option?.series) ? option.series.map(applySeries) : applySeries(option?.series);
const sourceSeries = Array.isArray(option?.series)
? option.series
: [option?.series];
const pieKeys = sourceSeries.flatMap((series) => (
series?.type === 'pie' && Array.isArray(series.data)
? series.data.map((item) => item?.name)
: []
));
const pieColorMap = createResolvedXinyuanPieChartColorMap(pieKeys);
const applySeries = (series) => {
if (series?.type !== 'pie') return series;
const data = Array.isArray(series.data)
? series.data.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return item;
const key = String(item.name ?? '').trim();
return {
...item,
itemStyle: {
...item.itemStyle,
// Pie/donut colors always come from the dedicated palette. This
// deliberately overrides legacy page-level itemStyle colors.
color: pieColorMap.get(key) || theme.piePalette[index % theme.piePalette.length],
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
};
})
: series.data;
return {
...series,
color: theme.piePalette,
itemStyle: {
...series.itemStyle,
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
},
data,
padAngle: series.padAngle ?? 1,
};
};
const series = Array.isArray(option?.series)
? option.series.map(applySeries)
: applySeries(option?.series);
return {
animationDuration: 320,
animationDurationUpdate: 240,
......
......@@ -45,6 +45,34 @@ export const XINYUAN_CHART_CATEGORY_PALETTE = Object.freeze([
'var(--rc-chart-16)',
]);
// Pie and donut slices use a separate palette so adjacent sectors stay
// distinguishable without changing the approved bar/line/ranking colors.
export const XINYUAN_PIE_CHART_PALETTE = Object.freeze([
'var(--rc-pie-chart-1)',
'var(--rc-pie-chart-2)',
'var(--rc-pie-chart-3)',
'var(--rc-pie-chart-4)',
'var(--rc-pie-chart-5)',
'var(--rc-pie-chart-6)',
'var(--rc-pie-chart-7)',
'var(--rc-pie-chart-8)',
'var(--rc-pie-chart-9)',
'var(--rc-pie-chart-10)',
'var(--rc-pie-chart-11)',
'var(--rc-pie-chart-12)',
'var(--rc-pie-chart-13)',
'var(--rc-pie-chart-14)',
'var(--rc-pie-chart-15)',
'var(--rc-pie-chart-16)',
]);
const XINYUAN_PIE_CHART_FALLBACKS = Object.freeze([
'#DA241A', '#3F5F86', '#8A668F', '#C28A3D',
'#8A6253', '#667080', '#74879A', '#9A8174',
'#6F6674', '#B87958', '#61758F', '#807783',
'#B0A4A0', '#AFA5B1', '#897A73', '#556B88',
]);
export const XINYUAN_CHART_SEMANTIC = Object.freeze({
primary: 'var(--rc-chart-primary)',
risk: 'var(--rc-chart-risk)',
......@@ -151,6 +179,12 @@ export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
);
}
export function createResolvedXinyuanPieChartColorMap(keys = []) {
return createResolvedXinyuanChartColorMap(keys, {
palette: XINYUAN_PIE_CHART_PALETTE,
});
}
export function getXinyuanEChartsTheme() {
const documentFont = typeof document === 'undefined'
? 'Inter, sans-serif'
......@@ -165,6 +199,12 @@ export function getXinyuanEChartsTheme() {
palette: Array.from({ length: 16 }, (_, index) => (
readRootToken(`--xinyuan-chart-${index + 1}`, XINYUAN_CHART_FALLBACKS[index])
)),
piePalette: Array.from({ length: 16 }, (_, index) => (
resolveXinyuanChartColor(
XINYUAN_PIE_CHART_PALETTE[index],
XINYUAN_PIE_CHART_FALLBACKS[index],
)
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
sectorGap: readRootToken('--rc-chart-sector-gap', '#ffffff'),
......
......@@ -88,6 +88,23 @@
--xinyuan-chart-14: #61758F;
--xinyuan-chart-15: #74879A;
--xinyuan-chart-16: #B39A93;
/* Pie / donut only: independent muted palette with stable sector contrast. */
--xinyuan-pie-chart-1: #DA241A;
--xinyuan-pie-chart-2: #3F5F86;
--xinyuan-pie-chart-3: #8A668F;
--xinyuan-pie-chart-4: #C28A3D;
--xinyuan-pie-chart-5: #8A6253;
--xinyuan-pie-chart-6: #667080;
--xinyuan-pie-chart-7: #74879A;
--xinyuan-pie-chart-8: #9A8174;
--xinyuan-pie-chart-9: #6F6674;
--xinyuan-pie-chart-10: #B87958;
--xinyuan-pie-chart-11: #61758F;
--xinyuan-pie-chart-12: #807783;
--xinyuan-pie-chart-13: #B0A4A0;
--xinyuan-pie-chart-14: #AFA5B1;
--xinyuan-pie-chart-15: #897A73;
--xinyuan-pie-chart-16: #556B88;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
......
......@@ -182,6 +182,23 @@
--rc-chart-14: var(--xinyuan-chart-14);
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
/* Pie / donut series are intentionally separate from bar/line colors. */
--rc-pie-chart-1: var(--xinyuan-pie-chart-1);
--rc-pie-chart-2: var(--xinyuan-pie-chart-2);
--rc-pie-chart-3: var(--xinyuan-pie-chart-3);
--rc-pie-chart-4: var(--xinyuan-pie-chart-4);
--rc-pie-chart-5: var(--xinyuan-pie-chart-5);
--rc-pie-chart-6: var(--xinyuan-pie-chart-6);
--rc-pie-chart-7: var(--xinyuan-pie-chart-7);
--rc-pie-chart-8: var(--xinyuan-pie-chart-8);
--rc-pie-chart-9: var(--xinyuan-pie-chart-9);
--rc-pie-chart-10: var(--xinyuan-pie-chart-10);
--rc-pie-chart-11: var(--xinyuan-pie-chart-11);
--rc-pie-chart-12: var(--xinyuan-pie-chart-12);
--rc-pie-chart-13: var(--xinyuan-pie-chart-13);
--rc-pie-chart-14: var(--xinyuan-pie-chart-14);
--rc-pie-chart-15: var(--xinyuan-pie-chart-15);
--rc-pie-chart-16: var(--xinyuan-pie-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: var(--rc-chart-2);
--rc-chart-warning: var(--rc-chart-6);
......
......@@ -71,18 +71,13 @@ function LedgerDistributionChart({ groups }) {
name: item.label,
value: Number(item.count || 0),
}));
const colorMap = createResolvedXinyuanChartColorMap(data.map((item) => item.name), {
order: data.map((item) => item.name),
palette: XINYUAN_CHART_CATEGORY_PALETTE,
});
if (data.length === 0) return <EmptyBlock title="暂无台账分类数据" />;
return <XinyuanEChart ariaLabel="台账分类分布图" option={{
color: data.map((item) => colorMap.get(item.name)),
legend: { bottom: 0, data: data.map((item) => item.name) },
tooltip: { trigger: 'item', formatter: ({ name, value, percent }) => `${name}<br/>数量:${value} 条(${percent}%)` },
series: [{ type: 'pie', radius: ['52%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true, data: data.map((item) => ({ name: item.name, value: item.value, itemStyle: { color: colorMap.get(item.name) } })), label: { alignTo: 'labelLine', distanceToLabelLine: 6, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`, lineHeight: 18, rich: { name: { fontSize: 13, fontWeight: 500, lineHeight: 18 }, value: { fontSize: 14, fontWeight: 600, lineHeight: 20 } } }, labelLine: { length: 20, length2: 22, lineStyle: { width: 1.5 } }, labelLayout: (params) => { const points = params?.labelLinePoints; if (!Array.isArray(points) || points.length < 3) return null; const endpoint = points[2]; const isRight = endpoint[0] >= points[0][0]; return { align: isRight ? 'left' : 'right', hideOverlap: true, moveOverlap: 'none', verticalAlign: 'middle', x: endpoint[0] + (isRight ? 8 : -8), y: endpoint[1] }; } }] }} height={320} />;
series: [{ type: 'pie', radius: ['52%', '72%'], center: ['50%', '44%'], avoidLabelOverlap: true, data: data.map((item) => ({ name: item.name, value: item.value })), label: { alignTo: 'labelLine', distanceToLabelLine: 6, formatter: ({ name, value }) => `{name|${name}}\n{value|${value} 条}`, lineHeight: 18, rich: { name: { fontSize: 13, fontWeight: 500, lineHeight: 18 }, value: { fontSize: 14, fontWeight: 600, lineHeight: 20 } } }, labelLine: { length: 20, length2: 22, lineStyle: { width: 1.5 } }, labelLayout: (params) => { const points = params?.labelLinePoints; if (!Array.isArray(points) || points.length < 3) return null; const endpoint = points[2]; const isRight = endpoint[0] >= points[0][0]; return { align: isRight ? 'left' : 'right', hideOverlap: true, moveOverlap: 'none', verticalAlign: 'middle', x: endpoint[0] + (isRight ? 8 : -8), y: endpoint[1] }; } }] }} height={320} />;
}
function YearDistributionChart({ groups }) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment