This commit is contained in:
zwgczx committed 2026-05-06 17:54:10 +08:00
1 parent 4afad5fe10
commit 36fee1f291
65 files changed
+2933 -137

No files matched your search

+332 -69
View File
@@ -1,101 +1,364 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { AgCharts } from 'ag-charts-react';
import type { AgCartesianChartOptions } from 'ag-charts-community';
import { ModuleRegistry } from 'ag-charts-community';
import {
AnnotationsModule,
ContextMenuModule,
CrosshairModule,
LicenseManager,
ZoomModule,
} from 'ag-charts-enterprise';
import { AG_CHARTS_LOCALE_ZH_CN } from 'ag-charts-locale';
const revenueData = [
{ month: '1月', revenue: 146, orders: 92, margin: 36 },
{ month: '2月', revenue: 158, orders: 101, margin: 39 },
{ month: '3月', revenue: 171, orders: 117, margin: 43 },
{ month: '4月', revenue: 185, orders: 126, margin: 45 },
{ month: '5月', revenue: 193, orders: 133, margin: 47 },
{ month: '6月', revenue: 221, orders: 148, margin: 52 },
LicenseManager.setLicenseKey('[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b');
ModuleRegistry.registerModules([AnnotationsModule, ContextMenuModule, ZoomModule, CrosshairModule]);
const yearlyCostData = [
{ year: '2021', cost: 2200000, buildingArea: 18600, builtArea: 17200, usableArea: 14800 },
{ year: '2022', cost: 2500000, buildingArea: 19100, builtArea: 17600, usableArea: 15300 },
{ year: '2023', cost: 3450000, buildingArea: 20600, builtArea: 19000, usableArea: 16400 },
{ year: '2024', cost: 9600000, buildingArea: 24800, builtArea: 23200, usableArea: 20100 },
{ year: '2025', cost: null, buildingArea: null, builtArea: null, usableArea: null },
];
function App() {
const [chartType, setChartType] = useState<'bar' | 'line'>('bar');
const metricOptions = [
{ key: 'cost', label: '造价(元)', shortLabel: '造价' },
{ key: 'buildingArea', label: '建筑面积指标(元/m²)', shortLabel: '建筑' },
{ key: 'builtArea', label: '建造面积指标(元/m²)', shortLabel: '建造' },
{ key: 'usableArea', label: '使用面积指标(元/m²)', shortLabel: '使用' },
] as const;
const chartOptions = useMemo<AgCartesianChartOptions>(
() => ({
title: {
text: '业务趋势',
type MetricKey = (typeof metricOptions)[number]['key'];
function formatWan(value: number) {
return `${Math.round(value / 10000).toLocaleString('zh-CN')}万`;
}
function App() {
const workspaceRef = useRef<HTMLElement>(null);
const chartFrameRef = useRef<HTMLDivElement>(null);
const [metricKey, setMetricKey] = useState<MetricKey>('cost');
const [metricMenuOpen, setMetricMenuOpen] = useState(false);
const selectedMetric = metricOptions.find((option) => option.key === metricKey) ?? metricOptions[0];
useEffect(() => {
const frame = chartFrameRef.current;
const fullscreenTarget = workspaceRef.current;
if (!frame || !fullscreenTarget) return;
const getFullscreenButton = () => frame.querySelector<HTMLButtonElement>('.chart-fullscreen-button');
const syncFullscreenButton = () => {
const button = getFullscreenButton();
if (!button) return;
let icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
if (!icon) {
button.innerHTML = '<i class="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button"></i>';
icon = button.querySelector<HTMLElement>('.ag-charts-myButton-fullScreen');
}
const isFullscreen = document.fullscreenElement === fullscreenTarget;
button.classList.toggle('ag-charts-toolbar__button--active', isFullscreen);
icon?.classList.toggle('anticon-arrow-salt', !isFullscreen);
icon?.classList.toggle('anticon-shrink', isFullscreen);
};
const toggleFullscreen = () => {
if (document.fullscreenElement === fullscreenTarget) {
void document.exitFullscreen();
} else {
void fullscreenTarget.requestFullscreen();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'F11') return;
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
const handleFullscreenChange = () => {
syncFullscreenButton();
};
const handleToolbarClick = (event: MouseEvent) => {
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
const handleToolbarKeyDown = (event: KeyboardEvent) => {
if (event.key !== ' ' && event.key !== 'Enter') return;
const target = event.target as Element | null;
const button = target?.closest<HTMLButtonElement>(
'.chart-fullscreen-button',
);
if (!button || !frame.contains(button)) return;
event.preventDefault();
event.stopPropagation();
toggleFullscreen();
};
const suppressBrowserContextMenu = (event: MouseEvent) => {
event.preventDefault();
};
const observer = new MutationObserver(syncFullscreenButton);
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('contextmenu', suppressBrowserContextMenu);
frame.addEventListener('click', handleToolbarClick, true);
frame.addEventListener('keydown', handleToolbarKeyDown, true);
observer.observe(frame, { childList: true, subtree: true });
syncFullscreenButton();
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('contextmenu', suppressBrowserContextMenu);
frame.removeEventListener('click', handleToolbarClick, true);
frame.removeEventListener('keydown', handleToolbarKeyDown, true);
observer.disconnect();
};
}, []);
const chartOptions = useMemo<AgCartesianChartOptions>(() => {
const isCost = metricKey === 'cost';
const chartData = yearlyCostData.map((datum) => ({
year: datum.year,
amount: datum.cost == null ? null : isCost ? datum.cost : Math.round((datum.cost / Number(datum[metricKey])) * 100) / 100,
}));
return {
theme: {
palette: {
fills: ['#006f9b', '#ff7faa', '#00994d', '#ff8833', '#00a0dd'],
strokes: ['#003f58', '#934962', '#004a25', '#914d1d', '#006288'],
},
params: {
foregroundColor: '#262a33',
backgroundColor: '#fff1e5',
accentColor: '#0d7680',
fontFamily: '"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif',
fontSize: 14,
tooltipBackgroundColor: '#fff7ef',
tooltipTextColor: '#262a33',
},
},
subtitle: {
text: '收入、订单与毛利表现',
locale: {
localeText: AG_CHARTS_LOCALE_ZH_CN,
},
background: {
fill: 'transparent',
},
padding: {
top: 16,
right: 16,
bottom: 18,
left: 24,
},
data: chartData,
zoom: {
enabled: true,
anchorPointX: 'pointer',
anchorPointY: 'pointer',
buttons: {
enabled: true,
visible: 'hover',
buttons: [
{ icon: 'zoom-out', value: 'zoom-out', section: 'zoom', tooltip: '缩小' },
{ icon: 'zoom-in', value: 'zoom-in', section: 'zoom', tooltip: '放大' },
{ icon: 'pan-left', value: 'pan-left', section: 'pan', tooltip: '左移' },
{ icon: 'pan-right', value: 'pan-right', section: 'pan', tooltip: '右移' },
{ icon: 'reset', value: 'reset', section: 'reset', tooltip: '重置' },
],
},
},
contextMenu: {
enabled: true,
items: ['defaults'],
},
annotations: {
enabled: true,
toolbar: {
buttons: [
{
icon: 'trend-line-drawing',
value: 'line-menu',
tooltip: 'Line Tool',
},
{
icon: 'text-annotation',
value: 'text-menu',
tooltip: 'Text Tool',
},
{
icon: 'arrow-drawing',
value: 'shape-menu',
tooltip: 'Shape Tool',
},
{
icon: 'fibonacci-retracement-drawing',
value: 'fibonacci-menu',
tooltip: 'Fibonacci Tool',
},
{
icon: 'delete',
value: 'clear',
tooltip: 'Clear annotations',
},
],
},
},
data: revenueData,
series: [
{
type: chartType,
xKey: 'month',
yKey: 'revenue',
yName: '收入',
fill: '#2563eb',
stroke: '#2563eb',
},
{
type: chartType,
xKey: 'month',
yKey: 'orders',
yName: '订单',
fill: '#059669',
stroke: '#059669',
},
{
type: chartType,
xKey: 'month',
yKey: 'margin',
yName: '毛利',
fill: '#d97706',
stroke: '#d97706',
type: 'line',
xKey: 'year',
yKey: 'amount',
yName: selectedMetric.label,
stroke: '#0078a8',
strokeWidth: 2,
marker: {
enabled: true,
fill: '#0078a8',
stroke: '#0078a8',
size: 5,
},
interpolation: {
type: 'smooth',
},
},
],
axes: {
x: {
type: 'category',
position: 'bottom',
line: {
enabled: true,
stroke: '#c8b9a7',
},
tick: {
enabled: false,
},
label: {
color: '#1f2933',
fontSize: 12,
},
crosshair: {
snap: false,
},
},
y: {
type: 'number',
position: 'left',
title: {
text: '数值',
text: '',
},
label: {
color: '#1f2933',
fontSize: 12,
formatter: ({ value }) =>
isCost
? formatWan(Number(value))
: Number(value).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}),
},
line: {
enabled: false,
},
tick: {
enabled: false,
},
gridLine: {
enabled: true,
style: [
{
stroke: '#e5d9ca',
lineDash: [0],
},
],
},
crosshair: {
snap: false,
},
},
},
legend: {
position: 'bottom',
enabled: false,
},
}),
[chartType],
);
tooltip: {
enabled: true,
},
};
}, [metricKey, selectedMetric.label]);
return (
<main className="app-shell">
<section className="toolbar" aria-label="图表设置">
<div>
<h1>AG Chart Service</h1>
<p>React 单页图表服务,无路由。</p>
</div>
<div className="segmented-control" aria-label="切换图表类型">
<button
className={chartType === 'bar' ? 'active' : ''}
type="button"
onClick={() => setChartType('bar')}
>
柱状
</button>
<button
className={chartType === 'line' ? 'active' : ''}
type="button"
onClick={() => setChartType('line')}
>
折线
</button>
</div>
</section>
<main className="dashboard-shell">
<div className="watermark-layer" aria-hidden="true">
{Array.from({ length: 18 }).map((_, index) => (
<span key={index}>众为数字化管理平台</span>
))}
</div>
<section className="chart-panel" aria-label="业务趋势图表">
<AgCharts options={chartOptions} />
<section className="workspace" aria-label="年度费用模板" ref={workspaceRef}>
<section className="chart-area" aria-label="年度总费用图表">
<div className="chart-frame" ref={chartFrameRef}>
<div className="metric-switcher">
<button
className="metric-switcher-button"
type="button"
title="切换纵坐标指标"
aria-expanded={metricMenuOpen}
aria-haspopup="menu"
onClick={() => setMetricMenuOpen((open) => !open)}
>
{selectedMetric.label}
</button>
{metricMenuOpen ? (
<div className="metric-switcher-menu" role="menu" aria-label="切换纵坐标指标">
{metricOptions.map((option) => (
<button
className="metric-switcher-menu-item"
type="button"
role="menuitem"
key={option.key}
aria-current={option.key === metricKey}
onClick={() => {
setMetricKey(option.key);
setMetricMenuOpen(false);
}}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
<button className="chart-fullscreen-button ag-charts-toolbar__button" type="button" title="全屏(F11)">
<i className="anticon anticon-arrow-salt ag-charts-myButton-fullScreen ag-charts-diy-button" />
</button>
<AgCharts options={chartOptions} />
</div>
</section>
<aside className="right-placeholder" aria-label="右侧预留区域" />
</section>
</main>
);
+406 -68
View File
@@ -1,9 +1,8 @@
:root {
color: #172033;
background: #f4f7fb;
color: #161616;
background: #f5e8d8;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
"Microsoft YaHei", "PingFang SC", "Segoe UI", Arial, sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
@@ -15,102 +14,441 @@
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
margin: 0;
overflow: hidden;
}
button {
font: inherit;
}
.app-shell {
display: grid;
grid-template-rows: auto minmax(360px, 1fr);
gap: 18px;
width: min(1180px, calc(100vw - 32px));
min-height: 100vh;
margin: 0 auto;
padding: 24px 0;
.wea-right-menu-show {
display: none !important;
}
.toolbar {
.wea-right-menu,
.wea-right-menu-show {
display: none !important;
}
.dashboard-shell {
position: relative;
min-height: 100vh;
background: #f5e8d8;
overflow: hidden;
}
.watermark-layer {
position: absolute;
inset: -30px;
z-index: 0;
display: grid;
grid-template-columns: repeat(4, minmax(260px, 1fr));
grid-auto-rows: 128px;
align-items: center;
pointer-events: none;
color: rgba(150, 140, 128, 0.2);
font-size: 16px;
letter-spacing: 0;
transform: rotate(-18deg);
}
.watermark-layer span {
display: block;
white-space: nowrap;
}
.workspace {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(540px, 52vw) 1fr;
gap: 28px;
height: 100vh;
padding: 8px 28px 18px 64px;
}
.chart-area {
display: grid;
grid-template-rows: minmax(0, 1fr);
min-width: 0;
}
.chart-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
justify-content: center;
gap: 6px;
}
.toolbar h1 {
.chart-title h1 {
margin: 0;
font-size: 28px;
line-height: 1.15;
font-size: 16px;
font-weight: 500;
letter-spacing: 0;
line-height: 1;
}
.toolbar p {
margin: 6px 0 0;
color: #5b6578;
font-size: 14px;
}
.segmented-control {
display: inline-grid;
grid-template-columns: repeat(2, minmax(72px, 1fr));
min-width: 160px;
padding: 4px;
border: 1px solid #cfd7e6;
border-radius: 8px;
background: #ffffff;
}
.segmented-control button {
min-height: 34px;
border: 0;
border-radius: 6px;
color: #4a5568;
background: transparent;
.chart-title button {
display: grid;
width: 18px;
height: 18px;
place-items: center;
padding: 0;
border: 1px solid rgba(95, 88, 80, 0.18);
border-radius: 2px;
color: #6d6258;
background: rgba(255, 249, 241, 0.36);
cursor: pointer;
transition:
background-color 120ms ease,
border-color 120ms ease,
color 120ms ease,
transform 120ms ease,
box-shadow 120ms ease;
}
.segmented-control button.active {
color: #ffffff;
background: #1f4ed8;
.chart-title button:hover {
border-color: rgba(0, 120, 168, 0.36);
color: #0078a8;
background: rgba(255, 252, 248, 0.94);
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.chart-panel {
min-height: 520px;
padding: 16px;
border: 1px solid #d9e1ee;
border-radius: 8px;
background: #ffffff;
.chart-title button:active {
transform: translateY(1px);
}
.chart-panel > div {
.chart-title button:focus-visible {
outline: 2px solid rgba(0, 120, 168, 0.34);
outline-offset: 2px;
}
.chart-title button span {
position: relative;
width: 11px;
height: 11px;
}
.chart-title button span::before {
position: absolute;
top: 5px;
left: 1px;
width: 10px;
height: 1px;
background: currentColor;
transform: rotate(-35deg);
content: "";
}
.chart-title button span::after {
position: absolute;
right: 1px;
bottom: 1px;
width: 4px;
height: 1px;
background: currentColor;
content: "";
}
.chart-frame {
position: relative;
min-width: 0;
min-height: 0;
padding: 26px 0 0;
}
.workspace:fullscreen {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: minmax(540px, 52vw) 1fr;
gap: 28px;
width: 100vw;
height: 100vh;
padding: 8px 28px 18px 64px;
background: #f5e8d8;
}
.workspace:fullscreen .chart-area {
height: calc(100vh - 26px);
}
.workspace:fullscreen .right-placeholder {
display: block;
min-width: 0;
height: 100%;
}
@media (max-width: 640px) {
.app-shell {
width: min(100vw - 20px, 1180px);
grid-template-rows: auto minmax(420px, 1fr);
padding: 16px 0;
.chart-frame > div {
height: 100%;
}
.chart-frame .ag-charts-wrapper {
--ag-charts-accent-color: #0078a8;
--ag-charts-button-background-color: rgba(255, 249, 241, 0.72);
--ag-charts-button-border-radius: 3px;
--ag-charts-border-color: rgba(90, 82, 72, 0.22);
--ag-charts-chrome-background-color: rgba(255, 249, 241, 0.84);
}
.chart-frame .ag-charts-toolbar {
gap: 8px;
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.chart-frame .ag-charts-annotations__toolbar {
left: -46px;
top: 24px;
overflow: visible !important;
}
.chart-frame .ag-charts-annotations__toolbar .ag-charts-toolbar {
overflow: visible !important;
}
.metric-switcher {
position: absolute;
left: 74px;
top: 272px;
z-index: 12;
}
.metric-switcher-button {
display: block;
width: 22px;
min-height: 112px;
padding: 8px 2px;
border: 1px solid rgba(90, 82, 72, 0.16);
border-radius: 3px;
color: #111827;
background: rgba(255, 241, 229, 0.68);
font-size: 12px;
letter-spacing: 2px;
line-height: 1.25;
text-align: center;
cursor: pointer;
transition:
background-color 120ms ease,
border-color 120ms ease,
color 120ms ease,
box-shadow 120ms ease;
writing-mode: vertical-rl;
text-orientation: upright;
}
.metric-switcher-button:hover,
.metric-switcher-button[aria-expanded="true"] {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
background: rgba(255, 252, 248, 0.94);
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.metric-switcher-menu {
position: absolute;
left: 30px;
top: 8px;
width: max-content;
min-width: 196px;
padding: 4px 0;
border: 1px solid rgba(90, 82, 72, 0.22);
border-radius: 3px;
background: #fbede1;
box-shadow: 0 4px 14px rgba(69, 54, 36, 0.14);
}
.metric-switcher-menu-item {
display: block;
width: 100%;
min-height: 30px;
padding: 0 12px;
border: 0;
color: #262a33;
background: transparent;
font-size: 13px;
line-height: 30px;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.metric-switcher-menu-item:hover,
.metric-switcher-menu-item[aria-current="true"] {
color: #0078a8;
background: rgba(255, 252, 248, 0.94);
}
.chart-frame .chart-fullscreen-button {
position: absolute;
left: 24px;
top: 210px;
z-index: 9;
display: grid;
width: 34px;
height: 34px;
min-width: 34px;
min-height: 34px;
place-items: center;
padding: 0;
border: 1px solid rgba(90, 82, 72, 0.22);
border-radius: 3px;
color: #46413b;
background: rgba(255, 249, 241, 0.72);
box-shadow: none;
}
.chart-frame .ag-charts-toolbar__button[title="Clear annotations"] {
transform: translateY(42px);
}
.chart-frame .ag-charts-zoom-buttons {
top: auto !important;
right: 24px !important;
bottom: 34px !important;
left: auto !important;
width: auto !important;
height: auto !important;
z-index: 8;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition:
opacity 140ms ease,
transform 140ms ease;
}
.chart-frame:hover .ag-charts-zoom-buttons,
.chart-frame:focus-within .ag-charts-zoom-buttons,
.chart-frame .ag-charts-zoom-buttons:focus-within {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.chart-frame .ag-charts-zoom-buttons .ag-charts-toolbar {
position: relative !important;
z-index: 9;
gap: 0;
padding: 0;
border: 0;
border-radius: 3px;
background: transparent;
box-shadow: none;
}
.chart-frame .ag-charts-toolbar__button {
width: 26px;
height: 26px;
color: #46413b;
border-color: rgba(90, 82, 72, 0.22);
background: rgba(255, 249, 241, 0.72);
cursor: pointer;
}
.chart-frame .ag-charts-zoom-buttons .ag-charts-toolbar__button {
width: 26px;
height: 26px;
min-width: 26px;
min-height: 26px;
padding: 3px;
background: rgba(255, 249, 241, 0.72);
}
.chart-frame .ag-charts-toolbar__button:hover,
.chart-frame .ag-charts-toolbar__button--active {
color: #0078a8;
border-color: rgba(0, 120, 168, 0.36);
background: rgba(255, 252, 248, 0.94);
box-shadow: 0 1px 5px rgba(69, 54, 36, 0.12);
}
.chart-frame .ag-charts-myButton-fullScreen {
position: relative;
display: block;
width: 16px;
height: 16px;
}
.chart-frame .ag-charts-myButton-fullScreen::before,
.chart-frame .ag-charts-myButton-fullScreen::after {
position: absolute;
inset: 0;
display: block;
width: 16px;
height: 16px;
content: "";
}
.chart-frame .anticon-arrow-salt::before {
border-top: 1px solid currentColor;
border-right: 1px solid currentColor;
clip-path: polygon(54% 0, 100% 0, 100% 46%, 86% 46%, 86% 14%, 54% 14%);
}
.chart-frame .anticon-arrow-salt::after {
border-bottom: 1px solid currentColor;
border-left: 1px solid currentColor;
clip-path: polygon(0 54%, 14% 54%, 14% 86%, 46% 86%, 46% 100%, 0 100%);
}
.chart-frame .anticon-shrink::before {
top: 2px;
left: 2px;
width: 6px;
height: 6px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
}
.chart-frame .anticon-shrink::after {
right: 2px;
bottom: 2px;
left: auto;
top: auto;
width: 6px;
height: 6px;
border-top: 1px solid currentColor;
border-left: 1px solid currentColor;
}
.chart-frame .ag-charts-toolbar__button:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.chart-frame .ag-charts-toolbar__button[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.38;
}
.right-placeholder {
min-width: 0;
height: 100%;
}
@media (max-width: 900px) {
body {
overflow: auto;
}
.toolbar {
align-items: stretch;
flex-direction: column;
.workspace {
grid-template-columns: 1fr;
height: auto;
min-height: 100vh;
padding: 8px 18px 18px 56px;
}
.toolbar h1 {
font-size: 24px;
.chart-area {
min-height: 520px;
}
.segmented-control {
width: 100%;
}
.chart-panel {
min-height: 460px;
padding: 10px;
.right-placeholder {
min-height: 280px;
}
}