@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.
Files changed (75) hide show
  1. package/README.md +50 -0
  2. package/package.json +49 -0
  3. package/src/env.d.ts +62 -0
  4. package/src/index.ts +4 -0
  5. package/src/panel/VueViewOnlinePreview.vue +276 -0
  6. package/src/panel/VueViewPanel.vue +871 -0
  7. package/src/panel/components/ConfigHintIcon.vue +34 -0
  8. package/src/panel/components/ElementsLayer.vue +165 -0
  9. package/src/panel/components/MaterialPreview.vue +135 -0
  10. package/src/panel/components/MaterialSidebar.vue +526 -0
  11. package/src/panel/components/MaterialSidebarTreeNode.vue +305 -0
  12. package/src/panel/components/MoveableLayer.vue +859 -0
  13. package/src/panel/components/PanelCanvas.vue +630 -0
  14. package/src/panel/components/PanelConfigSidebar.vue +397 -0
  15. package/src/panel/components/PanelRulers.vue +177 -0
  16. package/src/panel/components/SelectLayer.vue +115 -0
  17. package/src/panel/components/ViewElementScopePanel.vue +76 -0
  18. package/src/panel/components/WorkspaceConfigSidebar.vue +147 -0
  19. package/src/panel/components/WorkspaceProjectNav.vue +192 -0
  20. package/src/panel/components/WorkspaceStageSplit.vue +258 -0
  21. package/src/panel/components/config/ConfigColorField.vue +52 -0
  22. package/src/panel/components/config/ConfigFieldGroup.vue +20 -0
  23. package/src/panel/components/config/ConfigSection.vue +50 -0
  24. package/src/panel/components/config/PanelConfigAudioSection.vue +256 -0
  25. package/src/panel/components/config/PanelConfigChartSection.vue +650 -0
  26. package/src/panel/components/config/PanelConfigGeometrySection.vue +209 -0
  27. package/src/panel/components/config/PanelConfigGridChildSpan.vue +68 -0
  28. package/src/panel/components/config/PanelConfigGridSection.vue +103 -0
  29. package/src/panel/components/config/PanelConfigImageSection.vue +136 -0
  30. package/src/panel/components/config/PanelConfigMultiSelect.vue +434 -0
  31. package/src/panel/components/config/PanelConfigNodeInfo.vue +165 -0
  32. package/src/panel/components/config/PanelConfigReferenceSection.vue +77 -0
  33. package/src/panel/components/config/PanelConfigStyleSections.vue +208 -0
  34. package/src/panel/components/config/PanelConfigTextSection.vue +195 -0
  35. package/src/panel/components/config/PanelConfigVideoSection.vue +107 -0
  36. package/src/panel/components/config/shared.ts +74 -0
  37. package/src/panel/components/elementsLayerNodes.ts +830 -0
  38. package/src/panel/components/materialSidebarData.ts +85 -0
  39. package/src/panel/components/scope-config/ScopeConfigProvider.vue +153 -0
  40. package/src/panel/components/scope-config/ScopeTemplateAutocompleteHost.vue +234 -0
  41. package/src/panel/components/scope-config/ScopeTemplatePreviewHost.vue +192 -0
  42. package/src/panel/components/scope-config/ScopeTemplatePreviewPanel.vue +42 -0
  43. package/src/panel/components/scope-config/ScopeTemplateUsageHint.vue +20 -0
  44. package/src/panel/components/scope-config/ScopeTemplateWarningsPanel.vue +63 -0
  45. package/src/panel/components/scope-config/scopeConfigContext.ts +17 -0
  46. package/src/panel/components/scope-config/useScopeConfig.ts +11 -0
  47. package/src/panel/constants/messages.ts +34 -0
  48. package/src/panel/constants/zIndex.ts +6 -0
  49. package/src/panel/hooks/usePanelElements.ts +1075 -0
  50. package/src/panel/hooks/useRafThrottledScroll.ts +25 -0
  51. package/src/panel/hooks/useWorkspaceProjects.ts +240 -0
  52. package/src/panel/lib/panel-ruler-canvas.ts +139 -0
  53. package/src/panel/library/workspace-project-cache.ts +23 -0
  54. package/src/panel/library/workspace-project-db.ts +111 -0
  55. package/src/panel/library/workspace-project-sync.ts +41 -0
  56. package/src/panel/library/workspace-snapshot.ts +30 -0
  57. package/src/panel/parseOnlinePreviewSearchParams.ts +13 -0
  58. package/src/panel/scope/view-scope-store.ts +82 -0
  59. package/src/panel/types.ts +127 -0
  60. package/src/panel/utils/chartOptionBuilder.ts +327 -0
  61. package/src/panel/utils/gridPlacement.ts +189 -0
  62. package/src/panel/utils/mappingLayerOps.ts +142 -0
  63. package/src/panel/utils/panelElementDefaults.ts +161 -0
  64. package/src/panel/utils/panelElementNodes.ts +35 -0
  65. package/src/panel/utils/panelStateIO.ts +124 -0
  66. package/src/panel/utils/scope-autocomplete.ts +114 -0
  67. package/src/panel/utils/scope-field-labels.ts +46 -0
  68. package/src/panel/utils/scope-template-chart.ts +92 -0
  69. package/src/panel/utils/scope-template-preview.ts +124 -0
  70. package/src/panel/utils/scope-template-spread.ts +229 -0
  71. package/src/panel/utils/scope-template-warnings.ts +243 -0
  72. package/src/panel/utils/scope-template.ts +97 -0
  73. package/src/panel/utils/updateElementDraft.ts +221 -0
  74. package/src/panel/viewportZoom.ts +26 -0
  75. package/src/tailwind.css +43 -0
