@arronqzy/react-view 1.0.3 → 1.0.5

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.
@@ -0,0 +1,238 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useId,
5
+ useMemo,
6
+ useState,
7
+ type RefObject,
8
+ } from "react";
9
+ import { createPortal } from "react-dom";
10
+
11
+ import type { PanelElement } from "../../types";
12
+ import { hasScopeTemplate } from "../../utils/scope-template";
13
+ import {
14
+ collectElementScopeTemplateFields,
15
+ formatScopeTemplatePreview,
16
+ matchScopeTemplateFieldId,
17
+ resolveScopeTemplatePreview,
18
+ } from "../../utils/scope-template-preview";
19
+
20
+ const PREVIEW_SLOT_ATTR = "data-scope-preview-slot";
21
+ const PREVIEW_KEY_ATTR = "data-scope-preview-key";
22
+
23
+ type PreviewAnchor = {
24
+ key: string;
25
+ template: string;
26
+ fieldId?: string;
27
+ slot: HTMLElement;
28
+ };
29
+
30
+ function isPreviewTarget(
31
+ node: Element
32
+ ): node is HTMLInputElement | HTMLTextAreaElement {
33
+ if (node instanceof HTMLTextAreaElement) {
34
+ return node.dataset.scopeAutocomplete !== "off";
35
+ }
36
+ if (node instanceof HTMLInputElement) {
37
+ if (node.dataset.scopeAutocomplete === "off") return false;
38
+ const type = node.type;
39
+ return (
40
+ !type ||
41
+ type === "text" ||
42
+ type === "search" ||
43
+ type === "url" ||
44
+ type === "password"
45
+ );
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function ensurePreviewKey(
51
+ input: HTMLInputElement | HTMLTextAreaElement,
52
+ keySeed: string
53
+ ): string {
54
+ const existing = input.getAttribute(PREVIEW_KEY_ATTR);
55
+ if (existing) return existing;
56
+ const key = `${keySeed}-${Math.random().toString(36).slice(2, 9)}`;
57
+ input.setAttribute(PREVIEW_KEY_ATTR, key);
58
+ return key;
59
+ }
60
+
61
+ function ensurePreviewSlot(
62
+ input: HTMLInputElement | HTMLTextAreaElement
63
+ ): HTMLElement {
64
+ const host = input.closest("label") ?? input.parentElement;
65
+ if (!host) return input;
66
+
67
+ const existing = host.querySelector(`[${PREVIEW_SLOT_ATTR}]`);
68
+ if (existing instanceof HTMLElement) return existing;
69
+
70
+ const slot = document.createElement("div");
71
+ slot.setAttribute(PREVIEW_SLOT_ATTR, "1");
72
+ input.insertAdjacentElement("afterend", slot);
73
+ return slot;
74
+ }
75
+
76
+ function removeOrphanPreviewSlots(container: HTMLElement) {
77
+ container.querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`).forEach((slot) => {
78
+ const host = slot.parentElement;
79
+ const input = host?.querySelector("input, textarea");
80
+ if (
81
+ !input ||
82
+ !(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) ||
83
+ !hasScopeTemplate(input.value)
84
+ ) {
85
+ slot.remove();
86
+ }
87
+ });
88
+ }
89
+
90
+ function inferFieldIdFromDom(
91
+ input: HTMLInputElement | HTMLTextAreaElement
92
+ ): string | undefined {
93
+ const label = input.closest("label");
94
+ const labelText = label?.textContent ?? "";
95
+ if (labelText.includes("类目")) return "chart.labelsText";
96
+ if (labelText.includes("数值")) return "chart.valuesText";
97
+ return undefined;
98
+ }
99
+
100
+ function ScopeTemplatePreviewPanel({
101
+ template,
102
+ scope,
103
+ fieldId,
104
+ }: {
105
+ template: string;
106
+ scope: unknown;
107
+ fieldId?: string;
108
+ }) {
109
+ const [isExpanded, setIsExpanded] = useState(false);
110
+ const previewValue = resolveScopeTemplatePreview(template, scope, fieldId);
111
+ const previewText = formatScopeTemplatePreview(previewValue);
112
+ const previewType = Array.isArray(previewValue)
113
+ ? `数组 · ${previewValue.length} 项`
114
+ : typeof previewValue;
115
+
116
+ return (
117
+ <div className="mt-1 rounded border border-border/60 bg-muted/30">
118
+ <button
119
+ type="button"
120
+ className="flex w-full items-center justify-between gap-2 px-2 py-1 text-left text-[10px] text-muted-foreground hover:bg-accent/40"
121
+ onClick={() => setIsExpanded((prev) => !prev)}
122
+ >
123
+ <span className="font-medium text-foreground/80">解析预览</span>
124
+ <span className="shrink-0 text-[10px]">
125
+ {previewType} · {isExpanded ? "收起" : "展开"}
126
+ </span>
127
+ </button>
128
+ {isExpanded ? (
129
+ <pre className="max-h-28 overflow-auto border-t border-border/50 px-2 py-1.5 font-mono text-[10px] leading-relaxed text-foreground/90 whitespace-pre-wrap break-all">
130
+ {previewText}
131
+ </pre>
132
+ ) : null}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export function ScopeTemplatePreviewHost({
138
+ scope,
139
+ element,
140
+ containerRef,
141
+ }: {
142
+ scope: unknown;
143
+ element: PanelElement | null;
144
+ containerRef?: RefObject<HTMLElement | null>;
145
+ }) {
146
+ const keySeed = useId();
147
+ const [anchors, setAnchors] = useState<PreviewAnchor[]>([]);
148
+
149
+ const templateFields = useMemo(
150
+ () => (element ? collectElementScopeTemplateFields(element) : []),
151
+ [element]
152
+ );
153
+
154
+ const scanAnchors = useCallback(() => {
155
+ const container = containerRef?.current;
156
+ if (!container || scope === undefined) {
157
+ setAnchors((prev) => (prev.length === 0 ? prev : []));
158
+ return;
159
+ }
160
+
161
+ const next: PreviewAnchor[] = [];
162
+ const inputs = container.querySelectorAll("input, textarea");
163
+ for (const node of inputs) {
164
+ if (!isPreviewTarget(node)) continue;
165
+ const template = node.value;
166
+ if (!hasScopeTemplate(template)) continue;
167
+
168
+ const fieldId =
169
+ matchScopeTemplateFieldId(template, templateFields) ??
170
+ inferFieldIdFromDom(node);
171
+ next.push({
172
+ key: ensurePreviewKey(node, keySeed),
173
+ template,
174
+ fieldId,
175
+ slot: ensurePreviewSlot(node),
176
+ });
177
+ }
178
+
179
+ removeOrphanPreviewSlots(container);
180
+ setAnchors((prev) => {
181
+ if (
182
+ prev.length === next.length &&
183
+ prev.every(
184
+ (item, index) =>
185
+ item.key === next[index]?.key &&
186
+ item.template === next[index]?.template &&
187
+ item.fieldId === next[index]?.fieldId
188
+ )
189
+ ) {
190
+ return prev;
191
+ }
192
+ return next;
193
+ });
194
+ }, [containerRef, keySeed, scope, templateFields]);
195
+
196
+ useEffect(() => {
197
+ scanAnchors();
198
+ }, [scanAnchors, element, scope]);
199
+
200
+ useEffect(() => {
201
+ const container = containerRef?.current;
202
+ if (!container || scope === undefined) return;
203
+
204
+ const onInput = () => {
205
+ window.requestAnimationFrame(scanAnchors);
206
+ };
207
+
208
+ container.addEventListener("input", onInput, true);
209
+ const observer = new MutationObserver(onInput);
210
+ observer.observe(container, { childList: true, subtree: true });
211
+
212
+ return () => {
213
+ container.removeEventListener("input", onInput, true);
214
+ observer.disconnect();
215
+ container
216
+ .querySelectorAll(`[${PREVIEW_SLOT_ATTR}]`)
217
+ .forEach((node) => node.remove());
218
+ };
219
+ }, [containerRef, scanAnchors, scope]);
220
+
221
+ if (scope === undefined || anchors.length === 0) return null;
222
+
223
+ return (
224
+ <>
225
+ {anchors.map((anchor) =>
226
+ createPortal(
227
+ <ScopeTemplatePreviewPanel
228
+ key={anchor.key}
229
+ template={anchor.template}
230
+ scope={scope}
231
+ fieldId={anchor.fieldId}
232
+ />,
233
+ anchor.slot
234
+ )
235
+ )}
236
+ </>
237
+ );
238
+ }
@@ -0,0 +1,24 @@
1
+ export function ScopeTemplateUsageHint() {
2
+ return (
3
+ <div className="space-y-2">
4
+ <p>
5
+ 表单中可使用模版字符串引用 scope,例如{" "}
6
+ <code className="rounded bg-muted px-1">{`{scope?.a||0}`}</code>
7
+ 、
8
+ <code className="rounded bg-muted px-1">{`{scope?.name||''}`}</code>
9
+ 。支持 <code className="rounded bg-muted px-1">?.</code> 与{" "}
10
+ <code className="rounded bg-muted px-1">||</code> 占位。
11
+ </p>
12
+ <p>
13
+ 数组展开模版{" "}
14
+ <code className="rounded bg-muted px-1">{`[...{scope?.a?.b}]`}</code>
15
+ 、
16
+ <code className="rounded bg-muted px-1">
17
+ {`[...{scope?.data?.data?.statusName}]`}
18
+ </code>
19
+ :在文本字段输出逗号分隔字符串;在图表「类目」等数组字段会展开为 n
20
+ 个独立项(如 3 条数据对应 3 个类目)。
21
+ </p>
22
+ </div>
23
+ );
24
+ }
@@ -42,7 +42,7 @@ export function ScopeTemplateWarningsPanel() {
42
42
  {!isCollapsed ? (
43
43
  <ul className="mt-2 max-h-[200px] space-y-1 overflow-auto text-[10px] leading-relaxed">
44
44
  {ctx.warnings.map((warning) => (
45
- <li key={`${warning.fieldId}-${warning.missingPath}-${warning.expression}`}>
45
+ <li key={`${warning.fieldId}-${warning.kind}-${warning.missingPath}-${warning.expression}`}>
46
46
  <button
47
47
  type="button"
48
48
  className="w-full rounded border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-left text-amber-800 hover:bg-amber-500/15 dark:text-amber-200"
@@ -0,0 +1,30 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export function useRafThrottledScroll() {
4
+ const scrollRef = useRef({ left: 0, top: 0 });
5
+ const [scroll, setScroll] = useState({ left: 0, top: 0 });
6
+ const rafRef = useRef<number | null>(null);
7
+
8
+ const onScrollChange = useCallback((next: { left: number; top: number }) => {
9
+ scrollRef.current = next;
10
+ if (rafRef.current != null) return;
11
+ rafRef.current = requestAnimationFrame(() => {
12
+ rafRef.current = null;
13
+ const latest = scrollRef.current;
14
+ setScroll((prev) =>
15
+ prev.left === latest.left && prev.top === latest.top ? prev : latest
16
+ );
17
+ });
18
+ }, []);
19
+
20
+ useEffect(
21
+ () => () => {
22
+ if (rafRef.current != null) {
23
+ cancelAnimationFrame(rafRef.current);
24
+ }
25
+ },
26
+ []
27
+ );
28
+
29
+ return { scroll, scrollRef, onScrollChange };
30
+ }
@@ -28,6 +28,8 @@ export type PanelChartConfig = {
28
28
  tooltipTextColor?: string;
29
29
  tooltipFormatter?: string;
30
30
  labels?: string[];
31
+ /** 逗号分隔的原始类目文本,支持 scope 模版;存在时优先于 labels */
32
+ labelsText?: string;
31
33
  values?: number[];
32
34
  /** 逗号分隔的原始数值文本,支持 scope 模版;存在时优先于 values */
33
35
  valuesText?: string;
@@ -8,6 +8,17 @@ export function getChartValuesDisplayText(chart?: PanelChartConfig): string {
8
8
  return (chart?.values ?? []).join(",");
9
9
  }
10
10
 
11
+ export function getChartLabelsDisplayText(chart?: PanelChartConfig): string {
12
+ if (chart?.labelsText !== undefined) return chart.labelsText;
13
+ return (chart?.labels ?? []).join(",");
14
+ }
15
+
16
+ export function resolveChartLabels(element: PanelElement): string[] {
17
+ const chart = element.chart;
18
+ if (chart?.labels?.length) return chart.labels;
19
+ return ["A", "B", "C", "D"];
20
+ }
21
+
11
22
  export function parseChartValuesFromText(
12
23
  text: string | undefined,
13
24
  fallback: number[] = DEFAULT_CHART_VALUES
@@ -75,7 +86,7 @@ function deepMerge<T extends Record<string, any>>(base: T, patch?: Record<string
75
86
 
76
87
  export function buildChartOption(element: PanelElement): EChartsOption {
77
88
  const chartType = (element.materialType ?? "") as ChartType;
78
- const labels = element.chart?.labels?.length ? element.chart.labels : ["A", "B", "C", "D"];
89
+ const labels = resolveChartLabels(element);
79
90
  const values = resolveChartValues(element);
80
91
  const color = element.chart?.color || "#3b82f6";
81
92
  const gradientFrom = element.chart?.gradientFrom || color;
@@ -19,6 +19,7 @@ const LABELS: Record<string, string> = {
19
19
  "chart.tooltipBackgroundColor": "Tooltip 背景色",
20
20
  "chart.tooltipTextColor": "Tooltip 文字色",
21
21
  "chart.tooltipFormatter": "Tooltip 格式化",
22
+ "chart.labelsText": "图表类目",
22
23
  "chart.valuesText": "图表数值",
23
24
  "style.backgroundColor": "背景色",
24
25
  "style.backgroundImage": "背景图",
@@ -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
+ }