@arronqzy/vue-view 0.1.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/README.md +50 -0
- package/package.json +49 -0
- package/src/env.d.ts +62 -0
- package/src/index.ts +4 -0
- package/src/panel/VueViewOnlinePreview.vue +276 -0
- package/src/panel/VueViewPanel.vue +871 -0
- package/src/panel/components/ConfigHintIcon.vue +34 -0
- package/src/panel/components/ElementsLayer.vue +165 -0
- package/src/panel/components/MaterialPreview.vue +135 -0
- package/src/panel/components/MaterialSidebar.vue +526 -0
- package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
- package/src/panel/components/MoveableLayer.vue +859 -0
- package/src/panel/components/PanelCanvas.vue +630 -0
- package/src/panel/components/PanelConfigSidebar.vue +397 -0
- package/src/panel/components/PanelRulers.vue +177 -0
- package/src/panel/components/SelectLayer.vue +115 -0
- package/src/panel/components/ViewElementScopePanel.vue +76 -0
- package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
- package/src/panel/components/WorkspaceProjectNav.vue +192 -0
- package/src/panel/components/WorkspaceStageSplit.vue +258 -0
- package/src/panel/components/config/ConfigColorField.vue +52 -0
- package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
- package/src/panel/components/config/ConfigSection.vue +50 -0
- package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
- package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
- package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
- package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
- package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
- package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
- package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
- package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
- package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
- package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
- package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
- package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
- package/src/panel/components/config/shared.ts +74 -0
- package/src/panel/components/elementsLayerNodes.ts +830 -0
- package/src/panel/components/materialSidebarData.ts +85 -0
- package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
- package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
- package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
- package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
- package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
- package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
- package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
- package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
- package/src/panel/constants/messages.ts +34 -0
- package/src/panel/constants/zIndex.ts +6 -0
- package/src/panel/hooks/usePanelElements.ts +1075 -0
- package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
- package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
- package/src/panel/lib/panel-ruler-canvas.ts +139 -0
- package/src/panel/library/workspace-project-cache.ts +23 -0
- package/src/panel/library/workspace-project-db.ts +111 -0
- package/src/panel/library/workspace-project-sync.ts +41 -0
- package/src/panel/library/workspace-snapshot.ts +30 -0
- package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
- package/src/panel/scope/view-scope-store.ts +82 -0
- package/src/panel/types.ts +127 -0
- package/src/panel/utils/chartOptionBuilder.ts +327 -0
- package/src/panel/utils/gridPlacement.ts +189 -0
- package/src/panel/utils/mappingLayerOps.ts +142 -0
- package/src/panel/utils/panelElementDefaults.ts +161 -0
- package/src/panel/utils/panelElementNodes.ts +35 -0
- package/src/panel/utils/panelStateIO.ts +124 -0
- package/src/panel/utils/scope-autocomplete.ts +114 -0
- package/src/panel/utils/scope-field-labels.ts +46 -0
- package/src/panel/utils/scope-template-chart.ts +92 -0
- package/src/panel/utils/scope-template-preview.ts +124 -0
- package/src/panel/utils/scope-template-spread.ts +229 -0
- package/src/panel/utils/scope-template-warnings.ts +243 -0
- package/src/panel/utils/scope-template.ts +97 -0
- package/src/panel/utils/updateElementDraft.ts +221 -0
- package/src/panel/viewportZoom.ts +26 -0
- package/src/tailwind.css +43 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type ScopeAutocompleteState = {
|
|
2
|
+
templateStart: number;
|
|
3
|
+
cursor: number;
|
|
4
|
+
path: string[];
|
|
5
|
+
partialKey: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function getValueAtPath(scope: unknown, path: string[]): unknown {
|
|
9
|
+
let current: unknown = scope;
|
|
10
|
+
for (const part of path) {
|
|
11
|
+
if (current === null || typeof current !== "object") return undefined;
|
|
12
|
+
current = (current as Record<string, unknown>)[part];
|
|
13
|
+
}
|
|
14
|
+
return current;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function listObjectKeys(value: unknown): string[] {
|
|
18
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
return Object.keys(value as Record<string, unknown>);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 解析光标处未闭合的 scope 模版表达式 */
|
|
25
|
+
export function parseScopeAutocomplete(
|
|
26
|
+
text: string,
|
|
27
|
+
cursor: number,
|
|
28
|
+
scope: unknown
|
|
29
|
+
): ScopeAutocompleteState | null {
|
|
30
|
+
const before = text.slice(0, cursor);
|
|
31
|
+
const templateStart = before.lastIndexOf("{");
|
|
32
|
+
if (templateStart < 0) return null;
|
|
33
|
+
|
|
34
|
+
const inner = before.slice(templateStart + 1);
|
|
35
|
+
if (inner.includes("}")) return null;
|
|
36
|
+
if (!inner.startsWith("scope")) return null;
|
|
37
|
+
|
|
38
|
+
let body = inner.slice("scope".length);
|
|
39
|
+
if (body === "" || body === "?") {
|
|
40
|
+
return { templateStart, cursor, path: [], partialKey: "" };
|
|
41
|
+
}
|
|
42
|
+
if (!body.startsWith("?.")) return null;
|
|
43
|
+
|
|
44
|
+
body = body.slice(2);
|
|
45
|
+
if (body === "") {
|
|
46
|
+
return { templateStart, cursor, path: [], partialKey: "" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const segments = body.split("?.");
|
|
50
|
+
const endsWithSeparator = inner.endsWith("?.");
|
|
51
|
+
|
|
52
|
+
if (endsWithSeparator) {
|
|
53
|
+
const path = segments.filter((segment) => segment.length > 0);
|
|
54
|
+
return { templateStart, cursor, path, partialKey: "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const lastSegment = segments[segments.length - 1] ?? "";
|
|
58
|
+
const prefixSegments = segments.slice(0, -1).filter((segment) => segment.length > 0);
|
|
59
|
+
const parent = getValueAtPath(scope, prefixSegments);
|
|
60
|
+
const keys = listObjectKeys(parent);
|
|
61
|
+
|
|
62
|
+
if (lastSegment.length === 0) {
|
|
63
|
+
return { templateStart, cursor, path: prefixSegments, partialKey: "" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (keys.includes(lastSegment)) {
|
|
67
|
+
return {
|
|
68
|
+
templateStart,
|
|
69
|
+
cursor,
|
|
70
|
+
path: [...prefixSegments, lastSegment],
|
|
71
|
+
partialKey: "",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
templateStart,
|
|
77
|
+
cursor,
|
|
78
|
+
path: prefixSegments,
|
|
79
|
+
partialKey: lastSegment,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getScopeAutocompleteSuggestions(
|
|
84
|
+
scope: unknown,
|
|
85
|
+
state: ScopeAutocompleteState
|
|
86
|
+
): string[] {
|
|
87
|
+
const parent =
|
|
88
|
+
state.partialKey.length > 0
|
|
89
|
+
? getValueAtPath(scope, state.path)
|
|
90
|
+
: state.path.length > 0
|
|
91
|
+
? getValueAtPath(scope, state.path)
|
|
92
|
+
: scope;
|
|
93
|
+
|
|
94
|
+
const keys = listObjectKeys(parent);
|
|
95
|
+
if (state.partialKey.length === 0) return keys;
|
|
96
|
+
return keys.filter((key) => key.startsWith(state.partialKey));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildScopeExpression(path: string[], selectedKey: string): string {
|
|
100
|
+
return `scope?.${[...path, selectedKey].join("?.")}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function applyScopeAutocompleteSelection(
|
|
104
|
+
text: string,
|
|
105
|
+
state: ScopeAutocompleteState,
|
|
106
|
+
selectedKey: string
|
|
107
|
+
): { value: string; cursor: number } {
|
|
108
|
+
const expr = buildScopeExpression(state.path, selectedKey);
|
|
109
|
+
const before = text.slice(0, state.templateStart + 1);
|
|
110
|
+
const after = text.slice(state.cursor);
|
|
111
|
+
const value = `${before}${expr}${after}`;
|
|
112
|
+
const cursor = before.length + expr.length;
|
|
113
|
+
return { value, cursor };
|
|
114
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const LABELS: Record<string, string> = {
|
|
2
|
+
name: "节点名称",
|
|
3
|
+
textHtml: "文本内容",
|
|
4
|
+
textFontFamily: "文本字体",
|
|
5
|
+
textFontWeight: "文本字重",
|
|
6
|
+
textColor: "文本颜色",
|
|
7
|
+
"chart.title": "图表标题",
|
|
8
|
+
"chart.color": "图表颜色",
|
|
9
|
+
"chart.gradientFrom": "渐变起始色",
|
|
10
|
+
"chart.gradientTo": "渐变结束色",
|
|
11
|
+
"chart.xAxisName": "X 轴名称",
|
|
12
|
+
"chart.yAxisName": "Y 轴名称",
|
|
13
|
+
"chart.xAxisTickColor": "X 轴刻度颜色",
|
|
14
|
+
"chart.yAxisTickColor": "Y 轴刻度颜色",
|
|
15
|
+
"chart.xAxisSplitLineColor": "X 轴分割线颜色",
|
|
16
|
+
"chart.yAxisSplitLineColor": "Y 轴分割线颜色",
|
|
17
|
+
"chart.xAxisLabelColor": "X 轴标签颜色",
|
|
18
|
+
"chart.yAxisLabelColor": "Y 轴标签颜色",
|
|
19
|
+
"chart.tooltipBackgroundColor": "Tooltip 背景色",
|
|
20
|
+
"chart.tooltipTextColor": "Tooltip 文字色",
|
|
21
|
+
"chart.tooltipFormatter": "Tooltip 格式化",
|
|
22
|
+
"chart.labelsText": "图表类目",
|
|
23
|
+
"chart.valuesText": "图表数值",
|
|
24
|
+
"style.backgroundColor": "背景色",
|
|
25
|
+
"style.backgroundImage": "背景图",
|
|
26
|
+
"style.backgroundImageRemoteUrl": "背景图远程地址",
|
|
27
|
+
"style.backgroundSize": "背景尺寸",
|
|
28
|
+
"style.backgroundPosition": "背景位置",
|
|
29
|
+
"style.borderColor": "边框颜色",
|
|
30
|
+
audioSrc: "音频地址",
|
|
31
|
+
audioRemoteUrl: "音频远程地址",
|
|
32
|
+
videoSrc: "视频地址",
|
|
33
|
+
videoRemoteUrl: "视频远程地址",
|
|
34
|
+
geometryColor: "几何颜色",
|
|
35
|
+
geometryScript: "几何脚本",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function resolveScopeFieldLabel(fieldId: string): string {
|
|
39
|
+
if (LABELS[fieldId]) return LABELS[fieldId];
|
|
40
|
+
const leaf = fieldId.split(".").pop() ?? fieldId;
|
|
41
|
+
return LABELS[fieldId] ?? leaf;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scopeFieldDomId(fieldId: string): string {
|
|
45
|
+
return `scope-field-${fieldId.replace(/\./g, "--")}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PanelChartConfig } from "../types";
|
|
2
|
+
import { hasScopeTemplate, evaluateScopeTemplate } from "./scope-template";
|
|
3
|
+
import {
|
|
4
|
+
evaluateSpreadScopeExpressionValues,
|
|
5
|
+
extractSpreadScopeExpression,
|
|
6
|
+
isSpreadOnlyScopeTemplate,
|
|
7
|
+
} from "./scope-template-spread";
|
|
8
|
+
|
|
9
|
+
function splitCommaSeparatedLabels(text: string): string[] {
|
|
10
|
+
return text
|
|
11
|
+
.split(",")
|
|
12
|
+
.map((part) => part.trim())
|
|
13
|
+
.filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function materializeSpreadTemplate(template: string, scope: unknown): string[] {
|
|
17
|
+
const expression = extractSpreadScopeExpression(template);
|
|
18
|
+
if (!expression) return [];
|
|
19
|
+
const result = evaluateSpreadScopeExpressionValues(expression, scope);
|
|
20
|
+
return result.ok ? result.values : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function materializeLabelToken(token: string, scope: unknown): string[] {
|
|
24
|
+
const trimmed = token.trim();
|
|
25
|
+
if (!trimmed) return [];
|
|
26
|
+
|
|
27
|
+
if (isSpreadOnlyScopeTemplate(trimmed)) {
|
|
28
|
+
return materializeSpreadTemplate(trimmed, scope);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (hasScopeTemplate(trimmed)) {
|
|
32
|
+
return [evaluateScopeTemplate(trimmed, scope)];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return [trimmed];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 将类目模版文本展开为图表所需的字符串数组 */
|
|
39
|
+
export function materializeChartLabelsText(
|
|
40
|
+
text: string,
|
|
41
|
+
scope: unknown
|
|
42
|
+
): string[] {
|
|
43
|
+
const trimmed = text.trim();
|
|
44
|
+
if (!trimmed) return [];
|
|
45
|
+
|
|
46
|
+
if (isSpreadOnlyScopeTemplate(trimmed)) {
|
|
47
|
+
return materializeSpreadTemplate(trimmed, scope);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (hasScopeTemplate(trimmed)) {
|
|
51
|
+
return splitCommaSeparatedLabels(evaluateScopeTemplate(trimmed, scope));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return splitCommaSeparatedLabels(trimmed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 兼容旧数据:labels 数组中每项可含展开模版 */
|
|
58
|
+
export function materializeChartLabelsArray(
|
|
59
|
+
labels: string[],
|
|
60
|
+
scope: unknown
|
|
61
|
+
): string[] {
|
|
62
|
+
const result: string[] = [];
|
|
63
|
+
for (const label of labels) {
|
|
64
|
+
result.push(...materializeLabelToken(label, scope));
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 将数值模版文本展开为图表所需的数字数组 */
|
|
70
|
+
export function materializeChartValuesText(
|
|
71
|
+
text: string,
|
|
72
|
+
scope: unknown
|
|
73
|
+
): number[] {
|
|
74
|
+
return materializeChartLabelsText(text, scope).map((part) => {
|
|
75
|
+
const value = Number(part);
|
|
76
|
+
return Number.isFinite(value) ? value : 0;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function materializeChartLabelsFromScope(
|
|
81
|
+
chart: PanelChartConfig | undefined,
|
|
82
|
+
scope: unknown
|
|
83
|
+
): string[] | undefined {
|
|
84
|
+
if (!chart) return undefined;
|
|
85
|
+
if (chart.labelsText !== undefined) {
|
|
86
|
+
return materializeChartLabelsText(chart.labelsText, scope);
|
|
87
|
+
}
|
|
88
|
+
if (chart.labels?.length) {
|
|
89
|
+
return materializeChartLabelsArray(chart.labels, scope);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PanelElement } from "../types";
|
|
2
|
+
import {
|
|
3
|
+
materializeChartLabelsText,
|
|
4
|
+
materializeChartValuesText,
|
|
5
|
+
} from "./scope-template-chart";
|
|
6
|
+
import { hasScopeTemplate, evaluateScopeTemplate } from "./scope-template";
|
|
7
|
+
import { SCOPE_SPREAD_TEMPLATE_RE } from "./scope-template-spread";
|
|
8
|
+
import { resolveScopeFieldLabel } from "./scope-field-labels";
|
|
9
|
+
|
|
10
|
+
export type ScopeTemplateField = {
|
|
11
|
+
fieldId: string;
|
|
12
|
+
fieldLabel: string;
|
|
13
|
+
template: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const SKIP_FIELD_PREFIXES = new Set(["refSnapshot", "geometrySketchDataUrl"]);
|
|
17
|
+
|
|
18
|
+
function shouldSkipField(fieldId: string): boolean {
|
|
19
|
+
return [...SKIP_FIELD_PREFIXES].some((prefix) => fieldId.startsWith(prefix));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function walkElementTemplateFields(
|
|
23
|
+
value: unknown,
|
|
24
|
+
fieldId: string,
|
|
25
|
+
results: ScopeTemplateField[]
|
|
26
|
+
) {
|
|
27
|
+
if (shouldSkipField(fieldId)) return;
|
|
28
|
+
|
|
29
|
+
if (typeof value === "string") {
|
|
30
|
+
if (!hasScopeTemplate(value)) return;
|
|
31
|
+
results.push({
|
|
32
|
+
fieldId,
|
|
33
|
+
fieldLabel: resolveScopeFieldLabel(fieldId),
|
|
34
|
+
template: value,
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
value.forEach((item, index) => {
|
|
41
|
+
walkElementTemplateFields(item, `${fieldId}.${index}`, results);
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (value && typeof value === "object") {
|
|
47
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
48
|
+
const nextId = fieldId ? `${fieldId}.${key}` : key;
|
|
49
|
+
walkElementTemplateFields(nested, nextId, results);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectElementScopeTemplateFields(
|
|
55
|
+
element: PanelElement
|
|
56
|
+
): ScopeTemplateField[] {
|
|
57
|
+
const results: ScopeTemplateField[] = [];
|
|
58
|
+
walkElementTemplateFields(element, "", results);
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function matchScopeTemplateFieldId(
|
|
63
|
+
template: string,
|
|
64
|
+
fields: ScopeTemplateField[]
|
|
65
|
+
): string | undefined {
|
|
66
|
+
const exact = fields.find((field) => field.template === template);
|
|
67
|
+
if (exact) return exact.fieldId;
|
|
68
|
+
|
|
69
|
+
if (!hasScopeTemplate(template)) return undefined;
|
|
70
|
+
|
|
71
|
+
const partial = fields.filter(
|
|
72
|
+
(field) =>
|
|
73
|
+
field.template.includes(template) || template.includes(field.template)
|
|
74
|
+
);
|
|
75
|
+
if (partial.length === 1) return partial[0].fieldId;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveScopeTemplatePreview(
|
|
80
|
+
template: string,
|
|
81
|
+
scope: unknown,
|
|
82
|
+
fieldId?: string
|
|
83
|
+
): unknown {
|
|
84
|
+
if (!hasScopeTemplate(template)) return template;
|
|
85
|
+
if (scope === undefined) return undefined;
|
|
86
|
+
|
|
87
|
+
const leafFieldId = fieldId?.split(".").pop();
|
|
88
|
+
|
|
89
|
+
if (
|
|
90
|
+
fieldId === "chart.labelsText" ||
|
|
91
|
+
leafFieldId === "labelsText"
|
|
92
|
+
) {
|
|
93
|
+
return materializeChartLabelsText(template, scope);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
fieldId === "chart.valuesText" ||
|
|
98
|
+
leafFieldId === "valuesText"
|
|
99
|
+
) {
|
|
100
|
+
return materializeChartValuesText(template, scope);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (SCOPE_SPREAD_TEMPLATE_RE.test(template)) {
|
|
104
|
+
return materializeChartLabelsText(template, scope);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const evaluated = evaluateScopeTemplate(template, scope);
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(evaluated);
|
|
110
|
+
} catch {
|
|
111
|
+
return evaluated;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatScopeTemplatePreview(value: unknown): string {
|
|
116
|
+
if (value === undefined) return "undefined";
|
|
117
|
+
if (value === null) return "null";
|
|
118
|
+
if (typeof value === "string") return value;
|
|
119
|
+
try {
|
|
120
|
+
return JSON.stringify(value, null, 2);
|
|
121
|
+
} catch {
|
|
122
|
+
return String(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** 数组展开模版:[...{scope?.a?.b}] 从 scope 中定位数组并提取每项字段,输出逗号分隔值 */
|
|
2
|
+
|
|
3
|
+
export const SCOPE_SPREAD_TEMPLATE_RE = /\[\.\.\.\{([^}]+)\}\]/g;
|
|
4
|
+
|
|
5
|
+
export const SCOPE_SPREAD_TEMPLATE_DEMO = {
|
|
6
|
+
scope: { a: [{ b: 1 }, { b: 2 }] },
|
|
7
|
+
template: "[...{scope?.a?.b}]",
|
|
8
|
+
output: "1,2",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export const SCOPE_SPREAD_DEEP_TEMPLATE_DEMO = {
|
|
12
|
+
scope: {
|
|
13
|
+
data: {
|
|
14
|
+
data: [{ statusName: "进行中" }, { statusName: "已完成" }],
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
template: "[...{scope?.data?.data?.statusName}]",
|
|
18
|
+
output: "进行中,已完成",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export type ScopeSpreadEvalIssue =
|
|
22
|
+
| "invalid-expression"
|
|
23
|
+
| "path-missing"
|
|
24
|
+
| "not-array";
|
|
25
|
+
|
|
26
|
+
export type ScopeSpreadEvalResult =
|
|
27
|
+
| { ok: true; value: string }
|
|
28
|
+
| { ok: false; issue: ScopeSpreadEvalIssue; arrayPath?: string };
|
|
29
|
+
|
|
30
|
+
export type SpreadScopePathResolution =
|
|
31
|
+
| {
|
|
32
|
+
ok: true;
|
|
33
|
+
arrayPathSegments: string[];
|
|
34
|
+
itemPathSegments: string[];
|
|
35
|
+
arrayValue: unknown[];
|
|
36
|
+
}
|
|
37
|
+
| { ok: false; issue: ScopeSpreadEvalIssue; arrayPath?: string };
|
|
38
|
+
|
|
39
|
+
/** 解析 scope?.a?.b 形式的路径段(不含 scope 前缀) */
|
|
40
|
+
export function parseScopePathSegments(expression: string): string[] | null {
|
|
41
|
+
const trimmed = expression.trim();
|
|
42
|
+
if (!trimmed.startsWith("scope")) return null;
|
|
43
|
+
|
|
44
|
+
let body = trimmed.slice("scope".length);
|
|
45
|
+
if (body.startsWith("?.")) body = body.slice(2);
|
|
46
|
+
else if (body.startsWith(".")) body = body.slice(1);
|
|
47
|
+
else if (body.length > 0) return null;
|
|
48
|
+
|
|
49
|
+
if (!body) return [];
|
|
50
|
+
return body.split("?.").filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatScopePathSegments(segments: string[]): string {
|
|
54
|
+
return segments.join(".");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getValueAtScopePath(scope: unknown, path: string[]): unknown {
|
|
58
|
+
let current: unknown = scope;
|
|
59
|
+
for (const part of path) {
|
|
60
|
+
if (current === null || current === undefined) return undefined;
|
|
61
|
+
if (typeof current !== "object") return undefined;
|
|
62
|
+
current = (current as Record<string, unknown>)[part];
|
|
63
|
+
}
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 从完整路径中定位数组段与项内字段段。
|
|
69
|
+
* 例如 scope?.data?.data?.statusName → 数组在 data.data,项字段为 statusName。
|
|
70
|
+
*/
|
|
71
|
+
export function resolveSpreadScopePath(
|
|
72
|
+
expression: string,
|
|
73
|
+
scope: unknown
|
|
74
|
+
): SpreadScopePathResolution {
|
|
75
|
+
const segments = parseScopePathSegments(expression);
|
|
76
|
+
if (segments === null || segments.length === 0) {
|
|
77
|
+
return { ok: false, issue: "invalid-expression" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (segments.length === 1) {
|
|
81
|
+
const arrayPathSegments = segments;
|
|
82
|
+
const arrayValue = getValueAtScopePath(scope, arrayPathSegments);
|
|
83
|
+
const arrayPath = formatScopePathSegments(arrayPathSegments);
|
|
84
|
+
if (arrayValue === undefined) {
|
|
85
|
+
return { ok: false, issue: "path-missing", arrayPath };
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(arrayValue)) {
|
|
88
|
+
return { ok: false, issue: "not-array", arrayPath };
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
arrayPathSegments,
|
|
93
|
+
itemPathSegments: [],
|
|
94
|
+
arrayValue,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
|
|
99
|
+
const arrayPathSegments = segments.slice(0, splitAt);
|
|
100
|
+
const itemPathSegments = segments.slice(splitAt);
|
|
101
|
+
const arrayValue = getValueAtScopePath(scope, arrayPathSegments);
|
|
102
|
+
if (arrayValue === undefined) continue;
|
|
103
|
+
if (Array.isArray(arrayValue)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
arrayPathSegments,
|
|
107
|
+
itemPathSegments,
|
|
108
|
+
arrayValue,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rootValue = getValueAtScopePath(scope, [segments[0]!]);
|
|
114
|
+
if (rootValue === undefined) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
issue: "path-missing",
|
|
118
|
+
arrayPath: segments[0],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let splitAt = 1; splitAt < segments.length; splitAt++) {
|
|
123
|
+
const arrayPathSegments = segments.slice(0, splitAt);
|
|
124
|
+
const arrayValue = getValueAtScopePath(scope, arrayPathSegments);
|
|
125
|
+
if (arrayValue === undefined) continue;
|
|
126
|
+
if (!Array.isArray(arrayValue)) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
issue: "not-array",
|
|
130
|
+
arrayPath: formatScopePathSegments(arrayPathSegments),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
issue: "path-missing",
|
|
138
|
+
arrayPath: formatScopePathSegments(segments.slice(0, -1)),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function formatSpreadItemValue(value: unknown): string {
|
|
143
|
+
if (value === null || value === undefined) return "";
|
|
144
|
+
if (typeof value === "string") return value;
|
|
145
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
146
|
+
return String(value);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
return JSON.stringify(value);
|
|
150
|
+
} catch {
|
|
151
|
+
return String(value);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function evaluateSpreadScopeExpression(
|
|
156
|
+
expression: string,
|
|
157
|
+
scope: unknown
|
|
158
|
+
): ScopeSpreadEvalResult {
|
|
159
|
+
const valuesResult = evaluateSpreadScopeExpressionValues(expression, scope);
|
|
160
|
+
if (!valuesResult.ok) {
|
|
161
|
+
return valuesResult;
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, value: valuesResult.values.join(",") };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function evaluateSpreadScopeExpressionValues(
|
|
167
|
+
expression: string,
|
|
168
|
+
scope: unknown
|
|
169
|
+
): { ok: true; values: string[] } | { ok: false; issue: ScopeSpreadEvalIssue; arrayPath?: string } {
|
|
170
|
+
const resolved = resolveSpreadScopePath(expression, scope);
|
|
171
|
+
if (!resolved.ok) {
|
|
172
|
+
return {
|
|
173
|
+
ok: false,
|
|
174
|
+
issue: resolved.issue,
|
|
175
|
+
arrayPath: resolved.arrayPath,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const values = resolved.arrayValue
|
|
180
|
+
.map((item) =>
|
|
181
|
+
resolved.itemPathSegments.length === 0
|
|
182
|
+
? item
|
|
183
|
+
: getValueAtScopePath(item, resolved.itemPathSegments)
|
|
184
|
+
)
|
|
185
|
+
.map(formatSpreadItemValue)
|
|
186
|
+
.filter((value) => value.length > 0);
|
|
187
|
+
|
|
188
|
+
return { ok: true, values };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function isSpreadOnlyScopeTemplate(value: string): boolean {
|
|
192
|
+
return /^\[\.\.\.\{[^}]+\}\]$/.test(value.trim());
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function extractSpreadScopeExpression(template: string): string | null {
|
|
196
|
+
const match = /^\[\.\.\.\{([^}]+)\}\]$/.exec(template.trim());
|
|
197
|
+
return match ? match[1]!.trim() : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function isSpreadTemplateBrace(value: string, braceIndex: number): boolean {
|
|
201
|
+
return value.slice(Math.max(0, braceIndex - 4), braceIndex) === "[...";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function buildSpreadNotArrayWarningMessage(
|
|
205
|
+
fieldLabel: string,
|
|
206
|
+
template: string,
|
|
207
|
+
expression: string,
|
|
208
|
+
arrayPath: string
|
|
209
|
+
): string {
|
|
210
|
+
const demoScope = JSON.stringify(SCOPE_SPREAD_TEMPLATE_DEMO.scope);
|
|
211
|
+
const deepDemo = SCOPE_SPREAD_DEEP_TEMPLATE_DEMO;
|
|
212
|
+
return (
|
|
213
|
+
`「${fieldLabel}」中的 ${template} 模版仅可做数组值的映射,当前 scope.${arrayPath} 不是数组,数据不符合规则。` +
|
|
214
|
+
`参考示例:scope 为 ${demoScope} 时使用 ${SCOPE_SPREAD_TEMPLATE_DEMO.template} 输出 ${SCOPE_SPREAD_TEMPLATE_DEMO.output};` +
|
|
215
|
+
`深层路径可用 ${deepDemo.template}(scope 为 ${JSON.stringify(deepDemo.scope)} 时输出 ${deepDemo.output})。`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function buildSpreadInvalidExpressionWarningMessage(
|
|
220
|
+
fieldLabel: string,
|
|
221
|
+
template: string
|
|
222
|
+
): string {
|
|
223
|
+
const demoScope = JSON.stringify(SCOPE_SPREAD_TEMPLATE_DEMO.scope);
|
|
224
|
+
const deepDemo = SCOPE_SPREAD_DEEP_TEMPLATE_DEMO;
|
|
225
|
+
return (
|
|
226
|
+
`「${fieldLabel}」中的 ${template} 写法无效,请使用 ${SCOPE_SPREAD_TEMPLATE_DEMO.template} 或 ${deepDemo.template} 形式。` +
|
|
227
|
+
`参考示例:scope 为 ${demoScope} 时输出 ${SCOPE_SPREAD_TEMPLATE_DEMO.output}。`
|
|
228
|
+
);
|
|
229
|
+
}
|