Commit 190e6b24 authored by Andy-bubu's avatar Andy-bubu

feat: refine dashboard chart palette and headings

parent b9f397bf
This diff is collapsed.
......@@ -166,14 +166,24 @@ Inter 与系统回退字体之间切换。
- 详情和关联记录中的历史数组字符串必须先解析再展示:数组值使用业务标签以“、”连接;流程图/文档结构等结构化对象展示节点、连线、章节和内容块摘要;无法解析出名称的内部 ID 显示“已关联记录/已关联 N 项”,禁止把 JSON、ObjectId 或存储字段原文直接暴露给业务人员。编辑态 JSON 输入仍保持原数据协议。
- 数字指标使用 Pro KPI;所有图表统一通过共享 `XinyuanEChart` 使用 `echarts` 与
`echarts-for-react`。系列色、坐标轴、网格、图例、Tooltip Surface/边框/阴影均读取
`theme.css` 的 `--xinyuan-chart-*` Token。当前主题固定提供 16 个鲜艳且稳定的分类色
(红、粉、橙、黄、绿、青、蓝、靛、紫、品红及中性灰),页面只提供业务数据和图表 option。图标始终使用
`theme.css` 的 `--xinyuan-chart-*` Token。首轮 8 个色位固定按红→粉→橙→黄→蓝→紫→青→中性灰排列,
让最多 8 个分类拥有清晰且高级的色相差异;第 9-16 位提供对应的明暗补充,页面只提供业务数据和
图表 option。具体色值依次为 `#991b1b`、`#db2777`、`#ea580c`、`#eab308`、`#1d4ed8`、
`#7e22ce`、`#0891b2`、`#475569`、`#dc2626`、`#ec4899`、`#f97316`、`#facc15`、`#60a5fa`、
`#a855f7`、`#06b6d4`、`#1e293b`。图标始终使用
`AppIcons` 汇出的 Ant Design Icons,ECharts 不作为图标来源。
- 同一图表的分类色按业务展示顺序连续取用相邻色相;有显式业务顺序时必须传入稳定顺序,
无显式顺序时按稳定 key 确定性分配。禁止在有业务顺序时使用名称哈希打散首轮色板,避免相邻
扇区或相邻图例出现无序跳色色系;筛选、排序和 Tab 切换后已有业务 key 保持原色。
- 环形/饼图需要在图表外展示分类内容时,必须使用 ECharts 原生 `label` 与 `labelLine` 引导线,
使用与标签端点对齐的完整引导线连接对应标签,标签按“分类名称 / 真实数量”分两行显示;分类名称保持
13px/500,数量使用 14px/600;标签位置必须锚定到对应引导线终点,避免自动避让造成错位。
不得把引导线拉到卡片边缘或只保留
底部图例代替内容标注。
- 环形/饼图扇区之间使用统一的紧凑 Surface 色间隙和圆角(`padAngle: 1`、`borderWidth: 2`、
`borderRadius: 10`),形成清晰分隔但不改变数据比例;间隙色必须读取 `--xinyuan-chart-sector-gap`,
圆角值读取 `--xinyuan-chart-sector-radius`,
随主题 Surface 自动适配。图表外层模块继续使用 24px 共享圆角。
- 严谨业务界面不使用粉色、玫红或洋红危险色;HeroUI `danger` / `danger-soft` 必须继承鑫元红系
`--danger` Token。图表中的红色序列同样使用红系,不使用粉色替代危险语义。
......
......@@ -284,10 +284,13 @@ HeroUI Context。`@heroui/styles@3.2.4` 的两个 pnpm peer 虚拟目录仅分
- 驾驶舱状态饼图和阶段柱图已从 HeroUI Pro/Recharts 切换到 ECharts 6.1.0 与
echarts-for-react 3.0.6。共享 `XinyuanEChart` 统一 ARIA、主题系列色、图例、坐标轴、网格和
body HTML Tooltip;柱宽仍限制为 28px,API、统计口径、项目详情入口和数据结构未改。
- 图表分类色已扩展为 `--xinyuan-chart-1` 至 `--xinyuan-chart-16`,使用鲜艳固定色值;
通过稳定业务 key 分配颜色,禁止页面写入随机或局部色值。
- 图表分类色已扩展为 `--xinyuan-chart-1` 至 `--xinyuan-chart-16`,使用鲜艳固定色值,首轮顺序固定为
红→粉→橙→黄→蓝→紫→青→中性灰;
通过稳定业务顺序/业务 key 分配相邻色相,禁止页面写入随机或局部色值;项目状态分布按
计划、执行、归档、整改、待启动、完成的顺序连续取用色板,筛选后 key 颜色保持不变。
- 状态环图已启用 ECharts 外部分类标签和 `labelLine` 引导线,标签显示真实状态与数量,
使用 `labelLayout` 根据 `labelLinePoints` 将标签锚定到各自折线终点,避免文案与折线错位。
- 状态环图扇区统一使用 `padAngle: 1`、2px Surface 色间隙和 `borderRadius: 10`,扇区边界紧凑清晰且不改变真实数量比例;图表模块外层使用 24px 圆角。
- 删除、拒绝和危险操作的 `danger-soft` 背景已从默认粉色修正为鑫元红系,活动页面不再出现
粉色危险操作视觉。
- 列表普通业务单元格已关闭 Hover 文案 Tooltip;操作列仍保留查看、编辑、删除等真实操作按钮
......
......@@ -59,6 +59,23 @@ export default function XinyuanEChart({
const themedOption = useMemo(() => {
const theme = getXinyuanEChartsTheme();
const applyPieTheme = (series) => {
if (series?.type !== 'pie') return series;
return {
...series,
itemStyle: {
borderColor: theme.sectorGap,
borderRadius: theme.sectorRadius,
borderWidth: 2,
...series.itemStyle,
},
padAngle: series.padAngle ?? 1,
};
};
const themedSeries = Array.isArray(option?.series)
? option.series.map(applyPieTheme)
: applyPieTheme(option?.series);
return {
animationDuration: 320,
......@@ -76,6 +93,7 @@ export default function XinyuanEChart({
...option?.textStyle,
},
...option,
series: themedSeries,
legend: option?.legend ? {
icon: 'circle',
itemHeight: 8,
......
......@@ -37,27 +37,59 @@ function hashChartKey(key) {
);
}
export function createXinyuanChartColorMap(keys = []) {
const stableKeys = [...new Set(keys.map(normalizeChartKey).filter(Boolean))]
.sort();
export function createXinyuanChartColorMap(keys = [], options = {}) {
const sourceKeys = Array.isArray(keys) ? keys : [];
const order = Array.isArray(options?.order) ? options.order : [];
const orderedKeys = [...new Set(sourceKeys.map(normalizeChartKey).filter(Boolean))];
const preferredOrder = new Map(
order.map(normalizeChartKey).filter(Boolean).map((key, index) => [key, index]),
);
const stableKeys = orderedKeys.sort((left, right) => {
const leftOrder = preferredOrder.has(left)
? preferredOrder.get(left)
: Number.MAX_SAFE_INTEGER;
const rightOrder = preferredOrder.has(right)
? preferredOrder.get(right)
: Number.MAX_SAFE_INTEGER;
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.localeCompare(right, 'zh-CN');
});
const unknownPositions = new Map(
stableKeys
.filter((key) => !preferredOrder.has(key))
.map((key, index) => [key, index]),
);
const colorMap = new Map();
const usedIndexes = new Set();
stableKeys.forEach((key, keyIndex) => {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
let paletteIndex = preferredIndex;
stableKeys.forEach((key) => {
const hasExplicitOrder = preferredOrder.size > 0;
let paletteIndex = hasExplicitOrder && preferredOrder.has(key)
? preferredOrder.get(key) % XINYUAN_CHART_PALETTE.length
: hasExplicitOrder
? preferredOrder.size + unknownPositions.get(key)
: hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
if (keyIndex < XINYUAN_CHART_PALETTE.length) {
if (
paletteIndex >= XINYUAN_CHART_PALETTE.length
|| usedIndexes.has(paletteIndex)
) {
const preferredIndex = hashChartKey(key) % XINYUAN_CHART_PALETTE.length;
paletteIndex = preferredIndex;
for (let offset = 0; offset < XINYUAN_CHART_PALETTE.length; offset += 1) {
const candidateIndex = (preferredIndex + offset) % XINYUAN_CHART_PALETTE.length;
if (!usedIndexes.has(candidateIndex)) {
paletteIndex = candidateIndex;
usedIndexes.add(candidateIndex);
break;
}
}
}
usedIndexes.add(paletteIndex);
colorMap.set(key, XINYUAN_CHART_PALETTE[paletteIndex]);
});
......@@ -79,9 +111,9 @@ export function resolveXinyuanChartColor(color, fallback = '') {
return match ? readRootToken(match[1], fallback) : String(color || fallback);
}
export function createResolvedXinyuanChartColorMap(keys = []) {
export function createResolvedXinyuanChartColorMap(keys = [], options = {}) {
return new Map(
Array.from(createXinyuanChartColorMap(keys)).map(([key, color]) => [
Array.from(createXinyuanChartColorMap(keys, options)).map(([key, color]) => [
key,
resolveXinyuanChartColor(color),
]),
......@@ -104,6 +136,10 @@ export function getXinyuanEChartsTheme() {
)),
tooltipBackground: readRootToken('--xinyuan-chart-tooltip-background', '#ffffff'),
tooltipBorder: readRootToken('--xinyuan-chart-tooltip-border', '#dedede'),
sectorGap: readRootToken('--xinyuan-chart-sector-gap', '#ffffff'),
sectorRadius: Number.parseFloat(
readRootToken('--xinyuan-chart-sector-radius', '10'),
) || 10,
tooltipShadow: readRootToken(
'--xinyuan-chart-tooltip-shadow',
'0 16px 40px rgb(15 23 42 / 14%)',
......
......@@ -435,6 +435,23 @@
gap: var(--rc-page-module-gap);
}
.rc-section-title {
margin: 0;
color: var(--rc-text-primary);
font-size: 1.25rem;
font-weight: var(--rc-font-semibold);
line-height: 1.75rem;
letter-spacing: 0;
}
.rc-module-title {
color: var(--rc-text-primary);
font-size: 1.125rem;
font-weight: var(--rc-font-semibold);
line-height: 1.5rem;
letter-spacing: 0;
}
.rc-page-grid {
min-width: 0;
gap: var(--rc-page-module-gap);
......
......@@ -70,22 +70,26 @@
* Ant Design remains the icon source. Resolve these CSS variables to
* concrete colors before assigning them to an ECharts option object.
*/
--xinyuan-chart-1: #d92d20;
--xinyuan-chart-2: #ef4444;
--xinyuan-chart-3: #f97316;
--xinyuan-chart-4: #f59e0b;
--xinyuan-chart-5: #22c55e;
--xinyuan-chart-6: #14b87a;
--xinyuan-chart-7: #06b6d4;
--xinyuan-chart-8: #0ea5e9;
--xinyuan-chart-9: #3b82f6;
--xinyuan-chart-10: #6366f1;
--xinyuan-chart-11: #8b5cf6;
--xinyuan-chart-12: #7c3aed;
--xinyuan-chart-13: #b42318;
--xinyuan-chart-14: #84cc16;
--xinyuan-chart-15: #a855f7;
--xinyuan-chart-16: #64748b;
/* First pass: red -> pink -> orange -> yellow -> blue -> purple -> cyan -> neutral. */
--xinyuan-chart-1: #991b1b;
--xinyuan-chart-2: #db2777;
--xinyuan-chart-3: #ea580c;
--xinyuan-chart-4: #eab308;
--xinyuan-chart-5: #1d4ed8;
--xinyuan-chart-6: #7e22ce;
--xinyuan-chart-7: #0891b2;
--xinyuan-chart-8: #475569;
/* Second pass: stronger/lighter companions for charts with more series. */
--xinyuan-chart-9: #dc2626;
--xinyuan-chart-10: #ec4899;
--xinyuan-chart-11: #f97316;
--xinyuan-chart-12: #facc15;
--xinyuan-chart-13: #60a5fa;
--xinyuan-chart-14: #a855f7;
--xinyuan-chart-15: #06b6d4;
--xinyuan-chart-16: #1e293b;
--xinyuan-chart-sector-gap: var(--surface);
--xinyuan-chart-sector-radius: 10px;
--xinyuan-chart-grid: #e8e8e8;
--xinyuan-chart-axis: #737373;
--xinyuan-chart-label: #737373;
......
......@@ -165,10 +165,10 @@
--rc-chart-15: var(--xinyuan-chart-15);
--rc-chart-16: var(--xinyuan-chart-16);
--rc-chart-primary: var(--rc-chart-1);
--rc-chart-risk: oklch(47% 0.145 27.03);
--rc-chart-warning: var(--rc-chart-6);
--rc-chart-positive: var(--rc-chart-2);
--rc-chart-comparison: var(--rc-chart-4);
--rc-chart-risk: var(--rc-chart-1);
--rc-chart-warning: var(--rc-chart-3);
--rc-chart-positive: var(--rc-chart-7);
--rc-chart-comparison: var(--rc-chart-5);
--rc-chart-neutral: var(--rc-chart-8);
--rc-chart-legend-font-size: var(--xinyuan-chart-legend-font-size);
--rc-chart-legend-line-height: var(--xinyuan-chart-legend-line-height);
......
......@@ -34,6 +34,13 @@ const STATUS_TONES = {
归档: 'secondary',
};
const STATUS_CHART_ORDER = ['计划', '执行', '归档', '整改', '待启动', '完成', '未设置'];
function getStatusChartOrder(status) {
const index = STATUS_CHART_ORDER.indexOf(status);
return index === -1 ? Number.MAX_SAFE_INTEGER : index;
}
function MetricCard({ label, value, icon: Icon, tone, onPress }) {
const numericValue = Number.parseFloat(String(value).replace(/[% ,]/g, ''));
const content = (
......@@ -93,7 +100,7 @@ function ProjectPanel({ title, records, emptyText, onOpen }) {
return (
<Card className="rc-dashboard-card">
<Card.Header className="flex-row items-center gap-3">
<Card.Title className="min-w-0 flex-1">{title}</Card.Title>
<Card.Title className="rc-module-title min-w-0 flex-1">{title}</Card.Title>
</Card.Header>
<Card.Content className="max-h-80 overflow-y-auto">
<ListView
......@@ -123,6 +130,7 @@ function StatusChart({ data }) {
const theme = getXinyuanEChartsTheme();
const colorMap = createResolvedXinyuanChartColorMap(
data.map((item) => item.name),
{ order: STATUS_CHART_ORDER },
);
return {
......@@ -132,7 +140,9 @@ function StatusChart({ data }) {
},
series: [{
avoidLabelOverlap: true,
data: data.map((item) => ({
data: [...data].sort(
(left, right) => getStatusChartOrder(left.name) - getStatusChartOrder(right.name),
).map((item) => ({
...item,
itemStyle: { color: colorMap.get(item.name) },
})),
......@@ -355,21 +365,21 @@ export default function AuditProjectDashboardPage() {
</section>
<section className="rc-page-section">
<h2 className="text-base font-medium text-foreground">项目进度分析</h2>
<h2 className="rc-section-title">项目进度分析</h2>
<div className="rc-page-grid rc-chart-grid">
<Card className="rc-dashboard-card">
<Card.Header><Card.Title>项目状态分布</Card.Title></Card.Header>
<Card.Header><Card.Title className="rc-module-title">项目状态分布</Card.Title></Card.Header>
<Card.Content>{dashboard.statusData.length ? <StatusChart data={dashboard.statusData} /> : null}</Card.Content>
</Card>
<Card className="rc-dashboard-card">
<Card.Header><Card.Title>开展中项目进度</Card.Title></Card.Header>
<Card.Header><Card.Title className="rc-module-title">开展中项目进度</Card.Title></Card.Header>
<Card.Content><StageChart data={dashboard.stageData} /></Card.Content>
</Card>
</div>
</section>
<section className="rc-page-section">
<h2 className="text-base font-medium text-foreground">项目提醒</h2>
<h2 className="rc-section-title">项目提醒</h2>
<div className="grid gap-6 xl:grid-cols-3">
<ProjectPanel title="逾期未完成项目" records={dashboard.overdue} emptyText="暂无逾期未完成项目" onOpen={openRecord} />
<ProjectPanel title="正在开展项目" records={dashboard.active} emptyText="暂无正在开展项目" onOpen={openRecord} />
......
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