@veltra/sheet 2.3.3 → 2.4.0
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/dist/components/report/binding-badge.js +23 -5
- package/dist/components/report/designer/drill-dialog.js +258 -0
- package/dist/components/report/designer/float-panel.js +26 -14
- package/dist/components/report/print.js +39 -0
- package/dist/components/report/report-designer.js +56 -21
- package/dist/components/report/report-viewer.js +173 -44
- package/dist/components/report/style2.css +91 -0
- package/dist/components/report/use-report-designer.js +2 -1
- package/dist/components/report/use-report-viewer.js +88 -3
- package/dist/components/sheet/insert-image.js +1 -1
- package/dist/components/sheet/popups/import.worker.js +2 -3
- package/dist/components/sheet/use-sheet-state.js +2 -2
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -2
- package/dist/report/drill.d.ts +48 -0
- package/dist/report/drill.js +78 -0
- package/dist/report/export-xlsx.d.ts +3 -7
- package/dist/report/export-xlsx.js +6 -85
- package/dist/report/index.d.ts +5 -3
- package/dist/report/print.d.ts +23 -0
- package/dist/report/print.js +197 -0
- package/dist/report/render/aggregate.js +25 -1
- package/dist/report/types.d.ts +19 -1
- package/dist/tools/download.js +2 -2
- package/dist/types/report-designer.d.ts +13 -0
- package/dist/types/report-viewer.d.ts +19 -0
- package/package.json +6 -9
- package/src/components/report/__test__/binding-badge.test.ts +92 -0
- package/src/components/report/__test__/report-designer.test.ts +98 -3
- package/src/components/report/__test__/report-drill.test.ts +692 -0
- package/src/components/report/__test__/report-viewer.test.ts +43 -0
- package/src/components/report/__test__/use-report-designer.test.ts +108 -1
- package/src/components/report/binding-badge.ts +35 -5
- package/src/components/report/designer/drill-dialog.vue +245 -0
- package/src/components/report/designer/float-panel.vue +12 -0
- package/src/components/report/print.ts +44 -0
- package/src/components/report/report-designer.vue +35 -2
- package/src/components/report/report-viewer.vue +171 -18
- package/src/components/report/style-designer.scss +66 -0
- package/src/components/report/style-viewer.scss +40 -0
- package/src/components/report/use-report-designer.ts +8 -1
- package/src/components/report/use-report-viewer.ts +150 -11
- package/src/components/sheet/insert-image.ts +1 -1
- package/src/components/sheet/popups/export-popup.vue +1 -1
- package/src/components/sheet/popups/import.worker.ts +7 -10
- package/src/components/sheet/use-sheet-state.ts +2 -2
- package/src/report/__test__/drill.test.ts +194 -0
- package/src/report/__test__/export-xlsx.test.ts +3 -25
- package/src/report/__test__/print.test.ts +170 -0
- package/src/report/__test__/render.test.ts +26 -0
- package/src/report/drill.ts +112 -0
- package/src/report/export-xlsx.ts +5 -103
- package/src/report/index.ts +4 -1
- package/src/report/print.ts +234 -0
- package/src/report/render/aggregate.ts +28 -0
- package/src/report/types.ts +20 -0
- package/src/tools/__test__/builtin.test.ts +1 -1
- package/src/tools/download.ts +2 -2
- package/src/tools/export.worker.ts +2 -2
- package/src/types/report-designer.ts +18 -1
- package/src/types/report-viewer.ts +18 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { BORDER_SIDES } from "@veltra/sheet-core";
|
|
2
|
+
//#region src/report/print.ts
|
|
3
|
+
/**
|
|
4
|
+
* 默认列宽 / 行高(px):与 grid 层 vtable-theme 的
|
|
5
|
+
* SHEET_DEFAULT_COL_WIDTH / SHEET_DEFAULT_ROW_HEIGHT 对齐;
|
|
6
|
+
* report 内核保持 headless,不引 grid 入口。
|
|
7
|
+
*/
|
|
8
|
+
const PRINT_DEFAULT_COL_WIDTH = 80;
|
|
9
|
+
const PRINT_DEFAULT_ROW_HEIGHT = 28;
|
|
10
|
+
const IMAGE_MIME = {
|
|
11
|
+
png: "image/png",
|
|
12
|
+
jpeg: "image/jpeg",
|
|
13
|
+
gif: "image/gif",
|
|
14
|
+
svg: "image/svg+xml",
|
|
15
|
+
webp: "image/webp"
|
|
16
|
+
};
|
|
17
|
+
/** 模型边框线型 → CSS 线型(thin/medium/thick 为实线粗细分级,宽度由 edge.width 表达) */
|
|
18
|
+
const BORDER_CSS_LINE = {
|
|
19
|
+
thin: "solid",
|
|
20
|
+
medium: "solid",
|
|
21
|
+
thick: "solid",
|
|
22
|
+
dashed: "dashed",
|
|
23
|
+
dotted: "dotted"
|
|
24
|
+
};
|
|
25
|
+
function escapeHtml(text) {
|
|
26
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
27
|
+
}
|
|
28
|
+
function escapeAttr(text) {
|
|
29
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
30
|
+
}
|
|
31
|
+
/** Uint8Array → base64(分块避免栈溢出;btoa 在浏览器 / Node >=16 / Bun / happy-dom 均可用) */
|
|
32
|
+
function bytesToBase64(data) {
|
|
33
|
+
let binary = "";
|
|
34
|
+
const CHUNK = 32768;
|
|
35
|
+
for (let i = 0; i < data.length; i += CHUNK) binary += String.fromCharCode(...data.subarray(i, i + CHUNK));
|
|
36
|
+
return btoa(binary);
|
|
37
|
+
}
|
|
38
|
+
/** 模型有效样式 → td 内联 CSS(字号 pt 直出,与屏幕 px ×4/3 物理尺寸一致) */
|
|
39
|
+
function styleToCss(style) {
|
|
40
|
+
const decls = [];
|
|
41
|
+
if (style.fill?.color) decls.push(`background-color:${style.fill.color}`);
|
|
42
|
+
for (const side of BORDER_SIDES) {
|
|
43
|
+
const edge = style.border?.[side];
|
|
44
|
+
if (edge) decls.push(`border-${side}:${edge.width}px ${BORDER_CSS_LINE[edge.style]} ${edge.color}`);
|
|
45
|
+
}
|
|
46
|
+
const font = style.font;
|
|
47
|
+
if (font) {
|
|
48
|
+
if (font.color) decls.push(`color:${font.color}`);
|
|
49
|
+
if (font.bold) decls.push("font-weight:bold");
|
|
50
|
+
if (font.italic) decls.push("font-style:italic");
|
|
51
|
+
const decoration = [font.underline ? "underline" : "", font.strikethrough ? "line-through" : ""].filter(Boolean).join(" ");
|
|
52
|
+
if (decoration) decls.push(`text-decoration:${decoration}`);
|
|
53
|
+
if (typeof font.size === "number") decls.push(`font-size:${font.size}pt`);
|
|
54
|
+
}
|
|
55
|
+
const align = style.align;
|
|
56
|
+
if (align) {
|
|
57
|
+
if (align.horizontal) decls.push(`text-align:${align.horizontal}`);
|
|
58
|
+
if (align.vertical) decls.push(`vertical-align:${align.vertical}`);
|
|
59
|
+
if (align.wrap) decls.push("white-space:normal;word-break:break-all");
|
|
60
|
+
}
|
|
61
|
+
return decls.join(";");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 浮动图打印定位(镜像 grid image-layer `computeImageRect`):
|
|
65
|
+
* left/top = from 格左上 + 格内像素偏移;宽高优先 image.width/height(两者都有才用),
|
|
66
|
+
* 缺失且有 to 时按 from→to 跨度兜底,都缺失则省略尺寸(浏览器取自然尺寸)。
|
|
67
|
+
*/
|
|
68
|
+
function computePrintImageRect(image, colWidths, rowHeights) {
|
|
69
|
+
const { from, to } = image.anchor;
|
|
70
|
+
let left = from.offsetX ?? 0;
|
|
71
|
+
for (let c = 0; c < from.col; c++) left += colWidths[c] ?? PRINT_DEFAULT_COL_WIDTH;
|
|
72
|
+
let top = from.offsetY ?? 0;
|
|
73
|
+
for (let r = 0; r < from.row; r++) top += rowHeights[r] ?? PRINT_DEFAULT_ROW_HEIGHT;
|
|
74
|
+
if (image.width != null && image.height != null) return {
|
|
75
|
+
left,
|
|
76
|
+
top,
|
|
77
|
+
width: image.width,
|
|
78
|
+
height: image.height
|
|
79
|
+
};
|
|
80
|
+
if (to) {
|
|
81
|
+
let width = 0;
|
|
82
|
+
for (let c = from.col; c <= to.col; c++) width += colWidths[c] ?? PRINT_DEFAULT_COL_WIDTH;
|
|
83
|
+
let height = 0;
|
|
84
|
+
for (let r = from.row; r <= to.row; r++) height += rowHeights[r] ?? PRINT_DEFAULT_ROW_HEIGHT;
|
|
85
|
+
return {
|
|
86
|
+
left,
|
|
87
|
+
top,
|
|
88
|
+
width,
|
|
89
|
+
height
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
left,
|
|
94
|
+
top
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 将已填充的 Sheet(Filled Report)构建为打印专用 HTML 文档(纯 TS、headless、无 DOM)。
|
|
99
|
+
*
|
|
100
|
+
* 路线说明:网格渲染层是 canvas + 虚拟滚动(视口外不绘制),无法直接 window.print;
|
|
101
|
+
* 这里从模型单一事实源(与 exportFilledReportXlsx 同一读取路径)重建 `<table>`——
|
|
102
|
+
* 矢量文字清晰、浏览器原生分页、合并格天然映射 rowspan/colspan。
|
|
103
|
+
*
|
|
104
|
+
* 保真范围:值 / 合并 / 有效样式(fill + border + font + align/wrap,条件样式已在
|
|
105
|
+
* 展开阶段打平进 StylePool)/ 行高 / 列宽 / 浮动图(data URL 内嵌,跨页被裁剪属预期)。
|
|
106
|
+
* 与 Excel 默认一致:不打印屏幕网格线(仅模型显式边框)。
|
|
107
|
+
*/
|
|
108
|
+
function buildFilledReportPrintHtml(sheet, options) {
|
|
109
|
+
const title = options?.title ?? sheet.name ?? "";
|
|
110
|
+
const orientation = options?.orientation ?? "portrait";
|
|
111
|
+
let maxRow = -1;
|
|
112
|
+
let maxCol = -1;
|
|
113
|
+
const grow = (row, col) => {
|
|
114
|
+
if (row > maxRow) maxRow = row;
|
|
115
|
+
if (col > maxCol) maxCol = col;
|
|
116
|
+
};
|
|
117
|
+
for (const [addr] of sheet.store.peekEntries()) grow(addr.row, addr.col);
|
|
118
|
+
for (const [row] of sheet.getRowHeights()) grow(row, 0);
|
|
119
|
+
for (const [col] of sheet.getColWidths()) grow(0, col);
|
|
120
|
+
const merges = sheet.merges.getMerges();
|
|
121
|
+
for (const range of merges) grow(range.end.row, range.end.col);
|
|
122
|
+
const images = sheet.getImages();
|
|
123
|
+
for (const image of images) {
|
|
124
|
+
grow(image.anchor.from.row, image.anchor.from.col);
|
|
125
|
+
if (image.anchor.to) grow(image.anchor.to.row, image.anchor.to.col);
|
|
126
|
+
}
|
|
127
|
+
const colWidths = [];
|
|
128
|
+
for (let c = 0; c <= maxCol; c++) colWidths.push(sheet.getColWidth(c) ?? PRINT_DEFAULT_COL_WIDTH);
|
|
129
|
+
const rowHeights = [];
|
|
130
|
+
for (let r = 0; r <= maxRow; r++) rowHeights.push(sheet.getRowHeight(r) ?? PRINT_DEFAULT_ROW_HEIGHT);
|
|
131
|
+
const spanByAnchor = /* @__PURE__ */ new Map();
|
|
132
|
+
const covered = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const range of merges) {
|
|
134
|
+
const rowSpan = range.end.row - range.start.row + 1;
|
|
135
|
+
const colSpan = range.end.col - range.start.col + 1;
|
|
136
|
+
spanByAnchor.set(`${range.start.row},${range.start.col}`, {
|
|
137
|
+
rowSpan,
|
|
138
|
+
colSpan
|
|
139
|
+
});
|
|
140
|
+
for (let r = range.start.row; r <= range.end.row; r++) for (let c = range.start.col; c <= range.end.col; c++) if (r !== range.start.row || c !== range.start.col) covered.add(`${r},${c}`);
|
|
141
|
+
}
|
|
142
|
+
const css = [
|
|
143
|
+
`@page { size: A4 ${orientation}; margin: 12mm; }`,
|
|
144
|
+
"html, body { margin: 0; padding: 0; }",
|
|
145
|
+
"body { font-family: Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; font-size: 12px; color: #1f2329; -webkit-print-color-adjust: exact; print-color-adjust: exact; }",
|
|
146
|
+
".sheet-print-root { position: relative; }",
|
|
147
|
+
"table { border-collapse: collapse; table-layout: fixed; }",
|
|
148
|
+
"tr { break-inside: avoid; }",
|
|
149
|
+
"td { box-sizing: border-box; padding: 2px 6px; overflow: hidden; white-space: nowrap; vertical-align: middle; text-align: left; }",
|
|
150
|
+
".sheet-print-root img { position: absolute; }"
|
|
151
|
+
].join("\n");
|
|
152
|
+
const parts = [];
|
|
153
|
+
parts.push("<!doctype html><html><head><meta charset=\"utf-8\">");
|
|
154
|
+
parts.push(`<title>${escapeHtml(title || "报表")}</title>`);
|
|
155
|
+
parts.push(`<style>\n${css}\n</style></head><body>`);
|
|
156
|
+
parts.push("<div class=\"sheet-print-root\">");
|
|
157
|
+
parts.push("<table>");
|
|
158
|
+
if (colWidths.length > 0) {
|
|
159
|
+
parts.push("<colgroup>");
|
|
160
|
+
for (const width of colWidths) parts.push(`<col style="width:${width}px">`);
|
|
161
|
+
parts.push("</colgroup>");
|
|
162
|
+
}
|
|
163
|
+
for (let r = 0; r <= maxRow; r++) {
|
|
164
|
+
parts.push(`<tr style="height:${rowHeights[r]}px">`);
|
|
165
|
+
for (let c = 0; c <= maxCol; c++) {
|
|
166
|
+
if (covered.has(`${r},${c}`)) continue;
|
|
167
|
+
const attrs = [];
|
|
168
|
+
const span = spanByAnchor.get(`${r},${c}`);
|
|
169
|
+
if (span && span.colSpan > 1) attrs.push(`colspan="${span.colSpan}"`);
|
|
170
|
+
if (span && span.rowSpan > 1) attrs.push(`rowspan="${span.rowSpan}"`);
|
|
171
|
+
const style = sheet.getEffectiveStyle({
|
|
172
|
+
row: r,
|
|
173
|
+
col: c
|
|
174
|
+
});
|
|
175
|
+
const cellCss = style ? styleToCss(style) : "";
|
|
176
|
+
if (cellCss) attrs.push(`style="${escapeAttr(cellCss)}"`);
|
|
177
|
+
const value = sheet.getDisplayValue({
|
|
178
|
+
row: r,
|
|
179
|
+
col: c
|
|
180
|
+
});
|
|
181
|
+
const text = value == null ? "" : escapeHtml(String(value));
|
|
182
|
+
parts.push(`<td${attrs.length > 0 ? ` ${attrs.join(" ")}` : ""}>${text}</td>`);
|
|
183
|
+
}
|
|
184
|
+
parts.push("</tr>");
|
|
185
|
+
}
|
|
186
|
+
parts.push("</table>");
|
|
187
|
+
for (const image of images) {
|
|
188
|
+
const rect = computePrintImageRect(image, colWidths, rowHeights);
|
|
189
|
+
const size = rect.width != null && rect.height != null ? `width:${rect.width}px;height:${rect.height}px;` : "";
|
|
190
|
+
const src = `data:${IMAGE_MIME[image.type]};base64,${bytesToBase64(image.data)}`;
|
|
191
|
+
parts.push(`<img src="${src}" style="left:${rect.left}px;top:${rect.top}px;${size}" alt="${escapeAttr(image.altText ?? "")}">`);
|
|
192
|
+
}
|
|
193
|
+
parts.push("</div></body></html>");
|
|
194
|
+
return parts.join("");
|
|
195
|
+
}
|
|
196
|
+
//#endregion
|
|
197
|
+
export { buildFilledReportPrintHtml };
|
|
@@ -165,5 +165,29 @@ function resolvePlacementRecord(placement, index, data, aggregateIndex) {
|
|
|
165
165
|
const filter = resolveAncestorFilters(binding, placement, index, data);
|
|
166
166
|
return aggregateIndex.rowsFor(binding.dataset, filter)[placement.listIndex];
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* 解析落点的上下文记录(下钻字段映射用):
|
|
170
|
+
* `list` 明细取源数据行;分组/汇总格取祖先分组过滤值合成的上下文
|
|
171
|
+
* (分组格含自身分组值,如地区分组格 → `{ region: '华东' }`;
|
|
172
|
+
* 汇总聚合格如 sum/avg/count/max/min 也将本格聚合计算值并入上下文,如 `{ region: '华东', total: 300 }`)。
|
|
173
|
+
*/
|
|
174
|
+
function resolvePlacementContext(placement, index, data, aggregateIndex) {
|
|
175
|
+
const binding = placement.binding;
|
|
176
|
+
if (!binding) return void 0;
|
|
177
|
+
if (binding.aggregate === "list") return resolvePlacementRecord(placement, index, data, aggregateIndex);
|
|
178
|
+
const filter = resolveAncestorFilters(binding, placement, index, data);
|
|
179
|
+
if (binding.aggregate === "group") {
|
|
180
|
+
const value = filter[binding.field] ?? resolveOwnGroupValue(binding, placement, index, data);
|
|
181
|
+
return value === void 0 ? filter : {
|
|
182
|
+
...filter,
|
|
183
|
+
[binding.field]: value
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const value = resolvePlacementValue(placement, index, data, aggregateIndex);
|
|
187
|
+
return value === void 0 ? filter : {
|
|
188
|
+
...filter,
|
|
189
|
+
[binding.field]: value
|
|
190
|
+
};
|
|
191
|
+
}
|
|
168
192
|
//#endregion
|
|
169
|
-
export { AggregateIndex, aggregateField, resolvePlacementRecord, resolvePlacementValue };
|
|
193
|
+
export { AggregateIndex, aggregateField, resolvePlacementContext, resolvePlacementRecord, resolvePlacementValue };
|
package/dist/report/types.d.ts
CHANGED
|
@@ -24,6 +24,22 @@ interface ConditionalRule {
|
|
|
24
24
|
/** 作用范围;缺省 `'cell'` */
|
|
25
25
|
scope?: 'cell' | 'row';
|
|
26
26
|
}
|
|
27
|
+
/** 绑定格下钻配置(存于 Cell Meta namespace `report`,随绑定序列化;旧查看器忽略该字段) */
|
|
28
|
+
interface ReportDrillConfig {
|
|
29
|
+
/** 目标模板引用(宿主模板列表项的 ref,不支持手填) */
|
|
30
|
+
target: string;
|
|
31
|
+
/** 字段 → 详情报参数映射:源记录字段名 → 目标报表 Filter Bar 参数 id */
|
|
32
|
+
mapping: Record<string, string>;
|
|
33
|
+
/** 打开方式:查看器内切换 | UDialog 弹框 */
|
|
34
|
+
openMode: 'switch' | 'dialog';
|
|
35
|
+
}
|
|
36
|
+
/** 宿主提供的可下钻模板列表项(设计器下拉与查看器 resolveTemplate 契约共用) */
|
|
37
|
+
interface ReportTemplateListItem {
|
|
38
|
+
/** 模板引用,作为下钻 target 并传给宿主 resolveTemplate */
|
|
39
|
+
ref: string;
|
|
40
|
+
/** 可读名,用于设计器下拉展示 */
|
|
41
|
+
label: string;
|
|
42
|
+
}
|
|
27
43
|
/** 设计格上的报表绑定(存于 Cell Meta namespace `report`) */
|
|
28
44
|
interface ReportBinding {
|
|
29
45
|
dataset: string;
|
|
@@ -42,6 +58,8 @@ interface ReportBinding {
|
|
|
42
58
|
conditionalRules?: ConditionalRule[];
|
|
43
59
|
/** 设计器预设;引擎不读 */
|
|
44
60
|
preset?: ReportPreset;
|
|
61
|
+
/** 下钻配置;缺省不可下钻 */
|
|
62
|
+
drill?: ReportDrillConfig;
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
65
|
* 字段 schema(来自数据集配置,非运行时推断)。
|
|
@@ -82,4 +100,4 @@ interface QueryParamDef {
|
|
|
82
100
|
/** 运行时参数值 */
|
|
83
101
|
type ParamValues = Record<string, unknown>;
|
|
84
102
|
//#endregion
|
|
85
|
-
export { ConditionalOperator, ConditionalRule, DatasetCatalogItem, DatasetField, DatasetRecords, ParamValues, QueryParamDef, QueryParamOption, QueryParamType, ReportAggregate, ReportBinding, ReportExpand, ReportPreset, ReportSort };
|
|
103
|
+
export { ConditionalOperator, ConditionalRule, DatasetCatalogItem, DatasetField, DatasetRecords, ParamValues, QueryParamDef, QueryParamOption, QueryParamType, ReportAggregate, ReportBinding, ReportDrillConfig, ReportExpand, ReportPreset, ReportSort, ReportTemplateListItem };
|
package/dist/tools/download.js
CHANGED
|
@@ -2,7 +2,7 @@ import { saveBlob } from "@cat-kit/fe";
|
|
|
2
2
|
import { exportSheetCsv, exportWorkbookXlsx } from "@veltra/sheet-core/core/io/export";
|
|
3
3
|
//#region src/tools/download.ts
|
|
4
4
|
/**
|
|
5
|
-
* 大工作簿的 XLSX 导出(
|
|
5
|
+
* 大工作簿的 XLSX 导出(sheet-core 序列化 + ZIP 压缩为秒级同步重活)移入 Web Worker:
|
|
6
6
|
* 主线程只采集快照(结构化克隆友好的纯数据,行高随 SheetSnapshot.rowHeights
|
|
7
7
|
* 携带),序列化与压缩在 worker 线程进行。worker 不可用(极端环境 / 打包产物
|
|
8
8
|
* 缺文件)回退主线程导出——与导入侧 parseXlsxAsync 同一模式。
|
|
@@ -48,7 +48,7 @@ async function exportWorkbookXlsxAsync(workbook) {
|
|
|
48
48
|
/**
|
|
49
49
|
* 导出当前工作簿为 .xlsx(无 workbook 时空操作)。
|
|
50
50
|
* 下载动作复用 @cat-kit/fe 的 saveBlob(#28:不再自研 Object URL + a.click)。
|
|
51
|
-
*
|
|
51
|
+
* 导出侧 sheet-core 的 writeXlsx 会校验 sheet 名(Excel 非法字符 / 超长 / 保留名)并抛
|
|
52
52
|
* InvalidArgumentError——失败经返回的 Promise 传播,调用方(导出面板)负责提示。
|
|
53
53
|
*/
|
|
54
54
|
async function exportWorkbookFile(ctx) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { ReportTemplateListItem } from "../report/types.js";
|
|
1
2
|
import { DataConnection, DataConnector } from "../report/connector.js";
|
|
2
3
|
import { ReportTemplate } from "../report/template.js";
|
|
4
|
+
import { ResolveReportTemplate } from "../report/drill.js";
|
|
3
5
|
import "../report/index.js";
|
|
4
6
|
import { DeconstructValue } from "@veltra/utils";
|
|
5
7
|
import { Workbook } from "@veltra/sheet-core/core/workbook";
|
|
@@ -20,6 +22,17 @@ interface ReportDesignerProps {
|
|
|
20
22
|
template?: ReportTemplate;
|
|
21
23
|
/** 承载设计态的工作簿(USheet 先例:缺省内部自建单 sheet 工作簿) */
|
|
22
24
|
workbook?: Workbook;
|
|
25
|
+
/**
|
|
26
|
+
* 宿主提供的可下钻目标模板列表(ref + 可读名):
|
|
27
|
+
* 传入后绑定格 Action Pill 出现下钻配置入口(目标仅从列表选择,不支持手填引用);不传则无入口
|
|
28
|
+
*/
|
|
29
|
+
drillTemplates?: ReportTemplateListItem[];
|
|
30
|
+
/**
|
|
31
|
+
* 宿主模板解析契约(按下钻 target 引用取回目标模板,失败抛错):
|
|
32
|
+
* 下钻配置对话框据此解析目标模板的查询参数(字段→参数映射);
|
|
33
|
+
* 预览模式原样转发给内嵌 UReportViewer,使预览内可下钻(含 switch / dialog)与逐级回退
|
|
34
|
+
*/
|
|
35
|
+
resolveTemplate?: ResolveReportTemplate;
|
|
23
36
|
}
|
|
24
37
|
/** 报表设计器组件定义的事件 */
|
|
25
38
|
interface ReportDesignerEmits {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { ParamValues } from "../report/types.js";
|
|
1
2
|
import { ReportColWidthEntry } from "../report/export-xlsx.js";
|
|
2
3
|
import { DataConnector } from "../report/connector.js";
|
|
3
4
|
import { ReportTemplate } from "../report/template.js";
|
|
5
|
+
import { ResolveReportTemplate } from "../report/drill.js";
|
|
6
|
+
import { ReportPrintOptions } from "../report/print.js";
|
|
4
7
|
import "../report/index.js";
|
|
5
8
|
import { DeconstructValue } from "@veltra/utils";
|
|
6
9
|
import { Workbook } from "@veltra/sheet-core/core/workbook";
|
|
@@ -21,6 +24,17 @@ interface ReportViewerProps {
|
|
|
21
24
|
* 列宽已进 `SheetSnapshot.colWidths`,通常无需再传。
|
|
22
25
|
*/
|
|
23
26
|
colWidths?: ReadonlyArray<ReportColWidthEntry>;
|
|
27
|
+
/**
|
|
28
|
+
* 下钻目标模板解析契约(可选,内核共享类型):单击配了下钻的绑定格时按 `target` 引用取目标模板。
|
|
29
|
+
* 解析失败(抛错 / reject,或宿主实现返回空值)时查看器给出可读提示并停留当前报。
|
|
30
|
+
* 缺省时下钻不生效(无点击行为与可点视觉提示)。
|
|
31
|
+
*/
|
|
32
|
+
resolveTemplate?: ResolveReportTemplate;
|
|
33
|
+
/**
|
|
34
|
+
* Filter Bar 初值(可选):与当前模板参数默认值合并,显式键覆盖默认值。
|
|
35
|
+
* 仅在根模板载入时播种(下钻弹框传入映射参数);缺省则只用参数默认值。
|
|
36
|
+
*/
|
|
37
|
+
initialValues?: ParamValues;
|
|
24
38
|
}
|
|
25
39
|
/** 在组件内部引用 */
|
|
26
40
|
interface _ReportViewerExposed {
|
|
@@ -31,6 +45,11 @@ interface _ReportViewerExposed {
|
|
|
31
45
|
* 不内置导出按钮,工具栏由下游决定。
|
|
32
46
|
*/
|
|
33
47
|
exportXlsx: () => Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* 打印填充报表(纯前端:模型 → 打印 HTML → 隐藏 iframe 调起浏览器打印;
|
|
50
|
+
* 取数完成前拒绝)。不内置打印按钮,工具栏由下游决定。
|
|
51
|
+
*/
|
|
52
|
+
print: (options?: ReportPrintOptions) => void;
|
|
34
53
|
}
|
|
35
54
|
/** 报表查看器组件暴露的方法 */
|
|
36
55
|
type ReportViewerExposed = DeconstructValue<_ReportViewerExposed>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@veltra/sheet",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"homepage": "https://github.com/cabinet-fe/ultra-ui#readme",
|
|
5
5
|
"bugs": {
|
|
6
6
|
"url": "https://github.com/cabinet-fe/ultra-ui/issues"
|
|
@@ -33,18 +33,15 @@
|
|
|
33
33
|
"import": "./dist/*"
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"hucre": "^1.1.0"
|
|
38
|
-
},
|
|
39
36
|
"peerDependencies": {
|
|
40
37
|
"@cat-kit/core": ">=1.1.8",
|
|
41
38
|
"@cat-kit/fe": ">=1.1.8",
|
|
42
|
-
"@veltra/compositions": "^1.6.
|
|
43
|
-
"@veltra/desktop": "^1.6.
|
|
39
|
+
"@veltra/compositions": "^1.6.5",
|
|
40
|
+
"@veltra/desktop": "^1.6.5",
|
|
44
41
|
"@veltra/icons": "^1.5.0",
|
|
45
|
-
"@veltra/sheet-core": "^2.
|
|
46
|
-
"@veltra/styles": "^1.6.
|
|
47
|
-
"@veltra/utils": "^1.6.
|
|
42
|
+
"@veltra/sheet-core": "^2.4.0",
|
|
43
|
+
"@veltra/styles": "^1.6.5",
|
|
44
|
+
"@veltra/utils": "^1.6.5",
|
|
48
45
|
"vue": ">=3.5.41"
|
|
49
46
|
}
|
|
50
47
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Sheet } from '@veltra/sheet-core'
|
|
2
|
+
import type { CellAddress } from '@veltra/sheet-core'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { REPORT_META_NAMESPACE, createReportBinding } from '../../../report/binding'
|
|
6
|
+
import type { DatasetCatalogItem, ReportBinding } from '../../../report/types'
|
|
7
|
+
import { badgeLayoutProps, createBindingBadgeRenderer } from '../binding-badge'
|
|
8
|
+
|
|
9
|
+
// ---- 内联 fixtures ----
|
|
10
|
+
|
|
11
|
+
const ORDERS_DATASET: DatasetCatalogItem = {
|
|
12
|
+
id: 'orders',
|
|
13
|
+
label: '销售明细',
|
|
14
|
+
fields: [
|
|
15
|
+
{ name: 'customer', label: '客户', type: 'string' },
|
|
16
|
+
{ name: 'amount', label: '金额', type: 'number' }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const ADDR = { row: 1, col: 3 }
|
|
21
|
+
|
|
22
|
+
function bindingMap(entries: Array<{ addr: CellAddress; binding: ReportBinding }>) {
|
|
23
|
+
const map = new Map<string, ReportBinding>()
|
|
24
|
+
for (const { addr, binding } of entries) map.set(`${addr.row},${addr.col}`, binding)
|
|
25
|
+
return (addr: CellAddress) => map.get(`${addr.row},${addr.col}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('badgeLayoutProps(单元格对齐 → 徽章容器 flex 映射)', () => {
|
|
29
|
+
it('缺省对齐 → 主轴起点 + 交叉轴居中(向后兼容)', () => {
|
|
30
|
+
expect(badgeLayoutProps(undefined)).toEqual({
|
|
31
|
+
justifyContent: 'flex-start',
|
|
32
|
+
alignItems: 'center'
|
|
33
|
+
})
|
|
34
|
+
expect(badgeLayoutProps({})).toEqual({ justifyContent: 'flex-start', alignItems: 'center' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('horizontal center / right / left → justifyContent', () => {
|
|
38
|
+
expect(badgeLayoutProps({ horizontal: 'center' }).justifyContent).toBe('center')
|
|
39
|
+
expect(badgeLayoutProps({ horizontal: 'right' }).justifyContent).toBe('flex-end')
|
|
40
|
+
expect(badgeLayoutProps({ horizontal: 'left' }).justifyContent).toBe('flex-start')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('vertical bottom / top / middle → alignItems', () => {
|
|
44
|
+
expect(badgeLayoutProps({ vertical: 'bottom' }).alignItems).toBe('flex-end')
|
|
45
|
+
expect(badgeLayoutProps({ vertical: 'top' }).alignItems).toBe('flex-start')
|
|
46
|
+
expect(badgeLayoutProps({ vertical: 'middle' }).alignItems).toBe('center')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('createBindingBadgeRenderer', () => {
|
|
51
|
+
it('未绑定格返回 undefined 回落默认渲染', () => {
|
|
52
|
+
const renderer = createBindingBadgeRenderer(bindingMap([]))
|
|
53
|
+
expect(renderer(ADDR, undefined)).toBeUndefined()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('绑定格不传 resolveAlign 保持左对齐徽章(向后兼容)', () => {
|
|
57
|
+
const binding = createReportBinding(ORDERS_DATASET, 'amount')
|
|
58
|
+
const renderer = createBindingBadgeRenderer(bindingMap([{ addr: ADDR, binding }]))
|
|
59
|
+
const layout = renderer(ADDR, undefined)
|
|
60
|
+
expect(layout?.renderDefault).toBe(false)
|
|
61
|
+
expect(layout?.rootContainer.attribute.justifyContent).toBe('flex-start')
|
|
62
|
+
expect(layout?.rootContainer.attribute.alignItems).toBe('center')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('绑定格容器跟随 resolveAlign 的水平 / 垂直对齐', () => {
|
|
66
|
+
const binding = createReportBinding(ORDERS_DATASET, 'amount')
|
|
67
|
+
const renderer = createBindingBadgeRenderer(
|
|
68
|
+
bindingMap([{ addr: ADDR, binding }]),
|
|
69
|
+
undefined,
|
|
70
|
+
() => ({ horizontal: 'center', vertical: 'bottom' })
|
|
71
|
+
)
|
|
72
|
+
const layout = renderer(ADDR, undefined)
|
|
73
|
+
expect(layout?.rootContainer.attribute.justifyContent).toBe('center')
|
|
74
|
+
expect(layout?.rootContainer.attribute.alignItems).toBe('flex-end')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('接线冒烟:经 Sheet.getEffectiveStyle 读取有效样式对齐(含行默认样式叠加)', () => {
|
|
78
|
+
const sheet = new Sheet()
|
|
79
|
+
sheet.setCellMeta(ADDR, REPORT_META_NAMESPACE, createReportBinding(ORDERS_DATASET, 'amount'))
|
|
80
|
+
sheet.setCellStyle({ start: ADDR, end: ADDR }, { align: { horizontal: 'center' } })
|
|
81
|
+
sheet.setRowStyle(ADDR.row, { align: { vertical: 'bottom' } })
|
|
82
|
+
|
|
83
|
+
const renderer = createBindingBadgeRenderer(
|
|
84
|
+
(addr) => sheet.getCellMeta<ReportBinding>(addr, REPORT_META_NAMESPACE),
|
|
85
|
+
undefined,
|
|
86
|
+
(addr) => sheet.getEffectiveStyle(addr)?.align
|
|
87
|
+
)
|
|
88
|
+
const layout = renderer(ADDR, undefined)
|
|
89
|
+
expect(layout?.rootContainer.attribute.justifyContent).toBe('center')
|
|
90
|
+
expect(layout?.rootContainer.attribute.alignItems).toBe('flex-end')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Workbook } from '@veltra/sheet-core'
|
|
2
|
+
import { SheetGrid } from '@veltra/sheet-core/grid'
|
|
2
3
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
4
|
import { createApp, h, nextTick, ref, type App } from 'vue'
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ import { UReportDesigner } from '../../../index'
|
|
|
6
7
|
import { REPORT_META_NAMESPACE } from '../../../report/binding'
|
|
7
8
|
import type { DataConnection, DataConnector } from '../../../report/connector'
|
|
8
9
|
import type { ReportTemplate } from '../../../report/template'
|
|
9
|
-
import type { ParamValues, ReportBinding } from '../../../report/types'
|
|
10
|
+
import type { ParamValues, ReportBinding, ReportTemplateListItem } from '../../../report/types'
|
|
10
11
|
import type { ReportDesignerExposed } from '../../../types'
|
|
11
12
|
import * as cellCoords from '../designer/cell-coords'
|
|
12
13
|
import * as gridOverlay from '../designer/use-grid-overlay'
|
|
@@ -64,7 +65,14 @@ function createStubConnector() {
|
|
|
64
65
|
const apps: App[] = []
|
|
65
66
|
const containers: HTMLElement[] = []
|
|
66
67
|
|
|
67
|
-
function mountDesigner(
|
|
68
|
+
function mountDesigner(
|
|
69
|
+
workbook: Workbook,
|
|
70
|
+
options?: {
|
|
71
|
+
template?: ReportTemplate
|
|
72
|
+
drillTemplates?: ReportTemplateListItem[]
|
|
73
|
+
resolveTemplate?: (ref: string) => Promise<ReportTemplate>
|
|
74
|
+
}
|
|
75
|
+
) {
|
|
68
76
|
const { connector, calls, state } = createStubConnector()
|
|
69
77
|
const el = document.createElement('div')
|
|
70
78
|
el.style.width = '960px'
|
|
@@ -80,6 +88,8 @@ function mountDesigner(workbook: Workbook, options?: { template?: ReportTemplate
|
|
|
80
88
|
workbook,
|
|
81
89
|
connections: connections.value,
|
|
82
90
|
template: options?.template,
|
|
91
|
+
drillTemplates: options?.drillTemplates,
|
|
92
|
+
resolveTemplate: options?.resolveTemplate,
|
|
83
93
|
'onUpdate:connections': (value: DataConnection[]) => {
|
|
84
94
|
connections.value = value
|
|
85
95
|
},
|
|
@@ -142,8 +152,9 @@ function dispatchFieldDragOver(target: HTMLElement, clientX = 100, clientY = 100
|
|
|
142
152
|
afterEach(() => {
|
|
143
153
|
while (apps.length) apps.pop()!.unmount()
|
|
144
154
|
while (containers.length) containers.pop()!.remove()
|
|
145
|
-
// drawer Teleport 到 body,卸载后清理残留
|
|
155
|
+
// drawer / dialog Teleport 到 body,卸载后清理残留
|
|
146
156
|
document.querySelectorAll('.u-drawer-overlay').forEach((node) => node.remove())
|
|
157
|
+
document.querySelectorAll('.u-dialog-overlay').forEach((node) => node.remove())
|
|
147
158
|
})
|
|
148
159
|
|
|
149
160
|
describe('UReportDesigner 最小闭环(数据中枢 + 拖拽绑定 + getTemplate)', () => {
|
|
@@ -347,6 +358,37 @@ describe('UReportDesigner 全量:template 载入与预览模式(内嵌查看
|
|
|
347
358
|
saveBlobSpy.mockRestore()
|
|
348
359
|
})
|
|
349
360
|
|
|
361
|
+
it('预览模式向内嵌查看器转发 resolveTemplate,预览内可下钻', async () => {
|
|
362
|
+
const workbook = new Workbook()
|
|
363
|
+
const template = buildTemplate()
|
|
364
|
+
;(template.meta![0]!.payload as ReportBinding).drill = {
|
|
365
|
+
target: 'tpl-preview-detail',
|
|
366
|
+
mapping: { customer: 'keyword' },
|
|
367
|
+
openMode: 'switch'
|
|
368
|
+
}
|
|
369
|
+
const resolveTemplate = vi.fn(() => Promise.resolve(template))
|
|
370
|
+
const { el } = mountDesigner(workbook, { template, resolveTemplate })
|
|
371
|
+
await flush()
|
|
372
|
+
|
|
373
|
+
await click(findButton('预览模式')!)
|
|
374
|
+
expect(el.querySelector('.u-report-viewer')).toBeTruthy()
|
|
375
|
+
|
|
376
|
+
const hitSpy = vi
|
|
377
|
+
.spyOn(SheetGrid.prototype, 'hitTestSheetAddr')
|
|
378
|
+
.mockReturnValue({ row: 1, col: 0 })
|
|
379
|
+
const gridEl = el.querySelector('.u-sheet__grid-instance')!
|
|
380
|
+
gridEl.dispatchEvent(
|
|
381
|
+
new PointerEvent('pointerdown', { bubbles: true, clientX: 10, clientY: 10 })
|
|
382
|
+
)
|
|
383
|
+
gridEl.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: 10, clientY: 10 }))
|
|
384
|
+
await flush()
|
|
385
|
+
await flush()
|
|
386
|
+
|
|
387
|
+
expect(resolveTemplate).toHaveBeenCalledWith('tpl-preview-detail')
|
|
388
|
+
expect(el.querySelector('.u-report-viewer__drill-bar')).toBeTruthy()
|
|
389
|
+
hitSpy.mockRestore()
|
|
390
|
+
})
|
|
391
|
+
|
|
350
392
|
it('字段拖拽 dragover 时落点高亮随 hit-test 更新;落空时提示回退当前选区', async () => {
|
|
351
393
|
const workbook = new Workbook()
|
|
352
394
|
workbook.activeSheet.selectCell({ row: 2, col: 1 })
|
|
@@ -461,4 +503,57 @@ describe('UReportDesigner Action Pill', () => {
|
|
|
461
503
|
expect(panel!.querySelector('.u-report-float-panel__select--aggregate')).toBeNull()
|
|
462
504
|
restore()
|
|
463
505
|
})
|
|
506
|
+
|
|
507
|
+
function seedBindingCell(workbook: Workbook): void {
|
|
508
|
+
workbook.activeSheet.setCellMeta({ row: 0, col: 0 }, REPORT_META_NAMESPACE, {
|
|
509
|
+
dataset: 'ds',
|
|
510
|
+
field: 'customer',
|
|
511
|
+
aggregate: 'group',
|
|
512
|
+
expand: 'down',
|
|
513
|
+
preset: 'groupHeader'
|
|
514
|
+
} satisfies ReportBinding)
|
|
515
|
+
workbook.activeSheet.selectCell({ row: 0, col: 0 })
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function findPanelButton(panel: Element, text: string): HTMLButtonElement | undefined {
|
|
519
|
+
return [...panel.querySelectorAll<HTMLButtonElement>('button')].find((button) =>
|
|
520
|
+
button.textContent?.includes(text)
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
it('宿主传入模板列表时绑定格 Action Pill 出现下钻入口,点击打开下钻配置对话框', async () => {
|
|
525
|
+
const workbook = new Workbook()
|
|
526
|
+
seedBindingCell(workbook)
|
|
527
|
+
|
|
528
|
+
const restore = spyOverlay()
|
|
529
|
+
const { el } = mountDesigner(workbook, {
|
|
530
|
+
drillTemplates: [{ ref: 'tpl-detail', label: '订单详情' }]
|
|
531
|
+
})
|
|
532
|
+
await flush()
|
|
533
|
+
|
|
534
|
+
const panel = el.querySelector('.u-report-float-panel')!
|
|
535
|
+
expect(panel).toBeTruthy()
|
|
536
|
+
const drillButton = findPanelButton(panel, '下钻')
|
|
537
|
+
expect(drillButton).toBeTruthy()
|
|
538
|
+
|
|
539
|
+
await click(drillButton!)
|
|
540
|
+
const dialog = document.querySelector('.u-report-drill-dialog')
|
|
541
|
+
expect(dialog).toBeTruthy()
|
|
542
|
+
expect(dialog!.textContent).toContain('目标模板')
|
|
543
|
+
restore()
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
it('宿主不传模板列表时下钻配置入口不出现', async () => {
|
|
547
|
+
const workbook = new Workbook()
|
|
548
|
+
seedBindingCell(workbook)
|
|
549
|
+
|
|
550
|
+
const restore = spyOverlay()
|
|
551
|
+
const { el } = mountDesigner(workbook)
|
|
552
|
+
await flush()
|
|
553
|
+
|
|
554
|
+
const panel = el.querySelector('.u-report-float-panel')!
|
|
555
|
+
expect(panel).toBeTruthy()
|
|
556
|
+
expect(findPanelButton(panel, '下钻')).toBeUndefined()
|
|
557
|
+
restore()
|
|
558
|
+
})
|
|
464
559
|
})
|