@jzg223/design-runtime 0.0.1 → 0.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +52 -0
- package/dist/board/api.js +57 -0
- package/dist/board/board-chart.vue.js +7 -0
- package/dist/board/board-chart2.vue.js +93 -0
- package/dist/board/context.js +15 -0
- package/dist/board/datav-map.js +66 -0
- package/dist/board/design-board.vue.js +172 -0
- package/dist/board/design-board2.vue.js +331 -0
- package/dist/board/echarts.js +40 -0
- package/dist/board/part-node.vue.js +63 -0
- package/dist/board/part-node2.vue.js +125 -0
- package/dist/board/part-renderer.vue.js +561 -0
- package/dist/board/part-renderer2.vue.js +884 -0
- package/dist/board/theme-host.js +129 -0
- package/dist/board-engine/asset.js +32 -0
- package/dist/board-engine/chart-option.js +377 -0
- package/dist/board-engine/data-hub.js +67 -0
- package/dist/board-engine/datav-meta.js +51 -0
- package/dist/board-engine/i18n.js +130 -0
- package/dist/board-engine/param-catalog.js +137 -0
- package/dist/board-engine/params.js +61 -0
- package/dist/board-engine/render-model.js +65 -0
- package/dist/board-engine/schema.js +856 -0
- package/dist/board-engine/theme-manifest.js +19 -0
- package/dist/board-engine/version.js +4 -0
- package/dist/host.js +6 -1
- package/dist/index.js +172 -24
- package/dist/style.css +1067 -0
- package/dist/types/board/api.d.ts +45 -0
- package/dist/types/board/board-chart.vue.d.ts +15 -0
- package/dist/types/board/context.d.ts +29 -0
- package/dist/types/board/datav-map.d.ts +24 -0
- package/dist/types/board/design-board.vue.d.ts +1409 -0
- package/dist/types/board/echarts.d.ts +25 -0
- package/dist/types/board/part-node.vue.d.ts +1237 -0
- package/dist/types/board/part-renderer.vue.d.ts +1205 -0
- package/dist/types/board/theme-host.d.ts +41 -0
- package/dist/types/board-engine/asset.d.ts +37 -0
- package/dist/types/board-engine/chart-option.d.ts +737 -0
- package/dist/types/board-engine/data-hub.d.ts +48 -0
- package/dist/types/board-engine/datav-meta.d.ts +37 -0
- package/dist/types/board-engine/i18n.d.ts +43 -0
- package/dist/types/board-engine/index.d.ts +25 -0
- package/dist/types/board-engine/param-catalog.d.ts +82 -0
- package/dist/types/board-engine/params.d.ts +37 -0
- package/dist/types/board-engine/render-model.d.ts +87 -0
- package/dist/types/board-engine/schema.d.ts +513 -0
- package/dist/types/board-engine/theme-manifest.d.ts +23 -0
- package/dist/types/board-engine/types.d.ts +147 -0
- package/dist/types/board-engine/version.d.ts +10 -0
- package/dist/types/host.d.ts +21 -0
- package/dist/types/index.d.ts +21 -2
- package/package.json +21 -4
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
import BoardChart from "./board-chart.vue.js";
|
|
2
|
+
import { fmtNum, buildChartOption } from "../board-engine/chart-option.js";
|
|
3
|
+
import { textStyle, measureLabel, partVisualStyle, CARD_TYPES, isContainer, decorOf, borderPresetOf, childRowsOf, titleStyleOf, titleInsetOf, titleOffsetOf, titleReserveOf, paddingOf, themeOf } from "../board-engine/schema.js";
|
|
4
|
+
import { boardAssetUrl } from "./api.js";
|
|
5
|
+
import { datavDecorationComponent, datavBorderComponent } from "./datav-map.js";
|
|
6
|
+
import { DESIGN_BOARD_CTX } from "./context.js";
|
|
7
|
+
const _sfc_main = {
|
|
8
|
+
name: "PartRenderer",
|
|
9
|
+
components: { BoardChart },
|
|
10
|
+
// 看板上下文(参数 / 参数目录 / 取数 / 素材前缀)。design-ui 版本用模块级单例,
|
|
11
|
+
// 运行端可能一页多个看板 ⇒ 改成 provide/inject,每个 DesignBoard 实例一份。
|
|
12
|
+
inject: { boardCtx: { from: DESIGN_BOARD_CTX, default: null } },
|
|
13
|
+
props: {
|
|
14
|
+
part: { type: Object, required: true },
|
|
15
|
+
themeCode: { type: String, default: "tech-blue" },
|
|
16
|
+
refreshKey: { type: Number, default: 0 },
|
|
17
|
+
/** 看板参数版本号(引擎的参数 state 是普通对象,Vue 追踪不到 ⇒ 由 DesignBoard 往下传) */
|
|
18
|
+
paramsVersion: { type: Number, default: 0 },
|
|
19
|
+
/** 容器内子栅格的几何(列数/行高/间距,来自整页设置) */
|
|
20
|
+
gridOptions: { type: Object, default: () => ({ cols: 24, rowHeight: 60, gap: 12 }) },
|
|
21
|
+
/** 本部件直接子级的总数(决定容器显示子级还是显示空提示) */
|
|
22
|
+
childrenCount: { type: Number, default: 0 },
|
|
23
|
+
/** 每个页签各有几个子级,形如 {0:2, 2:1} */
|
|
24
|
+
tabChildCounts: { type: Object, default: () => ({}) },
|
|
25
|
+
/** 设计器里为 true:空容器的引导文案只在设计器显示,不泄漏到运行时大屏 */
|
|
26
|
+
design: { type: Boolean, default: false },
|
|
27
|
+
/** 设计器里"当前选中的就是这个部件":选中时把容器的**内容区**画成虚线框,
|
|
28
|
+
* 这样"子级有没有超出内容区"一眼可见(用户反馈过"text 的区域好像超出 container 了")。 */
|
|
29
|
+
active: { type: Boolean, default: false },
|
|
30
|
+
/** 移动端堆叠:容器内的子部件区改成纵向流式(否则 inset:0 的绝对定位区在 auto 高度下会塌成 0) */
|
|
31
|
+
stacked: { type: Boolean, default: false },
|
|
32
|
+
/** 窄屏:图例挪到底部、饼图居中收窄(图表 option 用) */
|
|
33
|
+
narrow: { type: Boolean, default: false }
|
|
34
|
+
},
|
|
35
|
+
data() {
|
|
36
|
+
return {
|
|
37
|
+
result: null,
|
|
38
|
+
loading: false,
|
|
39
|
+
carouselIndex: 0,
|
|
40
|
+
activeTab: 0,
|
|
41
|
+
timer: null,
|
|
42
|
+
shrunkFont: 0,
|
|
43
|
+
// DataV 尺寸同步用的观察器(见 bindDatavResize 的注释)
|
|
44
|
+
dvRO: null,
|
|
45
|
+
dvROEl: null
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
computed: {
|
|
49
|
+
theme() {
|
|
50
|
+
return themeOf(this.themeCode);
|
|
51
|
+
},
|
|
52
|
+
/** tab容器当前页签里有没有子部件(有就显示子栅格、没有就回落成原来的文字内容) */
|
|
53
|
+
tabHasChildren() {
|
|
54
|
+
return (this.tabChildCounts[this.activeTab] || 0) > 0;
|
|
55
|
+
},
|
|
56
|
+
/** 本容器的子级是否用绝对定位摆放(容器设置里的 childLayout='free') */
|
|
57
|
+
childrenFree() {
|
|
58
|
+
return !!(this.part.view && this.part.view.childLayout === "free");
|
|
59
|
+
},
|
|
60
|
+
/** 本容器的内边距(部件级可配)。⚠️ 必须与 schema.paddingOf 用同一个来源:
|
|
61
|
+
* 内框宽度、拖入容器的列计算都用它,写死 10 会让"按落点算列"整体偏一列。 */
|
|
62
|
+
childPad() {
|
|
63
|
+
return paddingOf(this.part);
|
|
64
|
+
},
|
|
65
|
+
/** 标题要占的面板内高度(内容区顶部让位):bar 恒定 34,chip 挪进面板内时按实际高度算 */
|
|
66
|
+
titleBarH() {
|
|
67
|
+
return titleReserveOf(this.part);
|
|
68
|
+
},
|
|
69
|
+
/**
|
|
70
|
+
* 胶囊标题的内联位置:
|
|
71
|
+
* 纵向 = titleOffsetOf(相对面板上沿的偏移,0 = 贴面板内上沿)
|
|
72
|
+
* 横向 = 容器内边距 + titleInsetOf(默认再多缩 8px,即与内容左边缘略错开一点)
|
|
73
|
+
* 标题压在切角标记上没关系:标题 z-index 1、切角 z-index 0,观感是"标签切断框线"。
|
|
74
|
+
*/
|
|
75
|
+
titleOffsetStyle() {
|
|
76
|
+
if (this.titleStyle === "bar") return {};
|
|
77
|
+
return {
|
|
78
|
+
top: -titleOffsetOf(this.part) + "px",
|
|
79
|
+
left: Math.max(0, this.childPad + titleInsetOf(this.part)) + "px"
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
/** 容器标题样式:chip(胶囊骑框线,默认)/ bar(框内标题条,见 TITLE_BAR_HEIGHT) */
|
|
83
|
+
titleStyle() {
|
|
84
|
+
return titleStyleOf(this.part);
|
|
85
|
+
},
|
|
86
|
+
/** 子栅格行高是否"撑满容器内区"(见 schema.childRowsOf 的说明) */
|
|
87
|
+
childRowsFill() {
|
|
88
|
+
return childRowsOf(this.part) === "fill";
|
|
89
|
+
},
|
|
90
|
+
/** 容器边框样式预设(四角括号 / 切角描边 / 描边流光 / DataV 边框 / 无) */
|
|
91
|
+
borderPreset() {
|
|
92
|
+
return borderPresetOf(this.part);
|
|
93
|
+
},
|
|
94
|
+
/** DataV 边框组件(borderPreset==='datav' 时用),编号取自 view.datavBorder */
|
|
95
|
+
datavBorder() {
|
|
96
|
+
return datavBorderComponent(this.part.view && this.part.view.datavBorder);
|
|
97
|
+
},
|
|
98
|
+
/** DataV 装饰组件(decoration 部件用),编号取自 view.datavDecoration */
|
|
99
|
+
datavDecoration() {
|
|
100
|
+
return datavDecorationComponent(this.part.view && this.part.view.datavDecoration);
|
|
101
|
+
},
|
|
102
|
+
/**
|
|
103
|
+
* DataV **边框**的配色(容器边框预设=datav 时用)。取自主题 `--brd-frame-1 / --brd-frame-2`。
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ 第二个颜色默认是 **transparent**,这是刻意的:读了 DataV borderBox 的源码,
|
|
106
|
+
* `color[0]` 画整圈细框(1px),`color[1]` 画的是**沿边流动的光点**(3px + 径向发光遮罩)。
|
|
107
|
+
* 给它一个不透明色,任何时候都有一条边比其余三条又粗又亮 ⇒ 用户反馈"四条边的颜色不一样"。
|
|
108
|
+
* 主题里把 `--brd-frame-2` 写回颜色就能把那条流光要回来(同色流光=写 var(--brd-accent))。
|
|
109
|
+
*/
|
|
110
|
+
datavColors() {
|
|
111
|
+
const t = this.theme || {};
|
|
112
|
+
const f = t.frameColors;
|
|
113
|
+
if (Array.isArray(f) && f.length >= 2 && f[0]) return [f[0], f[1] || "transparent"];
|
|
114
|
+
const c = t.colors && t.colors[0] || "#4d8dff";
|
|
115
|
+
return [c, "transparent"];
|
|
116
|
+
},
|
|
117
|
+
/**
|
|
118
|
+
* DataV **装饰部件**(decoration 1–12)的配色:另一对变量 `--brd-deco-1 / --brd-deco-2`。
|
|
119
|
+
* 不能跟边框共用 —— 边框那边第二个颜色是 transparent(关掉流光),装饰要是也 transparent 就缺一半。
|
|
120
|
+
*/
|
|
121
|
+
datavDecoColors() {
|
|
122
|
+
const t = this.theme || {};
|
|
123
|
+
const d = t.decoColors;
|
|
124
|
+
if (Array.isArray(d) && d.length >= 2 && d[0]) return [d[0], d[1] || d[0]];
|
|
125
|
+
const cs = t.colors || [];
|
|
126
|
+
return [cs[0] || "#4d8dff", cs[1] || cs[0] || "#22d3ee"];
|
|
127
|
+
},
|
|
128
|
+
/** 子级栅格:列宽用 1fr ⇒ 容器一变宽子级就跟着重排,这就是"容器内子栅格"的意义 */
|
|
129
|
+
childGridStyle() {
|
|
130
|
+
const g = this.gridOptions || {};
|
|
131
|
+
const rh = g.rowHeight || 60;
|
|
132
|
+
const barH = this.titleBarH;
|
|
133
|
+
if (this.stacked) {
|
|
134
|
+
return {
|
|
135
|
+
position: "relative",
|
|
136
|
+
inset: "auto",
|
|
137
|
+
height: "auto",
|
|
138
|
+
// padding 不写在元素上:由 CSS 用 .part-body 上的 --brd-child-pad / --brd-title-reserve 出
|
|
139
|
+
// (配置仍是唯一真源,见 schema.paddingOf;见 .part-children.is-stacked 规则)
|
|
140
|
+
display: "flex",
|
|
141
|
+
flexDirection: "column",
|
|
142
|
+
gap: (g.gap === void 0 ? 10 : g.gap) + "px",
|
|
143
|
+
overflow: "visible"
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const ovf = this.design ? "visible" : "auto";
|
|
147
|
+
if (this.childrenFree) return { overflow: ovf, top: barH ? barH + "px" : 0 };
|
|
148
|
+
return {
|
|
149
|
+
display: "grid",
|
|
150
|
+
gridTemplateColumns: `repeat(${g.cols || 24}, minmax(0, 1fr))`,
|
|
151
|
+
// 行高两种口径(部件设置 → 容器 → 子栅格行高):
|
|
152
|
+
// fill = 按容器内区均分(1fr)⇒ 子级跨满时正好填满,内边距纯粹是"留白"(1:1 包裹用这个)
|
|
153
|
+
// fixed = 行高按整页值,但**封顶**在整页行高、空间不够时允许收缩:minmax(0, 行高)
|
|
154
|
+
//
|
|
155
|
+
// ⚠️ 这里**不能**用整页那套 `minmax(行高, auto)`:那个"最小值=行高"意味着行**永远不小于** 60px,
|
|
156
|
+
// 而容器的内容区 = 容器高 − 2×内边距 − 标题让位,天生比"同样行数的整页高度"小 20~50px
|
|
157
|
+
// ⇒ 子级跨满时必然超出内容区,运行时就冒出容器内部的滚动条(用户:"一个 container 装了 3 个
|
|
158
|
+
// 指标卡,预览的时候出现了滚动条")。改成 minmax(0, 行高):装得下就按整页行高,装不下就等比缩一点。
|
|
159
|
+
gridAutoRows: this.childRowsFill ? "minmax(0, 1fr)" : "minmax(0, " + rh + "px)",
|
|
160
|
+
gap: (g.gap === void 0 ? 12 : g.gap) + "px",
|
|
161
|
+
// 内边距不写在元素上(见 .part-children 规则:读 .part-body 上的 --brd-child-pad);
|
|
162
|
+
// 标题条样式额外让出 TITLE_BAR_HEIGHT,用 top 让位(那是"定位",不是 padding)
|
|
163
|
+
top: barH ? barH + "px" : 0,
|
|
164
|
+
overflow: ovf
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
hasData() {
|
|
168
|
+
return !!(this.result && this.result.rows && this.result.rows.length);
|
|
169
|
+
},
|
|
170
|
+
/**
|
|
171
|
+
* 根容器要不要放开裁切。
|
|
172
|
+
* 静态边框/自由容器的标题是**骑在框线上、画在框外面**的(top:-11px),
|
|
173
|
+
* 而 .part-body 默认 overflow:hidden ⇒ 标题上半截会被切掉(用户反馈"卡片标题没显示全")。
|
|
174
|
+
* 这两个类型的内部内容是 .part-children(自己带 overflow:auto),放开父级不会让内容溢出。
|
|
175
|
+
*/
|
|
176
|
+
bodyClass() {
|
|
177
|
+
const cls = [];
|
|
178
|
+
if (this.part.type === "border" || this.part.type === "container") cls.push("ov-visible");
|
|
179
|
+
if (CARD_TYPES.indexOf(this.part.type) >= 0) cls.push("has-body-pad");
|
|
180
|
+
if (isContainer(this.part.type)) {
|
|
181
|
+
cls.push("is-panel");
|
|
182
|
+
const d = decorOf(this.part);
|
|
183
|
+
if (d.glass) cls.push("dec-glass");
|
|
184
|
+
const preset = borderPresetOf(this.part);
|
|
185
|
+
if (preset !== "none") cls.push("bd-" + preset);
|
|
186
|
+
}
|
|
187
|
+
return cls;
|
|
188
|
+
},
|
|
189
|
+
/** 指标卡:把"对齐"落到横向排布上(.kpi 是纵向 flex,align-items 才是横向对齐;
|
|
190
|
+
* 以前 text.align 对指标卡同样完全不生效 —— 与文本部件同一个根因) */
|
|
191
|
+
kpiBoxStyle() {
|
|
192
|
+
const align = this.part.text && this.part.text.align || "left";
|
|
193
|
+
const map = { left: "flex-start", center: "center", right: "flex-end" };
|
|
194
|
+
return { alignItems: map[align] || "flex-start", textAlign: align };
|
|
195
|
+
},
|
|
196
|
+
bodyStyle() {
|
|
197
|
+
return Object.assign({
|
|
198
|
+
// ⚠️ padding 不在这里写(用户:"part-body 样式里的 padding:0px 不用加了,放到 theme 目录里"):
|
|
199
|
+
// 内容部件的内边距由主题变量 --brd-body-pad 给(见 .part-body 的样式规则);
|
|
200
|
+
// 容器的内边距仍是它自己的配置(--brd-child-pad),所以容器不吃这一层,免得两层叠加。
|
|
201
|
+
//
|
|
202
|
+
// 容器"内边距 / 标题让位"以 CSS 变量下发:.part-children 的样式在**样式表里**读它们,
|
|
203
|
+
// 不把 padding 写进元素的内联 style。配置仍是唯一真源(schema.paddingOf / titleReserveOf)。
|
|
204
|
+
"--brd-child-pad": this.childPad + "px",
|
|
205
|
+
"--brd-title-reserve": this.titleBarH + "px"
|
|
206
|
+
}, partVisualStyle(this.part));
|
|
207
|
+
},
|
|
208
|
+
subStyle() {
|
|
209
|
+
return {
|
|
210
|
+
color: this.theme.subText,
|
|
211
|
+
fontSize: Math.max(12, (this.part.text && this.part.text.fontSize ? this.part.text.fontSize : 14) - 2) + "px",
|
|
212
|
+
letterSpacing: "0.06em"
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
valueStyle() {
|
|
216
|
+
const t = this.part.text || {};
|
|
217
|
+
return {
|
|
218
|
+
color: t.color || this.theme.colors[0],
|
|
219
|
+
fontFamily: t.fontFamily || "微软雅黑",
|
|
220
|
+
fontWeight: 600,
|
|
221
|
+
fontVariantNumeric: "tabular-nums"
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
chartOption() {
|
|
225
|
+
if (this.part.type !== "chart" || !this.hasData) return null;
|
|
226
|
+
return buildChartOption(this.result.rows, this.result.columns, this.part, this.themeCode, { narrow: this.narrow });
|
|
227
|
+
},
|
|
228
|
+
kpiTitle() {
|
|
229
|
+
return this.part.view && this.part.view.title || this.part.name;
|
|
230
|
+
},
|
|
231
|
+
kpiValue() {
|
|
232
|
+
return this.measureText(0);
|
|
233
|
+
},
|
|
234
|
+
decimalFormat() {
|
|
235
|
+
const p = this.part.view && this.part.view.precision;
|
|
236
|
+
const thousand = this.part.view && this.part.view.thousand;
|
|
237
|
+
let f = "";
|
|
238
|
+
if (p) {
|
|
239
|
+
f = ".";
|
|
240
|
+
for (let i = 0; i < p; i++) f += "0";
|
|
241
|
+
}
|
|
242
|
+
return (thousand ? "," : "") + f;
|
|
243
|
+
},
|
|
244
|
+
tableColumns() {
|
|
245
|
+
const cols = this.result && this.result.columns || [];
|
|
246
|
+
const ms = this.part.data && this.part.data.measures || [];
|
|
247
|
+
if (!ms.length) return cols.map((c) => ({ ...c, label: c.alias || c.name }));
|
|
248
|
+
return cols.map((c) => {
|
|
249
|
+
const m = ms.find((x) => (x.alias || x.field) === c.name);
|
|
250
|
+
const label = m ? measureLabel(m, c.alias) : c.alias || c.name;
|
|
251
|
+
return Object.assign({}, c, { label });
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
tableRows() {
|
|
255
|
+
return this.result && this.result.rows || [];
|
|
256
|
+
},
|
|
257
|
+
/** 明细表:可排序 / 可拖列宽 / 显示合计(都可关) */
|
|
258
|
+
isSortable() {
|
|
259
|
+
return this.part.view.sortable !== false;
|
|
260
|
+
},
|
|
261
|
+
isResizable() {
|
|
262
|
+
return this.part.view.resizable !== false;
|
|
263
|
+
},
|
|
264
|
+
showSummary() {
|
|
265
|
+
return !!this.part.view.showSummary;
|
|
266
|
+
},
|
|
267
|
+
hasColWidths() {
|
|
268
|
+
return Object.keys(this.part.view.colWidths || {}).length > 0;
|
|
269
|
+
},
|
|
270
|
+
/**
|
|
271
|
+
* 表格宽度(只对"有拖过列宽"的明细表生效)。
|
|
272
|
+
*
|
|
273
|
+
* ⚠️ 原来的 CSS 是 `.fixed { width: max-content; min-width: 100% }` —— 表格按**内容最大宽度**排版,
|
|
274
|
+
* 于是只要"纵向滚动条"占掉十几像素,max-content 就比可视宽度大一点点 ⇒ **凭空冒出一条横向滚动条**
|
|
275
|
+
* (表格明明塞得下;用户反馈"这个应该没有 scroll-x")。
|
|
276
|
+
*
|
|
277
|
+
* 现在:
|
|
278
|
+
* - 所有列都有显式宽度(用户逐列拖过)⇒ width = max(100%, 各列合计),合计真的超了才横向滚动;
|
|
279
|
+
* - 还有"自动列"(只拖了其中几列)⇒ 交给浏览器按 100% 排(显式宽度仍然作为该列的最小宽度生效,
|
|
280
|
+
* 真放不下时表格会自己撑破容器、滚动条照旧出现)。
|
|
281
|
+
*/
|
|
282
|
+
tableStyle() {
|
|
283
|
+
const cw = this.part.view && this.part.view.colWidths;
|
|
284
|
+
if (!cw) return {};
|
|
285
|
+
const cols = this.tableColumns || [];
|
|
286
|
+
const widths = cols.map((c) => Number(cw[c.name]) || 0);
|
|
287
|
+
if (!cols.length || widths.some((w) => !w)) return {};
|
|
288
|
+
const total = widths.reduce((a, b) => a + b, 0);
|
|
289
|
+
return { width: `max(100%, ${total}px)` };
|
|
290
|
+
},
|
|
291
|
+
sortField() {
|
|
292
|
+
return this.part.data && this.part.data.orderField || "";
|
|
293
|
+
},
|
|
294
|
+
// ---------------- 交叉表 ----------------
|
|
295
|
+
/** 交叉表用的度量列表(多度量:每个度量一组数值列) */
|
|
296
|
+
crossMeasures() {
|
|
297
|
+
const d = this.part.data || {};
|
|
298
|
+
return (d.measures || []).filter((m) => m && m.field).map((m) => {
|
|
299
|
+
const key = m.alias || m.field;
|
|
300
|
+
const c = this.tableColumns.find((x) => x.name === key);
|
|
301
|
+
return { key, name: measureLabel(m, c && (c.alias || c.name) || key) };
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
/**
|
|
305
|
+
* 交叉表的**整份视图数据**:表头行 / 表体行 / 表尾单元格,一次算完。
|
|
306
|
+
*
|
|
307
|
+
* ⚠️ 为什么要合成一个 computed:之前拆成 crossHeadRows / crossBodyRows / crossFootCells 三个 computed,
|
|
308
|
+
* 它们都依赖另一个 computed(cross),结果出现了"cross 已经是最新(28 行 6 列)、
|
|
309
|
+
* 但表头 computed 还停在空数据那一版"的缓存不失效问题 —— 表头永远只有 2 列、$forceUpdate 都救不回来。
|
|
310
|
+
* 合成一个、直接从 result/part 推,就不存在链式 computed 的缓存问题。
|
|
311
|
+
*/
|
|
312
|
+
crossView() {
|
|
313
|
+
const ms = this.crossMeasures;
|
|
314
|
+
const showTotal = this.part.view.showTotal !== false;
|
|
315
|
+
const d = this.part.data || {};
|
|
316
|
+
const rows = this.tableRows;
|
|
317
|
+
const rowKey = this.part.view.rowDim || (d.dims || [])[0] || "";
|
|
318
|
+
const colKey = this.part.view.colDim || (d.dims || [])[1] || "";
|
|
319
|
+
const m0 = (d.measures || [])[0];
|
|
320
|
+
const mKey = m0 ? m0.alias || m0.field : "";
|
|
321
|
+
const empty = { headRows: [], bodyRows: [], footCells: [], footTotal: "", rowKey: "", rowField: "", colCount: 0 };
|
|
322
|
+
if (!rowKey || !colKey || !mKey) return empty;
|
|
323
|
+
const aliasOf = (name) => {
|
|
324
|
+
const c = this.tableColumns.find((x) => x.name === name);
|
|
325
|
+
return c && (c.label || c.alias || c.name) || name;
|
|
326
|
+
};
|
|
327
|
+
const val = (r, k) => {
|
|
328
|
+
if (r[k] !== void 0) return r[k];
|
|
329
|
+
const hit = Object.keys(r).find((x) => x.toLowerCase() === String(k).toLowerCase());
|
|
330
|
+
return hit ? r[hit] : null;
|
|
331
|
+
};
|
|
332
|
+
const multi = ms.length > 0;
|
|
333
|
+
const keys = multi ? ms.map((s) => s.key) : [mKey];
|
|
334
|
+
const zero = () => {
|
|
335
|
+
if (!multi) return 0;
|
|
336
|
+
const o = {};
|
|
337
|
+
keys.forEach((k) => {
|
|
338
|
+
o[k] = 0;
|
|
339
|
+
});
|
|
340
|
+
return o;
|
|
341
|
+
};
|
|
342
|
+
const num = (r, k) => {
|
|
343
|
+
const n = Number(val(r, k));
|
|
344
|
+
return Number.isNaN(n) ? 0 : n;
|
|
345
|
+
};
|
|
346
|
+
const agg = (cur, r) => {
|
|
347
|
+
if (multi) {
|
|
348
|
+
keys.forEach((k) => {
|
|
349
|
+
cur[k] += num(r, k);
|
|
350
|
+
});
|
|
351
|
+
} else {
|
|
352
|
+
cur += num(r, mKey);
|
|
353
|
+
}
|
|
354
|
+
return cur;
|
|
355
|
+
};
|
|
356
|
+
const map = {};
|
|
357
|
+
const rowTotal = {};
|
|
358
|
+
const colTotal = {};
|
|
359
|
+
const cols = [];
|
|
360
|
+
const rowNames = [];
|
|
361
|
+
let grand = zero();
|
|
362
|
+
rows.forEach((r) => {
|
|
363
|
+
const rk = String(val(r, rowKey) === null ? "(空)" : val(r, rowKey));
|
|
364
|
+
const ck = String(val(r, colKey) === null ? "(空)" : val(r, colKey));
|
|
365
|
+
if (rowNames.indexOf(rk) < 0) {
|
|
366
|
+
rowNames.push(rk);
|
|
367
|
+
map[rk] = {};
|
|
368
|
+
rowTotal[rk] = zero();
|
|
369
|
+
}
|
|
370
|
+
if (cols.indexOf(ck) < 0) {
|
|
371
|
+
cols.push(ck);
|
|
372
|
+
colTotal[ck] = zero();
|
|
373
|
+
}
|
|
374
|
+
if (!map[rk][ck]) map[rk][ck] = zero();
|
|
375
|
+
map[rk][ck] = agg(map[rk][ck], r);
|
|
376
|
+
rowTotal[rk] = agg(rowTotal[rk], r);
|
|
377
|
+
colTotal[ck] = agg(colTotal[ck], r);
|
|
378
|
+
grand = agg(grand, r);
|
|
379
|
+
});
|
|
380
|
+
let headRows;
|
|
381
|
+
if (!multi) {
|
|
382
|
+
const row = [{ text: aliasOf(rowKey), cls: "corner" }];
|
|
383
|
+
cols.forEach((x) => row.push({ text: x, align: "right" }));
|
|
384
|
+
if (showTotal) row.push({ text: "合计", cls: "corner" });
|
|
385
|
+
headRows = [row];
|
|
386
|
+
} else {
|
|
387
|
+
const r1 = [{ text: aliasOf(rowKey), cls: "corner", rowspan: 2 }];
|
|
388
|
+
cols.forEach((x) => r1.push({ text: x, cls: "group-head", colspan: ms.length }));
|
|
389
|
+
if (showTotal) r1.push({ text: "合计", cls: "corner", rowspan: 2 });
|
|
390
|
+
const r2 = [];
|
|
391
|
+
cols.forEach((x) => ms.forEach((m) => r2.push({ text: m.name, align: "right" })));
|
|
392
|
+
headRows = [r1, r2];
|
|
393
|
+
}
|
|
394
|
+
const cellAt = (rk, ck) => {
|
|
395
|
+
const cell = map[rk] && map[rk][ck];
|
|
396
|
+
return cell === void 0 ? zero() : cell;
|
|
397
|
+
};
|
|
398
|
+
const fmtAgg = (v) => multi ? keys.map((k) => this.fmt(v[k])).join(" / ") : this.fmt(v);
|
|
399
|
+
const bodyRows = rowNames.map((name) => {
|
|
400
|
+
const cells = [];
|
|
401
|
+
cols.forEach((col) => {
|
|
402
|
+
const cell = cellAt(name, col);
|
|
403
|
+
if (multi) ms.forEach((m) => cells.push(this.fmt(cell[m.key])));
|
|
404
|
+
else cells.push(this.fmt(cell));
|
|
405
|
+
});
|
|
406
|
+
return { name, cells, total: fmtAgg(rowTotal[name]) };
|
|
407
|
+
});
|
|
408
|
+
const footCells = [];
|
|
409
|
+
cols.forEach((col) => {
|
|
410
|
+
const cell = colTotal[col] === void 0 ? zero() : colTotal[col];
|
|
411
|
+
if (multi) ms.forEach((m) => footCells.push(this.fmt(cell[m.key])));
|
|
412
|
+
else footCells.push(this.fmt(cell));
|
|
413
|
+
});
|
|
414
|
+
return {
|
|
415
|
+
headRows,
|
|
416
|
+
bodyRows,
|
|
417
|
+
footCells,
|
|
418
|
+
footTotal: fmtAgg(grand),
|
|
419
|
+
rowKey: aliasOf(rowKey),
|
|
420
|
+
rowField: rowKey,
|
|
421
|
+
colCount: cols.length,
|
|
422
|
+
rows: rowNames,
|
|
423
|
+
cols,
|
|
424
|
+
map,
|
|
425
|
+
rowTotal,
|
|
426
|
+
colTotal,
|
|
427
|
+
grand,
|
|
428
|
+
multi
|
|
429
|
+
};
|
|
430
|
+
},
|
|
431
|
+
// ---------------- 其它类型 ----------------
|
|
432
|
+
tabs() {
|
|
433
|
+
return this.part.view && this.part.view.tabs || [];
|
|
434
|
+
},
|
|
435
|
+
tabContents() {
|
|
436
|
+
return this.part.view && this.part.view.contents || [];
|
|
437
|
+
},
|
|
438
|
+
carouselImages() {
|
|
439
|
+
return (this.part.view && this.part.view.images || []).filter((x) => x);
|
|
440
|
+
},
|
|
441
|
+
marqueeStyle() {
|
|
442
|
+
const speed = this.part.view && this.part.view.speed || 12;
|
|
443
|
+
return { animationDuration: speed + "s", color: this.part.text && this.part.text.color || this.theme.text };
|
|
444
|
+
},
|
|
445
|
+
progressValue() {
|
|
446
|
+
if (this.part.view && this.part.view.useData && this.hasData) {
|
|
447
|
+
const v = Number(this.measureText(0).toString().replace(/[,%]/g, ""));
|
|
448
|
+
if (!Number.isNaN(v)) return v <= 1 ? Math.round(v * 100) : Math.round(v);
|
|
449
|
+
}
|
|
450
|
+
return Number(this.part.view && this.part.view.percent || 0);
|
|
451
|
+
},
|
|
452
|
+
/** 筛选部件"生效配置":类型与可选值优先取数据集声明,部件自己的只作覆盖 */
|
|
453
|
+
filterCfg() {
|
|
454
|
+
return this.ctx().catalog.effectiveFilter(this.part.view);
|
|
455
|
+
},
|
|
456
|
+
currentParam() {
|
|
457
|
+
const code = this.part.view && this.part.view.paramCode;
|
|
458
|
+
return code && this.ctx().params.state.values[code] || "";
|
|
459
|
+
},
|
|
460
|
+
/** 导航项(过滤掉空项;value 缺省时回落成 label) */
|
|
461
|
+
navItems() {
|
|
462
|
+
const v = this.part.view || {};
|
|
463
|
+
const items = Array.isArray(v.items) ? v.items : [];
|
|
464
|
+
return items.filter((it) => it && (it.label || it.value)).map((it) => ({
|
|
465
|
+
label: String(it.label || it.value),
|
|
466
|
+
value: it.value === void 0 || it.value === "" ? String(it.label || "") : String(it.value),
|
|
467
|
+
code: it.code || "",
|
|
468
|
+
reset: it.reset,
|
|
469
|
+
x: it.x,
|
|
470
|
+
y: it.y,
|
|
471
|
+
w: it.w,
|
|
472
|
+
h: it.h,
|
|
473
|
+
align: it.align
|
|
474
|
+
}));
|
|
475
|
+
},
|
|
476
|
+
/** 绝对定位模式:每个菜单项自己带 x/y/w/h(设计稿里菜单项是**沿斜切板排布**的,
|
|
477
|
+
等宽等距的流式布局对不上,所以给 nav 加了这种布局) */
|
|
478
|
+
navAbsolute() {
|
|
479
|
+
return (this.part.view || {}).layout === "absolute";
|
|
480
|
+
},
|
|
481
|
+
/**
|
|
482
|
+
* 导航的"当前选中值":
|
|
483
|
+
* 运行时 —— 以**真实参数值**为准(刷新/深链/别处改参数都会跟着动)
|
|
484
|
+
* 设计器 —— 以 view.active 为准,这样第 2 页的部件也能被编辑(设计器不过滤 visibleWhen)
|
|
485
|
+
*/
|
|
486
|
+
navActiveValue() {
|
|
487
|
+
const v = this.part.view || {};
|
|
488
|
+
if (this.design) return v.active === void 0 || v.active === null ? "" : String(v.active);
|
|
489
|
+
const code = v.paramCode || "nav";
|
|
490
|
+
const cur = this.ctx().params.state.values[code];
|
|
491
|
+
return cur === void 0 || cur === null ? "" : String(cur);
|
|
492
|
+
},
|
|
493
|
+
/** 导航布局:间距与项尺寸走 CSS 变量,主题负责长相 */
|
|
494
|
+
navStyle() {
|
|
495
|
+
const v = this.part.view || {};
|
|
496
|
+
const st = textStyle(this.part, this.theme);
|
|
497
|
+
const base = { fontSize: st.fontSize, fontFamily: st.fontFamily, fontWeight: st.fontWeight, fontStyle: st.fontStyle, color: st.color, letterSpacing: st.letterSpacing, textShadow: st.textShadow };
|
|
498
|
+
if (this.navAbsolute) return base;
|
|
499
|
+
return {
|
|
500
|
+
...base,
|
|
501
|
+
gap: (v.gap === void 0 ? 8 : v.gap) + "px",
|
|
502
|
+
"--nav-item-w": (v.itemW || 120) + "px",
|
|
503
|
+
"--nav-item-h": (v.itemH || 34) + "px"
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
textOverflow() {
|
|
507
|
+
return this.part.view && this.part.view.overflow || "wrap";
|
|
508
|
+
},
|
|
509
|
+
/**
|
|
510
|
+
* 文本部件的内联样式:**必须把 textStyle(part, theme) 应用上**。
|
|
511
|
+
* 以前这里只返回"自动缩小字号"时的 fontSize,于是部件设置里的
|
|
512
|
+
* 字体/字号/加粗/斜体/颜色/对齐**全都没生效**(对齐是用户报的)。
|
|
513
|
+
* 另外 .part-text 是 flex(为了垂直居中),所以横向对齐除了 textAlign
|
|
514
|
+
* 还要给 justifyContent —— flex 容器里 text-align 管不到 flex item 的位置。
|
|
515
|
+
*/
|
|
516
|
+
textBoxStyle() {
|
|
517
|
+
const st = textStyle(this.part, this.theme);
|
|
518
|
+
if (this.textOverflow === "shrink" && this.shrunkFont) st.fontSize = this.shrunkFont + "px";
|
|
519
|
+
st.justifyContent = { left: "flex-start", center: "center", right: "flex-end" }[this.part.text && this.part.text.align || "left"] || "flex-start";
|
|
520
|
+
return st;
|
|
521
|
+
},
|
|
522
|
+
/**
|
|
523
|
+
* 看板参数版本号(必须走 computed)。
|
|
524
|
+
* ⚠️ 踩过的坑:直接写 `watch: { 'paramState.version'(){} }` 是无效的 ——
|
|
525
|
+
* Vue 的字符串路径是相对**组件实例**解析的,而 paramState 是 import 进来的模块对象,
|
|
526
|
+
* 解析不到 → 永远不触发(表现为"筛选点了没反应")。
|
|
527
|
+
*/
|
|
528
|
+
paramVersion() {
|
|
529
|
+
void this.paramsVersion;
|
|
530
|
+
return this.ctx().params.state.version;
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
watch: {
|
|
534
|
+
"part.data": {
|
|
535
|
+
deep: true,
|
|
536
|
+
handler() {
|
|
537
|
+
this.load();
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
refreshKey() {
|
|
541
|
+
this.load(true);
|
|
542
|
+
},
|
|
543
|
+
// 看板级参数变化 → 重新取数(筛选方案驱动)
|
|
544
|
+
paramVersion() {
|
|
545
|
+
this.load(true);
|
|
546
|
+
},
|
|
547
|
+
"part.view.autoplay"() {
|
|
548
|
+
this.startCarousel();
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
created() {
|
|
552
|
+
this.load().catch((error) => {
|
|
553
|
+
console.error("[design-board] PartRenderer 初始化失败:", error);
|
|
554
|
+
});
|
|
555
|
+
},
|
|
556
|
+
mounted() {
|
|
557
|
+
this.startCarousel();
|
|
558
|
+
this.fitText();
|
|
559
|
+
this.bindDatavResize();
|
|
560
|
+
},
|
|
561
|
+
updated() {
|
|
562
|
+
this.fitText();
|
|
563
|
+
this.bindDatavResize();
|
|
564
|
+
},
|
|
565
|
+
beforeUnmount() {
|
|
566
|
+
if (this.timer) clearInterval(this.timer);
|
|
567
|
+
this.unbindDatavResize();
|
|
568
|
+
},
|
|
569
|
+
methods: {
|
|
570
|
+
/** 上下文(缺失直接抛:静默降级只会让"数据不出来"更难查) */
|
|
571
|
+
ctx() {
|
|
572
|
+
if (!this.boardCtx) {
|
|
573
|
+
throw new Error("[design-board] PartRenderer 必须放在 <DesignBoard> 内部使用");
|
|
574
|
+
}
|
|
575
|
+
return this.boardCtx;
|
|
576
|
+
},
|
|
577
|
+
/**
|
|
578
|
+
* DataV 边框/装饰的**尺寸同步**(实测踩到的坑,2026-09)。
|
|
579
|
+
*
|
|
580
|
+
* DataV 的 "自适应" 其实很弱:`lib/util/observerDomResize` 是 **MutationObserver 盯自身 `style` 属性**,
|
|
581
|
+
* 外加一个 window resize 监听(`lib/mixin/autoResize.js`)。而我们的部件框是**父节点改宽高**
|
|
582
|
+
* (`.part` 的行内 width/height,DataV 根节点只有 class 给的 `inset:0`),它自己的 `style` 属性从头到尾不变
|
|
583
|
+
* ⇒ 拖拽缩放/栅格重排/移动端堆叠之后,边框**不重量**:实测部件宽从 340 改成 200,根节点跟到 200,
|
|
584
|
+
* 但 svg 的 width 还停在 340(边框和框错位,看起来像"边框画歪了")。
|
|
585
|
+
*
|
|
586
|
+
* 修法:自己挂一个真 ResizeObserver(和 BoardChart.vue 里图表用的是同一套做法),
|
|
587
|
+
* 尺寸变了就调 DataV 组件暴露出来的 `initWH(true)` 让它重新量一次(mixin 的方法,实例上直接可调)。
|
|
588
|
+
* 只在"量到的尺寸和它自己记的不一样"时调用,避免 svg 重画反过来触发观察器形成回环。
|
|
589
|
+
*/
|
|
590
|
+
bindDatavResize() {
|
|
591
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
592
|
+
const comp = this.$refs.datavFrame || this.$refs.datavDeco;
|
|
593
|
+
const el = comp && (comp.$el || comp);
|
|
594
|
+
if (!el || el === this.dvROEl) return;
|
|
595
|
+
this.unbindDatavResize();
|
|
596
|
+
this.dvROEl = el;
|
|
597
|
+
this.dvRO = new ResizeObserver(() => {
|
|
598
|
+
const c2 = this.$refs.datavFrame || this.$refs.datavDeco;
|
|
599
|
+
if (!c2 || typeof c2.initWH !== "function") return;
|
|
600
|
+
if (el.clientWidth === c2.width && el.clientHeight === c2.height) return;
|
|
601
|
+
c2.initWH(true);
|
|
602
|
+
});
|
|
603
|
+
this.dvRO.observe(el);
|
|
604
|
+
const c = this.$refs.datavFrame || this.$refs.datavDeco;
|
|
605
|
+
if (c && typeof c.initWH === "function" && (!c.width || !c.height)) c.initWH(true);
|
|
606
|
+
},
|
|
607
|
+
unbindDatavResize() {
|
|
608
|
+
if (this.dvRO) this.dvRO.disconnect();
|
|
609
|
+
this.dvRO = null;
|
|
610
|
+
this.dvROEl = null;
|
|
611
|
+
},
|
|
612
|
+
/** 配置里的 src → 可直接渲染的地址(asset: 引用在这里才拼成当前部署的 URL,见 engine/asset.js) */
|
|
613
|
+
mediaSrc(v) {
|
|
614
|
+
return boardAssetUrl(v);
|
|
615
|
+
},
|
|
616
|
+
/** 表格某列的宽度(colgroup 用)。⚠️ 必须放 methods:模板里是 colWidthOf(c) 这种调用写法,
|
|
617
|
+
放 computed 会变成"不是函数",渲染直接抛错 ⇒ DOM 停在旧状态(这个错我犯了两次,见文件里另一处注释)
|
|
618
|
+
优先用用户拖过的宽度;否则数值列给一个固定窄宽(数字本来就不长),**文本列给 auto**(平分剩余空间) */
|
|
619
|
+
colWidthOf(col) {
|
|
620
|
+
const w = (this.part.view.colWidths || {})[col.name];
|
|
621
|
+
if (w) return w + "px";
|
|
622
|
+
return this.isDim(col) ? "auto" : "110px";
|
|
623
|
+
},
|
|
624
|
+
async load(force) {
|
|
625
|
+
if (!this.part.data || !this.part.data.setId) {
|
|
626
|
+
this.result = null;
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.loading = true;
|
|
630
|
+
try {
|
|
631
|
+
this.result = await this.ctx().dataHub.fetch(this.part, force);
|
|
632
|
+
} finally {
|
|
633
|
+
this.loading = false;
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
isDim(col) {
|
|
637
|
+
return col.isDimension !== false;
|
|
638
|
+
},
|
|
639
|
+
// ---------------- 明细表:排序 / 列宽 / 合计 / 导出 CSV ----------------
|
|
640
|
+
thStyle(col) {
|
|
641
|
+
const w = (this.part.view.colWidths || {})[col.name];
|
|
642
|
+
const st = { textAlign: "center", position: "relative" };
|
|
643
|
+
if (w) {
|
|
644
|
+
st.width = w + "px";
|
|
645
|
+
st.minWidth = w + "px";
|
|
646
|
+
st.maxWidth = w + "px";
|
|
647
|
+
}
|
|
648
|
+
return st;
|
|
649
|
+
},
|
|
650
|
+
/** 点表头:切换排序字段/方向。写回部件配置(走服务端 orderField,大数据量也正确)并立刻重查 */
|
|
651
|
+
onSort(col) {
|
|
652
|
+
if (!this.isSortable) return;
|
|
653
|
+
const d = this.part.data || (this.part.data = {});
|
|
654
|
+
if (d.orderField === col.name) {
|
|
655
|
+
d.orderDesc = !d.orderDesc;
|
|
656
|
+
} else {
|
|
657
|
+
d.orderField = col.name;
|
|
658
|
+
d.orderDesc = true;
|
|
659
|
+
}
|
|
660
|
+
this.$emit("changed");
|
|
661
|
+
this.load(true);
|
|
662
|
+
},
|
|
663
|
+
/**
|
|
664
|
+
* 拖表头右边缘调列宽,存进部件配置(跟着看板一起保存)。
|
|
665
|
+
* ⚠️ 必须用 $set 整体替换 colWidths:colWidths 在老配置里通常还不存在,
|
|
666
|
+
* 直接 `view.colWidths = {...}` 是"加新 key",Vue 2 检测不到 ⇒ colgroup 不渲染、
|
|
667
|
+
* 表现成"拖了列宽但表格没反应"(我在这上面栽过)。
|
|
668
|
+
*/
|
|
669
|
+
startColResize(e, col) {
|
|
670
|
+
if (!this.isResizable) return;
|
|
671
|
+
const startX = e.clientX;
|
|
672
|
+
const th = e.target.parentElement;
|
|
673
|
+
const startW = th.getBoundingClientRect().width;
|
|
674
|
+
const move = (ev) => {
|
|
675
|
+
const w = Math.max(40, Math.round(startW + (ev.clientX - startX)));
|
|
676
|
+
this.part.view.colWidths = Object.assign({}, this.part.view.colWidths || {}, { [col.name]: w });
|
|
677
|
+
};
|
|
678
|
+
const up = () => {
|
|
679
|
+
document.removeEventListener("mousemove", move);
|
|
680
|
+
document.removeEventListener("mouseup", up);
|
|
681
|
+
this.$emit("changed");
|
|
682
|
+
};
|
|
683
|
+
document.addEventListener("mousemove", move);
|
|
684
|
+
document.addEventListener("mouseup", up);
|
|
685
|
+
},
|
|
686
|
+
/** 合计行:数字列求和,非数字列留空 */
|
|
687
|
+
summaryValue(col) {
|
|
688
|
+
let sum = 0;
|
|
689
|
+
let hit = false;
|
|
690
|
+
this.tableRows.forEach((r) => {
|
|
691
|
+
const k = Object.keys(r).find((x) => x.toLowerCase() === String(col.name).toLowerCase());
|
|
692
|
+
if (!k) return;
|
|
693
|
+
const n = Number(r[k]);
|
|
694
|
+
if (Number.isNaN(n)) return;
|
|
695
|
+
sum += n;
|
|
696
|
+
hit = true;
|
|
697
|
+
});
|
|
698
|
+
return hit ? sum : null;
|
|
699
|
+
},
|
|
700
|
+
summaryText(col) {
|
|
701
|
+
const v = this.summaryValue(col);
|
|
702
|
+
if (v === null) return "";
|
|
703
|
+
return col.numFormat ? fmtNum(v, col.numFormat) : String(Math.round(v * 100) / 100);
|
|
704
|
+
},
|
|
705
|
+
/** 导出当前渲染的数据为 CSV(带 BOM,Excel 打开不乱码) */
|
|
706
|
+
exportCsv() {
|
|
707
|
+
const isCross = this.part.type === "crossTable";
|
|
708
|
+
const lines = [];
|
|
709
|
+
if (isCross) {
|
|
710
|
+
const head = [this.crossView.rowKey];
|
|
711
|
+
if (this.crossMeasures.length) {
|
|
712
|
+
this.crossView.cols.forEach((c) => this.crossMeasures.forEach((m) => head.push(c + "/" + m.name)));
|
|
713
|
+
} else {
|
|
714
|
+
this.crossView.cols.forEach((c) => head.push(c));
|
|
715
|
+
}
|
|
716
|
+
if (this.part.view.showTotal !== false) head.push("合计");
|
|
717
|
+
lines.push(head);
|
|
718
|
+
this.crossView.bodyRows.forEach((r) => {
|
|
719
|
+
const row = [r.name].concat(r.cells);
|
|
720
|
+
if (this.part.view.showTotal !== false) row.push(r.total);
|
|
721
|
+
lines.push(row);
|
|
722
|
+
});
|
|
723
|
+
} else {
|
|
724
|
+
lines.push(this.tableColumns.map((c) => c.label || c.alias || c.name));
|
|
725
|
+
this.tableRows.forEach((r) => lines.push(this.tableColumns.map((c) => this.cellText(r, c))));
|
|
726
|
+
if (this.showSummary) lines.push(this.tableColumns.map((c, i) => i === 0 ? "合计" : this.summaryText(c)));
|
|
727
|
+
}
|
|
728
|
+
const csv = lines.map((row) => row.map((v) => {
|
|
729
|
+
const s = v === null || v === void 0 ? "" : String(v);
|
|
730
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
731
|
+
}).join(",")).join("\r\n");
|
|
732
|
+
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
|
|
733
|
+
const a = document.createElement("a");
|
|
734
|
+
a.href = URL.createObjectURL(blob);
|
|
735
|
+
a.download = (this.part.name || "data") + ".csv";
|
|
736
|
+
document.body.appendChild(a);
|
|
737
|
+
a.click();
|
|
738
|
+
document.body.removeChild(a);
|
|
739
|
+
URL.revokeObjectURL(a.href);
|
|
740
|
+
this.$emit("exported", this.part.name);
|
|
741
|
+
},
|
|
742
|
+
// ---------------- 下钻:把维度值写进看板参数,引用该参数的部件一起刷新 ----------------
|
|
743
|
+
onDrill(field, value) {
|
|
744
|
+
if (!field || value === void 0 || value === null || value === "") return;
|
|
745
|
+
if (!(this.part.view && this.part.view.drillDown)) return;
|
|
746
|
+
this.ctx().params.set(field, String(value));
|
|
747
|
+
this.$emit("drilled", { field, value: String(value) });
|
|
748
|
+
},
|
|
749
|
+
onCellClick(row, col) {
|
|
750
|
+
if (!this.isDim(col)) return;
|
|
751
|
+
const k = Object.keys(row).find((x) => x.toLowerCase() === String(col.name).toLowerCase());
|
|
752
|
+
this.onDrill(col.name, k ? row[k] : null);
|
|
753
|
+
},
|
|
754
|
+
/** 图表点击下钻:类目名就是维度值(饼图用 name,直角坐标系用类目名) */
|
|
755
|
+
onChartClick(p) {
|
|
756
|
+
if (!(this.part.view && this.part.view.drillDown)) return;
|
|
757
|
+
const dim = this.part.data && this.part.data.dims && this.part.data.dims[0] || "";
|
|
758
|
+
if (!dim || !p) return;
|
|
759
|
+
let v = p.name;
|
|
760
|
+
if (v === void 0 || v === null || v === "") v = p.data && p.data.name;
|
|
761
|
+
this.onDrill(dim, v);
|
|
762
|
+
},
|
|
763
|
+
cellText(row, col) {
|
|
764
|
+
const hit = Object.keys(row).find((k) => k.toLowerCase() === String(col.name).toLowerCase());
|
|
765
|
+
const v = hit ? row[hit] : null;
|
|
766
|
+
if (v === null || v === void 0) return "";
|
|
767
|
+
const n = Number(v);
|
|
768
|
+
if (!Number.isNaN(n) && col.numFormat) return fmtNum(n, col.numFormat);
|
|
769
|
+
return String(v);
|
|
770
|
+
},
|
|
771
|
+
measureText(index) {
|
|
772
|
+
if (!this.hasData) return "--";
|
|
773
|
+
const m = (this.part.data.measures || [])[0];
|
|
774
|
+
if (!m) return "--";
|
|
775
|
+
const key = m.alias || m.field;
|
|
776
|
+
const row = this.result.rows[index || 0];
|
|
777
|
+
const hit = Object.keys(row).find((k) => k.toLowerCase() === String(key).toLowerCase());
|
|
778
|
+
const raw = hit ? row[hit] : null;
|
|
779
|
+
const col = (this.result.columns || []).find((c) => c.name === key);
|
|
780
|
+
const n = Number(raw);
|
|
781
|
+
if (Number.isNaN(n)) return raw === null || raw === void 0 ? "--" : String(raw);
|
|
782
|
+
return fmtNum(n, col && col.numFormat || this.decimalFormat);
|
|
783
|
+
},
|
|
784
|
+
fmt(v) {
|
|
785
|
+
if (v === null || v === void 0 || v === "") return "";
|
|
786
|
+
return fmtNum(Number(v), "");
|
|
787
|
+
},
|
|
788
|
+
startCarousel() {
|
|
789
|
+
if (this.timer) clearInterval(this.timer);
|
|
790
|
+
this.timer = null;
|
|
791
|
+
const v = this.part.view || {};
|
|
792
|
+
if (this.part.type !== "carousel" || v.autoplay === false || this.carouselImages.length < 2) return;
|
|
793
|
+
const ms = Math.max(1, Number(v.interval || 3)) * 1e3;
|
|
794
|
+
this.timer = setInterval(() => {
|
|
795
|
+
this.carouselIndex = (this.carouselIndex + 1) % this.carouselImages.length;
|
|
796
|
+
}, ms);
|
|
797
|
+
},
|
|
798
|
+
onButtonClick() {
|
|
799
|
+
const href = this.part.view && this.part.view.href;
|
|
800
|
+
if (href) window.open(href, this.part.view.target || "_blank");
|
|
801
|
+
else this.$emit("action", "button-click", this.part);
|
|
802
|
+
},
|
|
803
|
+
isNavActive(item) {
|
|
804
|
+
const cur = this.navActiveValue;
|
|
805
|
+
if (!cur) return false;
|
|
806
|
+
return cur === item.value || !!item.code && cur === String(item.code);
|
|
807
|
+
},
|
|
808
|
+
/**
|
|
809
|
+
* 导航点击:
|
|
810
|
+
* mode=param(默认,主模型)—— 只写看板参数 ⇒ 各部件按 view.visibleWhen 显隐、并按参数重新取数
|
|
811
|
+
* mode=code —— 跳另一个看板(同窗)
|
|
812
|
+
* mode=both —— 先写参数再跳(目标看板可带同一参数继续用)
|
|
813
|
+
* item.reset:切页时顺带清掉的业务参数(如 ['ageRange'])——导航参数本身不清。
|
|
814
|
+
*/
|
|
815
|
+
/** 绝对布局下:项的坐标以**部件盒子左上角**为原点(设计稿坐标 - 部件 x/y 由调用方算好)
|
|
816
|
+
* ⚠ 必须放 methods:Vue 2 的 computed 里定义成函数会被当 getter,`vm.navItemStyle(it)`
|
|
817
|
+
* 会报 "is not a function" → 整个 nav 部件的渲染直接抛错,DOM 里只剩一个空注释。 */
|
|
818
|
+
navItemStyle(it) {
|
|
819
|
+
if (!this.navAbsolute) return null;
|
|
820
|
+
const s = {
|
|
821
|
+
left: (it.x || 0) + "px",
|
|
822
|
+
top: (it.y || 0) + "px"
|
|
823
|
+
};
|
|
824
|
+
if (it.w) s.width = it.w + "px";
|
|
825
|
+
if (it.h) s.height = it.h + "px";
|
|
826
|
+
if (it.align) s.justifyContent = it.align;
|
|
827
|
+
return s;
|
|
828
|
+
},
|
|
829
|
+
onNavClick(item) {
|
|
830
|
+
const v = this.part.view || {};
|
|
831
|
+
const mode = v.mode || "param";
|
|
832
|
+
if (mode === "param" || mode === "both") {
|
|
833
|
+
this.ctx().params.set(v.paramCode || "nav", item.value);
|
|
834
|
+
if (Array.isArray(item.reset)) item.reset.forEach((c) => c && this.ctx().params.set(c, ""));
|
|
835
|
+
}
|
|
836
|
+
if ((mode === "code" || mode === "both") && item.code) {
|
|
837
|
+
window.open(`${location.pathname}?type=board&action=view&code=${encodeURIComponent(item.code)}`, "_self");
|
|
838
|
+
}
|
|
839
|
+
this.$emit("action", "nav-click", { part: this.part, item });
|
|
840
|
+
},
|
|
841
|
+
onFilterChange(val) {
|
|
842
|
+
const code = this.part.view && this.part.view.paramCode;
|
|
843
|
+
if (!code) return;
|
|
844
|
+
this.ctx().params.set(code, val);
|
|
845
|
+
},
|
|
846
|
+
/**
|
|
847
|
+
* 文本"自动缩小字号":从设定字号起逐级降,直到内容放得下(或到下限 10px)。
|
|
848
|
+
* 为什么要这么做:绝对/栅格布局里文本高度是给定的,而多语言(英文比中文长)和长文案
|
|
849
|
+
* 会让文字溢出被静默裁掉。缩小字号是比裁切更可接受的降级。
|
|
850
|
+
*
|
|
851
|
+
* ⚠️ 两个必须遵守的点(否则会死循环/结果错):
|
|
852
|
+
* 1) 每轮先把字号写到内层元素上再测量 —— 直接读会被"上一次渲染"的字号干扰;
|
|
853
|
+
* 2) 只有结果真的变了才写 shrunkFont —— 无条件先赋值 base 会让
|
|
854
|
+
* updated() → fitText() → 改数据 → updated() 无限来回(base ⇄ 缩小值)。
|
|
855
|
+
*/
|
|
856
|
+
fitText() {
|
|
857
|
+
if (this.part.type !== "text" || this.textOverflow !== "shrink") {
|
|
858
|
+
if (this.shrunkFont) this.shrunkFont = 0;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
this.$nextTick(() => {
|
|
862
|
+
const box = this.$el;
|
|
863
|
+
const inner = this.$refs.textInner;
|
|
864
|
+
if (!box || !inner) return;
|
|
865
|
+
const base = this.part.text && this.part.text.fontSize || 14;
|
|
866
|
+
let size = base;
|
|
867
|
+
for (let i = 0; i < 20 && size > 10; i++) {
|
|
868
|
+
inner.style.fontSize = size + "px";
|
|
869
|
+
if (inner.scrollHeight <= box.clientHeight && inner.scrollWidth <= box.clientWidth) break;
|
|
870
|
+
size -= 1;
|
|
871
|
+
}
|
|
872
|
+
inner.style.fontSize = "";
|
|
873
|
+
if (this.shrunkFont !== size) this.shrunkFont = size;
|
|
874
|
+
});
|
|
875
|
+
},
|
|
876
|
+
/** 全部生效参数(设计器/子组件用) */
|
|
877
|
+
allParams() {
|
|
878
|
+
return this.ctx().params.all();
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
export {
|
|
883
|
+
_sfc_main as default
|
|
884
|
+
};
|