@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,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 };
|