feat: 实现供水运营专题大屏BI可视化

- 新增OperationDashboard.vue全屏大屏组件
- 添加6个核心KPI指标卡片: 进水总量/出水总量/产销差率/营收额/平均水质/报警次数
- 实现6个ECharts图表: 供水趋势/水质分布/报警统计/管网空间/设备状态/营收分析
- 创建静态HTML版本operation-dashboard.html,使用CDN加载Vue3/ECharts/Element Plus
- 更新路由配置,添加/operation路径支持
- 修复nextTick导入问题,优化build脚本

🚧 开发者: bot_dev1
📝 任务: #38 [BI] 运营仪表盘 + 供水专题大屏
This commit is contained in:
2026-06-15 09:06:11 +08:00
parent 37bcc78eee
commit 85df71fc28
17426 changed files with 4091508 additions and 47 deletions
+2
View File
@@ -0,0 +1,2 @@
import './liquidFillSeries';
import './liquidFillView';
+78
View File
@@ -0,0 +1,78 @@
import * as echarts from 'echarts/lib/echarts';
echarts.extendSeriesModel({
type: 'series.liquidFill',
optionUpdated: function () {
var option = this.option;
option.gridSize = Math.max(Math.floor(option.gridSize), 4);
},
getInitialData: function (option, ecModel) {
var dimensions = echarts.helper.createDimensions(option.data, {
coordDimensions: ['value']
});
var list = new echarts.List(dimensions, this);
list.initData(option.data);
return list;
},
defaultOption: {
color: ['#294D99', '#156ACF', '#1598ED', '#45BDFF'],
center: ['50%', '50%'],
radius: '50%',
amplitude: '8%',
waveLength: '80%',
phase: 'auto',
period: 'auto',
direction: 'right',
shape: 'circle',
waveAnimation: true,
animationEasing: 'linear',
animationEasingUpdate: 'linear',
animationDuration: 2000,
animationDurationUpdate: 1000,
outline: {
show: true,
borderDistance: 8,
itemStyle: {
color: 'none',
borderColor: '#294D99',
borderWidth: 8,
shadowBlur: 20,
shadowColor: 'rgba(0, 0, 0, 0.25)'
}
},
backgroundStyle: {
color: '#E3F7FF'
},
itemStyle: {
opacity: 0.95,
shadowBlur: 50,
shadowColor: 'rgba(0, 0, 0, 0.4)'
},
label: {
show: true,
color: '#294D99',
insideColor: '#fff',
fontSize: 50,
fontWeight: 'bold',
align: 'center',
baseline: 'middle',
position: 'inside'
},
emphasis: {
itemStyle: {
opacity: 0.8
}
}
}
});
+163
View File
@@ -0,0 +1,163 @@
import * as echarts from 'echarts/lib/echarts';
export default echarts.graphic.extendShape({
type: 'ec-liquid-fill',
shape: {
waveLength: 0,
radius: 0,
radiusY: 0,
cx: 0,
cy: 0,
waterLevel: 0,
amplitude: 0,
phase: 0,
inverse: false
},
buildPath: function (ctx, shape) {
if (shape.radiusY == null) {
shape.radiusY = shape.radius;
}
/**
* We define a sine wave having 4 waves, and make sure at least 8 curves
* is drawn. Otherwise, it may cause blank area for some waves when
* wave length is large enough.
*/
var curves = Math.max(
Math.ceil(2 * shape.radius / shape.waveLength * 4) * 2,
8
);
// map phase to [-Math.PI * 2, 0]
while (shape.phase < -Math.PI * 2) {
shape.phase += Math.PI * 2;
}
while (shape.phase > 0) {
shape.phase -= Math.PI * 2;
}
var phase = shape.phase / Math.PI / 2 * shape.waveLength;
var left = shape.cx - shape.radius + phase - shape.radius * 2;
/**
* top-left corner as start point
*
* draws this point
* |
* \|/
* ~~~~~~~~
* | |
* +------+
*/
ctx.moveTo(left, shape.waterLevel);
/**
* top wave
*
* ~~~~~~~~ <- draws this sine wave
* | |
* +------+
*/
var waveRight = 0;
for (var c = 0; c < curves; ++c) {
var stage = c % 4;
var pos = getWaterPositions(c * shape.waveLength / 4, stage,
shape.waveLength, shape.amplitude);
ctx.bezierCurveTo(pos[0][0] + left, -pos[0][1] + shape.waterLevel,
pos[1][0] + left, -pos[1][1] + shape.waterLevel,
pos[2][0] + left, -pos[2][1] + shape.waterLevel);
if (c === curves - 1) {
waveRight = pos[2][0];
}
}
if (shape.inverse) {
/**
* top-right corner
* 2. draws this line
* |
* +------+
* 3. draws this line -> | | <- 1. draws this line
* ~~~~~~~~
*/
ctx.lineTo(waveRight + left, shape.cy - shape.radiusY);
ctx.lineTo(left, shape.cy - shape.radiusY);
ctx.lineTo(left, shape.waterLevel);
}
else {
/**
* top-right corner
*
* ~~~~~~~~
* 3. draws this line -> | | <- 1. draws this line
* +------+
* ^
* |
* 2. draws this line
*/
ctx.lineTo(waveRight + left, shape.cy + shape.radiusY);
ctx.lineTo(left, shape.cy + shape.radiusY);
ctx.lineTo(left, shape.waterLevel);
}
ctx.closePath();
}
});
/**
* Using Bezier curves to fit sine wave.
* There is 4 control points for each curve of wave,
* which is at 1/4 wave length of the sine wave.
*
* The control points for a wave from (a) to (d) are a-b-c-d:
* c *----* d
* b *
* |
* ... a * ..................
*
* whose positions are a: (0, 0), b: (0.5, 0.5), c: (1, 1), d: (PI / 2, 1)
*
* @param {number} x x position of the left-most point (a)
* @param {number} stage 0-3, stating which part of the wave it is
* @param {number} waveLength wave length of the sine wave
* @param {number} amplitude wave amplitude
*/
function getWaterPositions(x, stage, waveLength, amplitude) {
if (stage === 0) {
return [
[x + 1 / 2 * waveLength / Math.PI / 2, amplitude / 2],
[x + 1 / 2 * waveLength / Math.PI, amplitude],
[x + waveLength / 4, amplitude]
];
}
else if (stage === 1) {
return [
[x + 1 / 2 * waveLength / Math.PI / 2 * (Math.PI - 2),
amplitude],
[x + 1 / 2 * waveLength / Math.PI / 2 * (Math.PI - 1),
amplitude / 2],
[x + waveLength / 4, 0]
]
}
else if (stage === 2) {
return [
[x + 1 / 2 * waveLength / Math.PI / 2, -amplitude / 2],
[x + 1 / 2 * waveLength / Math.PI, -amplitude],
[x + waveLength / 4, -amplitude]
]
}
else {
return [
[x + 1 / 2 * waveLength / Math.PI / 2 * (Math.PI - 2),
-amplitude],
[x + 1 / 2 * waveLength / Math.PI / 2 * (Math.PI - 1),
-amplitude / 2],
[x + waveLength / 4, 0]
]
}
}
+507
View File
@@ -0,0 +1,507 @@
import * as echarts from 'echarts/lib/echarts';
import * as numberUtil from 'echarts/lib/util/number';
import LiquidShape from './liquidFillShape';
var parsePercent = numberUtil.parsePercent;
function isPathSymbol(symbol) {
return symbol && symbol.indexOf('path://') === 0
}
echarts.extendChartView({
type: 'liquidFill',
render: function (seriesModel, ecModel, api) {
var self = this;
var group = this.group;
group.removeAll();
var data = seriesModel.getData();
var itemModel = data.getItemModel(0);
var center = itemModel.get('center');
var radius = itemModel.get('radius');
var width = api.getWidth();
var height = api.getHeight();
var size = Math.min(width, height);
// itemStyle
var outlineDistance = 0;
var outlineBorderWidth = 0;
var showOutline = seriesModel.get('outline.show');
if (showOutline) {
outlineDistance = seriesModel.get('outline.borderDistance');
outlineBorderWidth = parsePercent(
seriesModel.get('outline.itemStyle.borderWidth'), size
);
}
var cx = parsePercent(center[0], width);
var cy = parsePercent(center[1], height);
var outterRadius;
var innerRadius;
var paddingRadius;
var isFillContainer = false;
var symbol = seriesModel.get('shape');
if (symbol === 'container') {
// a shape that fully fills the container
isFillContainer = true;
outterRadius = [
width / 2,
height / 2
];
innerRadius = [
outterRadius[0] - outlineBorderWidth / 2,
outterRadius[1] - outlineBorderWidth / 2
];
paddingRadius = [
parsePercent(outlineDistance, width),
parsePercent(outlineDistance, height)
];
radius = [
Math.max(innerRadius[0] - paddingRadius[0], 0),
Math.max(innerRadius[1] - paddingRadius[1], 0)
];
}
else {
outterRadius = parsePercent(radius, size) / 2;
innerRadius = outterRadius - outlineBorderWidth / 2;
paddingRadius = parsePercent(outlineDistance, size);
radius = Math.max(innerRadius - paddingRadius, 0);
}
if (showOutline) {
var outline = getOutline();
outline.style.lineWidth = outlineBorderWidth;
group.add(getOutline());
}
var left = isFillContainer ? 0 : cx - radius;
var top = isFillContainer ? 0 : cy - radius;
var wavePath = null;
group.add(getBackground());
// each data item for a wave
var oldData = this._data;
var waves = [];
data.diff(oldData)
.add(function (idx) {
var wave = getWave(idx, false);
var waterLevel = wave.shape.waterLevel;
wave.shape.waterLevel = isFillContainer ? height / 2 : radius;
echarts.graphic.initProps(wave, {
shape: {
waterLevel: waterLevel
}
}, seriesModel);
wave.z2 = 2;
setWaveAnimation(idx, wave, null);
group.add(wave);
data.setItemGraphicEl(idx, wave);
waves.push(wave);
})
.update(function (newIdx, oldIdx) {
var waveElement = oldData.getItemGraphicEl(oldIdx);
// new wave is used to calculate position, but not added
var newWave = getWave(newIdx, false, waveElement);
// changes with animation
var shape = {};
var shapeAttrs = ['amplitude', 'cx', 'cy', 'phase', 'radius', 'radiusY', 'waterLevel', 'waveLength'];
for (var i = 0; i < shapeAttrs.length; ++i) {
var attr = shapeAttrs[i];
if (newWave.shape.hasOwnProperty(attr)) {
shape[attr] = newWave.shape[attr];
}
}
var style = {};
var styleAttrs = ['fill', 'opacity', 'shadowBlur', 'shadowColor'];
for (var i = 0; i < styleAttrs.length; ++i) {
var attr = styleAttrs[i];
if (newWave.style.hasOwnProperty(attr)) {
style[attr] = newWave.style[attr];
}
}
if (isFillContainer) {
shape.radiusY = height / 2;
}
// changes with animation
echarts.graphic.updateProps(waveElement, {
shape: shape,
x: newWave.x,
y: newWave.y
}, seriesModel);
if (seriesModel.isUniversalTransitionEnabled && seriesModel.isUniversalTransitionEnabled()) {
echarts.graphic.updateProps(waveElement, {
style: style
}, seriesModel);
}
else {
waveElement.useStyle(style);
}
// instant changes
var oldWaveClipPath = waveElement.getClipPath();
var newWaveClipPath = newWave.getClipPath();
waveElement.setClipPath(newWave.getClipPath());
waveElement.shape.inverse = newWave.inverse;
if (oldWaveClipPath && newWaveClipPath
&& self._shape === symbol
// TODO use zrender morphing to apply complex symbol animation.
&& !isPathSymbol(symbol)
) {
// Can be animated.
echarts.graphic.updateProps(newWaveClipPath, {
shape: oldWaveClipPath.shape
}, seriesModel, { isFrom: true });
}
setWaveAnimation(newIdx, waveElement, waveElement);
group.add(waveElement);
data.setItemGraphicEl(newIdx, waveElement);
waves.push(waveElement);
})
.remove(function (idx) {
var wave = oldData.getItemGraphicEl(idx);
group.remove(wave);
})
.execute();
if (itemModel.get('label.show')) {
group.add(getText(waves));
}
this._shape = symbol;
this._data = data;
/**
* Get path for outline, background and clipping
*
* @param {number} r outter radius of shape
* @param {boolean|undefined} isForClipping if the shape is used
* for clipping
*/
function getPath(r, isForClipping) {
if (symbol) {
// customed symbol path
if (isPathSymbol(symbol)) {
var path = echarts.graphic.makePath(symbol.slice(7), {});
var bouding = path.getBoundingRect();
var w = bouding.width;
var h = bouding.height;
if (w > h) {
h = r * 2 / w * h;
w = r * 2;
}
else {
w = r * 2 / h * w;
h = r * 2;
}
var left = isForClipping ? 0 : cx - w / 2;
var top = isForClipping ? 0 : cy - h / 2;
path = echarts.graphic.makePath(
symbol.slice(7),
{},
new echarts.graphic.BoundingRect(left, top, w, h)
);
if (isForClipping) {
path.x = -w / 2;
path.y = -h / 2;
}
return path;
}
else if (isFillContainer) {
// fully fill the container
var x = isForClipping ? -r[0] : cx - r[0];
var y = isForClipping ? -r[1] : cy - r[1];
return echarts.helper.createSymbol(
'rect', x, y, r[0] * 2, r[1] * 2
);
}
else {
var x = isForClipping ? -r : cx - r;
var y = isForClipping ? -r : cy - r;
if (symbol === 'pin') {
y += r;
}
else if (symbol === 'arrow') {
y -= r;
}
return echarts.helper.createSymbol(symbol, x, y, r * 2, r * 2);
}
}
return new echarts.graphic.Circle({
shape: {
cx: isForClipping ? 0 : cx,
cy: isForClipping ? 0 : cy,
r: r
}
});
}
/**
* Create outline
*/
function getOutline() {
var outlinePath = getPath(outterRadius);
outlinePath.style.fill = null;
outlinePath.setStyle(seriesModel.getModel('outline.itemStyle')
.getItemStyle());
return outlinePath;
}
/**
* Create background
*/
function getBackground() {
// Seperate stroke and fill, so we can use stroke to cover the alias of clipping.
var strokePath = getPath(radius);
strokePath.setStyle(seriesModel.getModel('backgroundStyle')
.getItemStyle());
strokePath.style.fill = null;
// Stroke is front of wave
strokePath.z2 = 5;
var fillPath = getPath(radius);
fillPath.setStyle(seriesModel.getModel('backgroundStyle')
.getItemStyle());
fillPath.style.stroke = null;
var group = new echarts.graphic.Group();
group.add(strokePath);
group.add(fillPath);
return group;
}
/**
* wave shape
*/
function getWave(idx, isInverse, oldWave) {
var radiusX = isFillContainer ? radius[0] : radius;
var radiusY = isFillContainer ? height / 2 : radius;
var itemModel = data.getItemModel(idx);
var itemStyleModel = itemModel.getModel('itemStyle');
var phase = itemModel.get('phase');
var amplitude = parsePercent(itemModel.get('amplitude'),
radiusY * 2);
var waveLength = parsePercent(itemModel.get('waveLength'),
radiusX * 2);
var value = data.get('value', idx);
var waterLevel = radiusY - value * radiusY * 2;
phase = oldWave ? oldWave.shape.phase
: (phase === 'auto' ? idx * Math.PI / 4 : phase);
var normalStyle = itemStyleModel.getItemStyle();
if (!normalStyle.fill) {
var seriesColor = seriesModel.get('color');
var id = idx % seriesColor.length;
normalStyle.fill = seriesColor[id];
}
var x = radiusX * 2;
var wave = new LiquidShape({
shape: {
waveLength: waveLength,
radius: radiusX,
radiusY: radiusY,
cx: x,
cy: 0,
waterLevel: waterLevel,
amplitude: amplitude,
phase: phase,
inverse: isInverse
},
style: normalStyle,
x: cx,
y: cy,
});
wave.shape._waterLevel = waterLevel;
var hoverStyle = itemModel.getModel('emphasis.itemStyle')
.getItemStyle();
hoverStyle.lineWidth = 0;
wave.ensureState('emphasis').style = hoverStyle;
echarts.helper.enableHoverEmphasis(wave);
// clip out the part outside the circle
var clip = getPath(radius, true);
// set fill for clipPath, otherwise it will not trigger hover event
clip.setStyle({
fill: 'white'
});
wave.setClipPath(clip);
return wave;
}
function setWaveAnimation(idx, wave, oldWave) {
var itemModel = data.getItemModel(idx);
var maxSpeed = itemModel.get('period');
var direction = itemModel.get('direction');
var value = data.get('value', idx);
var phase = itemModel.get('phase');
phase = oldWave ? oldWave.shape.phase
: (phase === 'auto' ? idx * Math.PI / 4 : phase);
var defaultSpeed = function (maxSpeed) {
var cnt = data.count();
return cnt === 0 ? maxSpeed : maxSpeed *
(0.2 + (cnt - idx) / cnt * 0.8);
};
var speed = 0;
if (maxSpeed === 'auto') {
speed = defaultSpeed(5000);
}
else {
speed = typeof maxSpeed === 'function'
? maxSpeed(value, idx) : maxSpeed;
}
// phase for moving left/right
var phaseOffset = 0;
if (direction === 'right' || direction == null) {
phaseOffset = Math.PI;
}
else if (direction === 'left') {
phaseOffset = -Math.PI;
}
else if (direction === 'none') {
phaseOffset = 0;
}
else {
console.error('Illegal direction value for liquid fill.');
}
// wave animation of moving left/right
if (direction !== 'none' && itemModel.get('waveAnimation')) {
wave
.animate('shape', true)
.when(0, {
phase: phase
})
.when(speed / 2, {
phase: phaseOffset + phase
})
.when(speed, {
phase: phaseOffset * 2 + phase
})
.during(function () {
if (wavePath) {
wavePath.dirty(true);
}
})
.start();
}
}
/**
* text on wave
*/
function getText(waves) {
var labelModel = itemModel.getModel('label');
function formatLabel() {
var formatted = seriesModel.getFormattedLabel(0, 'normal');
var defaultVal = (data.get('value', 0) * 100);
var defaultLabel = data.getName(0) || seriesModel.name;
if (!isNaN(defaultVal)) {
defaultLabel = defaultVal.toFixed(0) + '%';
}
return formatted == null ? defaultLabel : formatted;
}
var textRectOption = {
z2: 10,
shape: {
x: left,
y: top,
width: (isFillContainer ? radius[0] : radius) * 2,
height: (isFillContainer ? radius[1] : radius) * 2
},
style: {
fill: 'transparent'
},
textConfig: {
position: labelModel.get('position') || 'inside'
},
silent: true
};
var textOption = {
style: {
text: formatLabel(),
textAlign: labelModel.get('align'),
textVerticalAlign: labelModel.get('baseline')
}
};
Object.assign(textOption.style, echarts.helper.createTextStyle(labelModel));
var outsideTextRect = new echarts.graphic.Rect(textRectOption);
var insideTextRect = new echarts.graphic.Rect(textRectOption);
insideTextRect.disableLabelAnimation = true;
outsideTextRect.disableLabelAnimation = true;
var outsideText = new echarts.graphic.Text(textOption);
var insideText = new echarts.graphic.Text(textOption);
outsideTextRect.setTextContent(outsideText);
insideTextRect.setTextContent(insideText);
var insColor = labelModel.get('insideColor');
insideText.style.fill = insColor;
var group = new echarts.graphic.Group();
group.add(outsideTextRect);
group.add(insideTextRect);
// clip out waves for insideText
var boundingCircle = getPath(radius, true);
wavePath = new echarts.graphic.CompoundPath({
shape: {
paths: waves
},
x: cx,
y: cy
});
wavePath.setClipPath(boundingCircle);
insideTextRect.setClipPath(wavePath);
return group;
}
},
dispose: function () {
// dispose nothing here
}
});