@@ -0,0 +1,208 @@
1
+ <script setup lang="ts">
2
+ import { ref } from "vue";
3
+ import { Input, Select } from "ant-design-vue";
4
+ import type { PanelElement, PanelElementStyle } from "../../types";
5
+ import {
6
+ PANEL_MESSAGES,
7
+ readFileAsDataUrl,
8
+ uploadFileToRemote,
9
+ } from "./shared";
10
+ import ConfigColorField from "./ConfigColorField.vue";
11
+ import ConfigFieldGroup from "./ConfigFieldGroup.vue";
12
+ import ConfigSection from "./ConfigSection.vue";
13
+
14
+ const props = defineProps<{
15
+ element: PanelElement;
16
+ isEditable: boolean;
17
+ backgroundOpen: boolean;
18
+ borderOpen: boolean;
19
+ showBackground?: boolean;
20
+ showBorder?: boolean;
21
+ forceOpen?: boolean;
22
+ updateElement: (
23
+ id: string,
24
+ patch: Partial<PanelElement>,
25
+ options?: { batchId?: string; meta?: Record<string, unknown> }
26
+ ) => void;
27
+ }>();
28
+
29
+ const emit = defineEmits<{
30
+ "update:backgroundOpen": [value: boolean];
31
+ "update:borderOpen": [value: boolean];
32
+ }>();
33
+
34
+ const uploadStatus = ref("");
35
+
36
+ function patchStyle(patch: Partial<PanelElementStyle>) {
37
+ props.updateElement(props.element.id, {
38
+ style: { ...(props.element.style ?? {}), ...patch },
39
+ });
40
+ }
41
+
42
+ async function handleUploadBackgroundImage(file: File) {
43
+ const base64 = await readFileAsDataUrl(file, PANEL_MESSAGES.readImageFailed);
44
+ patchStyle({ backgroundImage: `url("${base64}")` });
45
+ uploadStatus.value = "已写入 base64";
46
+ const url = await uploadFileToRemote(file);
47
+ if (url) {
48
+ patchStyle({ backgroundImageRemoteUrl: url });
49
+ uploadStatus.value = "已上传服务器并写入 base64";
50
+ } else {
51
+ uploadStatus.value = "服务器上传失败,仅保留 base64";
52
+ }
53
+ }
54
+
55
+ function onFileChange(e: Event) {
56
+ const input = e.target as HTMLInputElement;
57
+ const file = input.files?.[0];
58
+ input.value = "";
59
+ if (file) void handleUploadBackgroundImage(file);
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <ConfigSection
65
+ v-if="showBackground !== false"
66
+ title="通用样式 / 背景"
67
+ :open="backgroundOpen"
68
+ :force-open="forceOpen"
69
+ @update:open="emit('update:backgroundOpen', $event)"
70
+ >
71
+ <ConfigFieldGroup title="背景填充">
72
+ <ConfigColorField
73
+ label="背景色"
74
+ :value="element.style?.backgroundColor ?? ''"
75
+ :disabled="!isEditable"
76
+ @update:value="(v) => patchStyle({ backgroundColor: v || undefined })"
77
+ />
78
+ <label class="block space-y-1">
79
+ <div>背景图</div>
80
+ <Input
81
+ size="small"
82
+ :value="element.style?.backgroundImage ?? ''"
83
+ :disabled="!isEditable"
84
+ placeholder='url("https://...") / linear-gradient(...)'
85
+ @update:value="(v: string) => patchStyle({ backgroundImage: v || undefined })"
86
+ />
87
+ </label>
88
+ <div class="flex items-center gap-2">
89
+ <label
90
+ class="inline-flex cursor-pointer items-center rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50"
91
+ :class="{ 'pointer-events-none opacity-50': !isEditable }"
92
+ >
93
+ 上传图片
94
+ <input
95
+ type="file"
96
+ accept="image/*"
97
+ class="hidden"
98
+ :disabled="!isEditable"
99
+ @change="onFileChange"
100
+ />
101
+ </label>
102
+ <span v-if="uploadStatus" class="text-[11px] text-gray-500">{{ uploadStatus }}</span>
103
+ </div>
104
+ </ConfigFieldGroup>
105
+ <ConfigFieldGroup title="背景布局">
106
+ <div class="grid grid-cols-2 gap-2">
107
+ <label class="block space-y-1">
108
+ <div>背景尺寸</div>
109
+ <Select
110
+ size="small"
111
+ class="w-full"
112
+ :value="element.style?.backgroundSize ?? '__none__'"
113
+ :disabled="!isEditable"
114
+ @update:value="(v) => patchStyle({ backgroundSize: v === '__none__' ? undefined : String(v) })"
115
+ >
116
+ <Select.Option value="__none__">默认</Select.Option>
117
+ <Select.Option value="cover">cover</Select.Option>
118
+ <Select.Option value="contain">contain</Select.Option>
119
+ <Select.Option value="100% 100%">100% 100%</Select.Option>
120
+ <Select.Option value="auto">auto</Select.Option>
121
+ </Select>
122
+ </label>
123
+ <label class="block space-y-1">
124
+ <div>背景位置</div>
125
+ <Select
126
+ size="small"
127
+ class="w-full"
128
+ :value="element.style?.backgroundPosition ?? '__none__'"
129
+ :disabled="!isEditable"
130
+ @update:value="(v) => patchStyle({ backgroundPosition: v === '__none__' ? undefined : String(v) })"
131
+ >
132
+ <Select.Option value="__none__">默认</Select.Option>
133
+ <Select.Option value="center">center</Select.Option>
134
+ <Select.Option value="top">top</Select.Option>
135
+ <Select.Option value="bottom">bottom</Select.Option>
136
+ <Select.Option value="left">left</Select.Option>
137
+ <Select.Option value="right">right</Select.Option>
138
+ <Select.Option value="top left">top left</Select.Option>
139
+ <Select.Option value="top right">top right</Select.Option>
140
+ <Select.Option value="bottom left">bottom left</Select.Option>
141
+ <Select.Option value="bottom right">bottom right</Select.Option>
142
+ </Select>
143
+ </label>
144
+ </div>
145
+ </ConfigFieldGroup>
146
+ </ConfigSection>
147
+
148
+ <ConfigSection
149
+ v-if="showBorder !== false"
150
+ title="通用样式 / 边框"
151
+ :open="borderOpen"
152
+ :force-open="forceOpen"
153
+ @update:open="emit('update:borderOpen', $event)"
154
+ >
155
+ <ConfigFieldGroup title="边框几何">
156
+ <div class="grid grid-cols-2 gap-2">
157
+ <label class="block space-y-1">
158
+ <div>边框宽度(px)</div>
159
+ <Input
160
+ size="small"
161
+ type="number"
162
+ :min="0"
163
+ :value="String(element.style?.borderWidth ?? 0)"
164
+ :disabled="!isEditable"
165
+ @update:value="(v) => patchStyle({ borderWidth: Math.max(0, Number(v) || 0) })"
166
+ />
167
+ </label>
168
+ <label class="block space-y-1">
169
+ <div>边框圆角(px)</div>
170
+ <Input
171
+ size="small"
172
+ type="number"
173
+ :min="0"
174
+ :value="String(element.style?.borderRadius ?? 0)"
175
+ :disabled="!isEditable"
176
+ @update:value="(v) => patchStyle({ borderRadius: Math.max(0, Number(v) || 0) })"
177
+ />
178
+ </label>
179
+ </div>
180
+ </ConfigFieldGroup>
181
+ <ConfigFieldGroup title="边框视觉">
182
+ <div class="grid grid-cols-2 gap-2">
183
+ <label class="block space-y-1">
184
+ <div>边框样式</div>
185
+ <Select
186
+ size="small"
187
+ class="w-full"
188
+ :value="element.style?.borderStyle ?? 'solid'"
189
+ :disabled="!isEditable"
190
+ @update:value="(v) => patchStyle({ borderStyle: v as PanelElementStyle['borderStyle'] })"
191
+ >
192
+ <Select.Option value="none">none</Select.Option>
193
+ <Select.Option value="solid">solid</Select.Option>
194
+ <Select.Option value="dashed">dashed</Select.Option>
195
+ <Select.Option value="dotted">dotted</Select.Option>
196
+ <Select.Option value="double">double</Select.Option>
197
+ </Select>
198
+ </label>
199
+ <ConfigColorField
200
+ label="边框颜色"
201
+ :value="element.style?.borderColor ?? ''"
202
+ :disabled="!isEditable"
203
+ @update:value="(v) => patchStyle({ borderColor: v || undefined })"
204
+ />
205
+ </div>
206
+ </ConfigFieldGroup>
207
+ </ConfigSection>
208
+ </template>
@@ -0,0 +1,195 @@
1
+ <script setup lang="ts">
2
+ import { onMounted, ref, watch } from "vue";
3
+ import { Checkbox, Input, InputNumber, Select } from "ant-design-vue";
4
+ import type { PanelElement } from "../../types";
5
+ import ConfigColorField from "./ConfigColorField.vue";
6
+ import ConfigFieldGroup from "./ConfigFieldGroup.vue";
7
+ import ConfigSection from "./ConfigSection.vue";
8
+
9
+ const props = defineProps<{
10
+ element: PanelElement;
11
+ isEditable: boolean;
12
+ open: boolean;
13
+ forceOpen?: boolean;
14
+ updateElement: (
15
+ id: string,
16
+ patch: Partial<PanelElement>,
17
+ options?: { batchId?: string; meta?: Record<string, unknown> }
18
+ ) => void;
19
+ }>();
20
+
21
+ const emit = defineEmits<{
22
+ "update:open": [value: boolean];
23
+ }>();
24
+
25
+ const textEditorRef = ref<HTMLDivElement | null>(null);
26
+
27
+ function patch(patch: Partial<PanelElement>) {
28
+ if (props.element.materialType !== "text") return;
29
+ props.updateElement(props.element.id, patch);
30
+ }
31
+
32
+ function execTextCommand(cmd: "bold" | "italic" | "underline") {
33
+ textEditorRef.value?.focus();
34
+ document.execCommand(cmd);
35
+ const next = textEditorRef.value?.innerHTML ?? "";
36
+ patch({ textHtml: next || "<p><br/></p>" });
37
+ }
38
+
39
+ function syncEditorHtml() {
40
+ if (props.element.materialType !== "text") return;
41
+ const nextHtml = props.element.textHtml ?? "<p>双击输入文本</p>";
42
+ if (textEditorRef.value && textEditorRef.value.innerHTML !== nextHtml) {
43
+ textEditorRef.value.innerHTML = nextHtml;
44
+ }
45
+ }
46
+
47
+ onMounted(syncEditorHtml);
48
+ watch(() => [props.element.id, props.element.textHtml], syncEditorHtml);
49
+ </script>
50
+
51
+ <template>
52
+ <ConfigSection
53
+ title="文本配置"
54
+ :open="open"
55
+ :force-open="forceOpen"
56
+ @update:open="emit('update:open', $event)"
57
+ >
58
+ <ConfigFieldGroup title="文本内容">
59
+ <div class="flex items-center gap-1">
60
+ <button
61
+ type="button"
62
+ class="rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50 disabled:opacity-50"
63
+ :disabled="!isEditable"
64
+ @click="execTextCommand('bold')"
65
+ >
66
+ B
67
+ </button>
68
+ <button
69
+ type="button"
70
+ class="rounded border border-gray-200 px-2 py-1 text-[11px] italic hover:bg-gray-50 disabled:opacity-50"
71
+ :disabled="!isEditable"
72
+ @click="execTextCommand('italic')"
73
+ >
74
+ I
75
+ </button>
76
+ <button
77
+ type="button"
78
+ class="rounded border border-gray-200 px-2 py-1 text-[11px] underline hover:bg-gray-50 disabled:opacity-50"
79
+ :disabled="!isEditable"
80
+ @click="execTextCommand('underline')"
81
+ >
82
+ U
83
+ </button>
84
+ </div>
85
+ <div
86
+ ref="textEditorRef"
87
+ class="min-h-[120px] rounded border border-gray-200 bg-white px-2 py-1.5 text-xs leading-6 outline-none"
88
+ :contenteditable="isEditable"
89
+ :style="{
90
+ fontFamily: element.textFontFamily || undefined,
91
+ fontSize: element.textFontSize ? `${element.textFontSize}px` : undefined,
92
+ fontWeight: element.textFontWeight || undefined,
93
+ color: element.textColor || undefined,
94
+ lineHeight: element.textLineHeight ? String(element.textLineHeight) : undefined,
95
+ textAlign: element.textAlign ?? 'left',
96
+ }"
97
+ @input="(e) => {
98
+ const nextHtml = (e.target as HTMLDivElement).innerHTML;
99
+ patch({ textHtml: nextHtml || '<p><br/></p>' });
100
+ }"
101
+ />
102
+ </ConfigFieldGroup>
103
+ <ConfigFieldGroup title="文字样式">
104
+ <label class="block space-y-1">
105
+ <div>字体</div>
106
+ <Input
107
+ size="small"
108
+ :value="element.textFontFamily ?? ''"
109
+ :disabled="!isEditable"
110
+ placeholder="如:Inter, PingFang SC, Microsoft YaHei"
111
+ @update:value="(v: string) => patch({ textFontFamily: v || undefined })"
112
+ />
113
+ </label>
114
+ <div class="grid grid-cols-2 gap-2">
115
+ <label class="block space-y-1">
116
+ <div>字号(px)</div>
117
+ <InputNumber
118
+ size="small"
119
+ class="w-full"
120
+ :min="8"
121
+ :max="200"
122
+ :value="element.textFontSize ?? 14"
123
+ :disabled="!isEditable"
124
+ @update:value="(v) => { const n = Number(v); if (!Number.isNaN(n)) patch({ textFontSize: Math.max(8, n) }); }"
125
+ />
126
+ </label>
127
+ <label class="block space-y-1">
128
+ <div>字重</div>
129
+ <Select
130
+ size="small"
131
+ class="w-full"
132
+ :value="element.textFontWeight ?? '400'"
133
+ :disabled="!isEditable"
134
+ @update:value="(v) => patch({ textFontWeight: String(v) })"
135
+ >
136
+ <Select.Option value="300">300</Select.Option>
137
+ <Select.Option value="400">400</Select.Option>
138
+ <Select.Option value="500">500</Select.Option>
139
+ <Select.Option value="600">600</Select.Option>
140
+ <Select.Option value="700">700</Select.Option>
141
+ </Select>
142
+ </label>
143
+ </div>
144
+ <div class="grid grid-cols-2 gap-2">
145
+ <label class="block space-y-1">
146
+ <div>对齐</div>
147
+ <Select
148
+ size="small"
149
+ class="w-full"
150
+ :value="element.textAlign ?? 'left'"
151
+ :disabled="!isEditable"
152
+ @update:value="(v) => patch({ textAlign: v as PanelElement['textAlign'] })"
153
+ >
154
+ <Select.Option value="left">左对齐</Select.Option>
155
+ <Select.Option value="center">居中</Select.Option>
156
+ <Select.Option value="right">右对齐</Select.Option>
157
+ <Select.Option value="justify">两端对齐</Select.Option>
158
+ </Select>
159
+ </label>
160
+ <label class="block space-y-1">
161
+ <div>行高</div>
162
+ <InputNumber
163
+ size="small"
164
+ class="w-full"
165
+ :min="1"
166
+ :max="3"
167
+ :step="0.1"
168
+ :value="element.textLineHeight ?? 1.6"
169
+ :disabled="!isEditable"
170
+ @update:value="(v) => {
171
+ const n = Number(v);
172
+ if (!Number.isNaN(n)) patch({ textLineHeight: Math.min(3, Math.max(1, n)) });
173
+ }"
174
+ />
175
+ </label>
176
+ </div>
177
+ <ConfigColorField
178
+ label="文字颜色"
179
+ :value="element.textColor ?? ''"
180
+ :disabled="!isEditable"
181
+ @update:value="(v) => patch({ textColor: v || undefined })"
182
+ />
183
+ </ConfigFieldGroup>
184
+ <ConfigFieldGroup title="输入能力">
185
+ <label class="flex items-center gap-2">
186
+ <Checkbox
187
+ :checked="element.textAllowInput ?? true"
188
+ :disabled="!isEditable"
189
+ @update:checked="(v) => patch({ textAllowInput: v !== false })"
190
+ />
191
+ <span>允许在画布内直接输入(默认开启)</span>
192
+ </label>
193
+ </ConfigFieldGroup>
194
+ </ConfigSection>
195
+ </template>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ import { ref } from "vue";
3
+ import { Checkbox, Input } from "ant-design-vue";
4
+ import type { PanelElement } from "../../types";
5
+ import {
6
+ PANEL_MESSAGES,
7
+ readFileAsDataUrl,
8
+ uploadFileToRemote,
9
+ } from "./shared";
10
+ import ConfigFieldGroup from "./ConfigFieldGroup.vue";
11
+ import ConfigSection from "./ConfigSection.vue";
12
+
13
+ const props = defineProps<{
14
+ element: PanelElement;
15
+ isEditable: boolean;
16
+ open: boolean;
17
+ forceOpen?: boolean;
18
+ updateElement: (
19
+ id: string,
20
+ patch: Partial<PanelElement>,
21
+ options?: { batchId?: string; meta?: Record<string, unknown> }
22
+ ) => void;
23
+ }>();
24
+
25
+ const emit = defineEmits<{
26
+ "update:open": [value: boolean];
27
+ }>();
28
+
29
+ const videoStatus = ref("");
30
+
31
+ function patch(patch: Partial<PanelElement>) {
32
+ if (props.element.materialType !== "video") return;
33
+ props.updateElement(props.element.id, patch);
34
+ }
35
+
36
+ async function handleUploadVideoFile(file: File) {
37
+ const base64 = await readFileAsDataUrl(file, PANEL_MESSAGES.readVideoFailed);
38
+ patch({ videoSrc: base64 });
39
+ videoStatus.value = PANEL_MESSAGES.videoLocalSaved;
40
+ const url = await uploadFileToRemote(file);
41
+ if (url) {
42
+ patch({ videoRemoteUrl: url });
43
+ videoStatus.value = PANEL_MESSAGES.videoRemoteUploaded;
44
+ } else {
45
+ videoStatus.value = PANEL_MESSAGES.videoServerUploadFailed;
46
+ }
47
+ }
48
+
49
+ function onVideoFileChange(e: Event) {
50
+ const file = (e.target as HTMLInputElement).files?.[0];
51
+ (e.target as HTMLInputElement).value = "";
52
+ if (file) void handleUploadVideoFile(file);
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <ConfigSection
58
+ title="视频配置"
59
+ :open="open"
60
+ :force-open="forceOpen"
61
+ @update:open="emit('update:open', $event)"
62
+ >
63
+ <ConfigFieldGroup title="视频来源">
64
+ <label class="block space-y-1">
65
+ <div>视频 URL</div>
66
+ <Input
67
+ size="small"
68
+ :value="element.videoRemoteUrl ?? ''"
69
+ :disabled="!isEditable"
70
+ placeholder="https://example.com/video.mp4"
71
+ @update:value="(v: string) => patch({
72
+ videoRemoteUrl: v || undefined,
73
+ videoSrc: v || element.videoSrc,
74
+ })"
75
+ />
76
+ </label>
77
+ <div class="flex items-center gap-2">
78
+ <label
79
+ class="inline-flex cursor-pointer items-center rounded border border-gray-200 px-2 py-1 text-[11px] hover:bg-gray-50"
80
+ :class="{ 'pointer-events-none opacity-50': !isEditable }"
81
+ >
82
+ 上传视频
83
+ <input type="file" accept="video/*" class="hidden" :disabled="!isEditable" @change="onVideoFileChange" />
84
+ </label>
85
+ </div>
86
+ <div
87
+ v-if="videoStatus"
88
+ class="rounded border border-gray-200/60 bg-white px-2 py-1.5 text-[11px] text-gray-500"
89
+ >
90
+ {{ videoStatus }}
91
+ </div>
92
+ <video
93
+ controls
94
+ class="h-36 w-full rounded border border-gray-200/60 bg-black/80 object-contain"
95
+ :src="element.videoSrc || element.videoRemoteUrl || ''"
96
+ />
97
+ <label class="flex items-center gap-2">
98
+ <Checkbox
99
+ :checked="element.mediaAutoPauseOnEdit !== false"
100
+ :disabled="!isEditable"
101
+ @update:checked="(v) => patch({ mediaAutoPauseOnEdit: v !== false })"
102
+ />
103
+ <span>编辑时自动暂停媒体</span>
104
+ </label>
105
+ </ConfigFieldGroup>
106
+ </ConfigSection>
107
+ </template>
@@ -0,0 +1,74 @@
1
+ import type { PanelChartConfig } from "../../types";
2
+ import { PANEL_MESSAGES } from "../../constants/messages";
3
+
4
+ export function mergeOptionPatch(
5
+ base: Record<string, unknown> | undefined,
6
+ patch: Record<string, unknown>
7
+ ): Record<string, unknown> {
8
+ const output: Record<string, unknown> = { ...(base ?? {}) };
9
+ for (const [key, value] of Object.entries(patch)) {
10
+ const prev = output[key];
11
+ if (
12
+ value &&
13
+ typeof value === "object" &&
14
+ !Array.isArray(value) &&
15
+ prev &&
16
+ typeof prev === "object" &&
17
+ !Array.isArray(prev)
18
+ ) {
19
+ output[key] = mergeOptionPatch(
20
+ prev as Record<string, unknown>,
21
+ value as Record<string, unknown>
22
+ );
23
+ } else {
24
+ output[key] = value;
25
+ }
26
+ }
27
+ return output;
28
+ }
29
+
30
+ export function sectionMatchesSearch(
31
+ title: string,
32
+ searchTerms: string[],
33
+ normalizedSearch: string,
34
+ hasSearch: boolean
35
+ ): boolean {
36
+ if (!hasSearch) return true;
37
+ return [title, ...searchTerms].some((term) =>
38
+ term.toLowerCase().includes(normalizedSearch)
39
+ );
40
+ }
41
+
42
+ export function readFileAsDataUrl(
43
+ file: File,
44
+ errorMessage: string
45
+ ): Promise<string> {
46
+ return new Promise((resolve, reject) => {
47
+ const reader = new FileReader();
48
+ reader.onload = () => resolve(String(reader.result ?? ""));
49
+ reader.onerror = () => reject(new Error(errorMessage));
50
+ reader.readAsDataURL(file);
51
+ });
52
+ }
53
+
54
+ export async function uploadFileToRemote(file: File): Promise<string | undefined> {
55
+ try {
56
+ const form = new FormData();
57
+ form.append("file", file);
58
+ const resp = await fetch("/api/upload", { method: "POST", body: form });
59
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
60
+ const data = (await resp.json()) as { url?: string };
61
+ return data.url;
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ export function patchChart(
68
+ chart: PanelChartConfig | undefined,
69
+ patch: Partial<PanelChartConfig>
70
+ ): PanelChartConfig {
71
+ return { ...(chart ?? {}), ...patch };
72
+ }
73
+
74
+ export { PANEL_MESSAGES };