inicontent 1.0.2 → 1.0.3
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/LICENSE +203 -21
- package/NOTICE +11 -0
- package/README.md +4 -0
- package/app/app.vue +0 -16
- package/app/assets/main.css +2 -1
- package/app/components/Dashboard/Grid.vue +6 -3
- package/app/components/Dashboard/View.vue +551 -80
- package/app/components/Dashboard/Widget/Table.vue +3 -1
- package/app/components/Form/Drawer.vue +4 -1
- package/app/components/Header.vue +1 -1
- package/app/components/Table/TranslateDrawer.vue +1 -1
- package/app/composables/databaseCookies.ts +1 -55
- package/app/composables/openDrawer.ts +3 -2
- package/app/composables/useDashboardData.ts +31 -0
- package/app/middleware/database.ts +0 -1
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +168 -18
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +1 -1
- package/package.json +2 -1
- package/app/components/Dashboard/Editor.vue +0 -221
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +0 -66
|
@@ -1,131 +1,602 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
<!-- Edit mode: draggable grid with per-widget controls -->
|
|
3
|
+
<div v-if="editMode" class="dashboard-edit">
|
|
4
|
+
<VueDraggable
|
|
5
|
+
v-if="editWidgets.length"
|
|
6
|
+
v-model="editWidgets"
|
|
7
|
+
item-key="id"
|
|
8
|
+
handle=".drag-handle"
|
|
9
|
+
:animation="200"
|
|
10
|
+
:custom-update="ignoreReorder"
|
|
11
|
+
@start="onDragStart"
|
|
12
|
+
@end="onDragEnd"
|
|
13
|
+
class="dashboard-grid"
|
|
14
|
+
>
|
|
15
|
+
<div
|
|
16
|
+
v-for="(widget, index) in editWidgets"
|
|
17
|
+
:key="widget.id"
|
|
18
|
+
:data-widget-id="widget.id"
|
|
19
|
+
class="dashboard-grid-item"
|
|
20
|
+
:style="widgetGridStyle(widget)"
|
|
21
|
+
>
|
|
22
|
+
<NCard
|
|
23
|
+
size="small"
|
|
24
|
+
:title="widget.title || t('untitledWidget')"
|
|
25
|
+
content-style="flex: 1; display: flex; align-items: center"
|
|
26
|
+
:style="
|
|
27
|
+
widget.color
|
|
28
|
+
? `border-inline-start: 3px solid ${widget.color}`
|
|
29
|
+
: ''
|
|
30
|
+
"
|
|
31
|
+
>
|
|
32
|
+
<template #header-extra>
|
|
33
|
+
<NFlex :size="4" align="center">
|
|
34
|
+
<span
|
|
35
|
+
class="drag-handle"
|
|
36
|
+
style="
|
|
37
|
+
cursor: grab;
|
|
38
|
+
display: inline-flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
padding: 4px;
|
|
41
|
+
"
|
|
42
|
+
>
|
|
43
|
+
<NIcon :size="16">
|
|
44
|
+
<Icon name="tabler:grip-vertical" />
|
|
45
|
+
</NIcon>
|
|
46
|
+
</span>
|
|
47
|
+
<NSelect
|
|
48
|
+
size="tiny"
|
|
49
|
+
:value="widget.size || 'small'"
|
|
50
|
+
:options="sizeOptions"
|
|
51
|
+
style="width: 100px"
|
|
52
|
+
@update:value="(size) => updateWidth(widget, size)"
|
|
53
|
+
/>
|
|
54
|
+
<NButton size="tiny" quaternary @click="openEditor(index)">
|
|
55
|
+
<template #icon>
|
|
56
|
+
<NIcon>
|
|
57
|
+
<Icon name="tabler:edit" />
|
|
58
|
+
</NIcon>
|
|
59
|
+
</template>
|
|
60
|
+
</NButton>
|
|
61
|
+
<NButton size="tiny" quaternary type="error" @click="removeWidget(index)">
|
|
62
|
+
<template #icon>
|
|
63
|
+
<NIcon>
|
|
64
|
+
<Icon name="tabler:trash" />
|
|
65
|
+
</NIcon>
|
|
66
|
+
</template>
|
|
67
|
+
</NButton>
|
|
68
|
+
</NFlex>
|
|
69
|
+
</template>
|
|
70
|
+
<component
|
|
71
|
+
:is="widgetRenderer(widget.type)"
|
|
72
|
+
:widget="widget"
|
|
73
|
+
:database-slug="databaseSlug"
|
|
74
|
+
:date-range-override="dateRangeOverride"
|
|
75
|
+
/>
|
|
76
|
+
</NCard>
|
|
77
|
+
</div>
|
|
78
|
+
</VueDraggable>
|
|
79
|
+
|
|
80
|
+
<NEmpty v-if="!editWidgets.length" :description="t('noWidgets')" />
|
|
81
|
+
|
|
82
|
+
<NModal
|
|
83
|
+
v-model:show="showWidgetModal"
|
|
84
|
+
preset="card"
|
|
85
|
+
:title="pendingNewWidget ? t('addWidget') : t('editWidget')"
|
|
86
|
+
style="width: min(680px, 90vw)"
|
|
87
|
+
>
|
|
88
|
+
<DashboardWidgetEditor v-if="editingWidget" v-model="editingWidget" />
|
|
89
|
+
<template #footer>
|
|
90
|
+
<NFlex justify="end">
|
|
91
|
+
<NButton @click="closeEditor">{{ t("cancel") }}</NButton>
|
|
92
|
+
<NButton type="primary" @click="confirmWidget">{{ t("confirm") }}</NButton>
|
|
93
|
+
</NFlex>
|
|
94
|
+
</template>
|
|
95
|
+
</NModal>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<!-- View mode: read-only grid, same layout rules as the editor -->
|
|
99
|
+
<div
|
|
100
|
+
v-else-if="dashboard.widgets?.length"
|
|
101
|
+
class="dashboard-grid"
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
4
104
|
v-for="widget in dashboard.widgets"
|
|
5
105
|
:key="widget.id + dateRangeKey"
|
|
6
|
-
|
|
106
|
+
class="dashboard-grid-item"
|
|
107
|
+
:style="widgetGridStyle(widget)"
|
|
7
108
|
>
|
|
8
109
|
<NCard
|
|
9
110
|
:title="widget.title"
|
|
10
111
|
size="small"
|
|
11
|
-
|
|
112
|
+
content-style="flex: 1; display: flex; align-items: center"
|
|
113
|
+
:style="
|
|
114
|
+
widget.color
|
|
115
|
+
? `border-inline-start: 3px solid ${widget.color}`
|
|
116
|
+
: ''
|
|
117
|
+
"
|
|
12
118
|
>
|
|
13
119
|
<template #header-extra>
|
|
14
120
|
<NTag size="small" :bordered="false" type="info">
|
|
15
121
|
{{ t(widget.type) }}
|
|
16
122
|
</NTag>
|
|
17
123
|
</template>
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
:widget="widget"
|
|
21
|
-
:database-slug="databaseSlug"
|
|
22
|
-
:date-range-override="dateRangeOverride"
|
|
23
|
-
/>
|
|
24
|
-
<DashboardWidgetLineChart
|
|
25
|
-
v-else-if="widget.type === 'line'"
|
|
26
|
-
:widget="widget"
|
|
27
|
-
:database-slug="databaseSlug"
|
|
28
|
-
:date-range-override="dateRangeOverride"
|
|
29
|
-
/>
|
|
30
|
-
<DashboardWidgetBarChart
|
|
31
|
-
v-else-if="widget.type === 'bar'"
|
|
32
|
-
:widget="widget"
|
|
33
|
-
:database-slug="databaseSlug"
|
|
34
|
-
:date-range-override="dateRangeOverride"
|
|
35
|
-
/>
|
|
36
|
-
<DashboardWidgetPieChart
|
|
37
|
-
v-else-if="widget.type === 'pie'"
|
|
38
|
-
:widget="widget"
|
|
39
|
-
:database-slug="databaseSlug"
|
|
40
|
-
:date-range-override="dateRangeOverride"
|
|
41
|
-
/>
|
|
42
|
-
<DashboardWidgetTable
|
|
43
|
-
v-else-if="widget.type === 'table'"
|
|
124
|
+
<component
|
|
125
|
+
:is="widgetRenderer(widget.type)"
|
|
44
126
|
:widget="widget"
|
|
45
127
|
:database-slug="databaseSlug"
|
|
46
128
|
:date-range-override="dateRangeOverride"
|
|
47
129
|
/>
|
|
48
130
|
</NCard>
|
|
49
|
-
</
|
|
50
|
-
</
|
|
51
|
-
<NEmpty v-
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<NEmpty v-else :description="t('noWidgets')" />
|
|
52
134
|
</template>
|
|
53
135
|
|
|
54
136
|
<script lang="ts" setup>
|
|
137
|
+
import { VueDraggable } from "vue-draggable-plus";
|
|
138
|
+
import {
|
|
139
|
+
DashboardWidgetBarChart,
|
|
140
|
+
DashboardWidgetCounter,
|
|
141
|
+
DashboardWidgetLineChart,
|
|
142
|
+
DashboardWidgetPieChart,
|
|
143
|
+
DashboardWidgetTable,
|
|
144
|
+
} from "#components";
|
|
145
|
+
|
|
55
146
|
const props = defineProps<{
|
|
56
147
|
dashboard: Dashboard;
|
|
57
148
|
databaseSlug: string;
|
|
58
149
|
dateRangeOverride?: WidgetDateRange;
|
|
150
|
+
editMode?: boolean;
|
|
59
151
|
}>();
|
|
60
152
|
|
|
153
|
+
const editWidgets = defineModel<Widget[]>("widgets", { default: () => [] });
|
|
154
|
+
|
|
61
155
|
const dateRangeKey = computed(() => props.dateRangeOverride ?? "default");
|
|
62
156
|
|
|
63
|
-
const
|
|
157
|
+
const editingWidgetIndex = ref<number | null>(null);
|
|
158
|
+
const showWidgetModal = ref(false);
|
|
159
|
+
const pendingNewWidget = ref<Widget | null>(null);
|
|
64
160
|
|
|
65
|
-
const
|
|
66
|
-
() =>
|
|
67
|
-
(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
161
|
+
const editingWidget = computed({
|
|
162
|
+
get: () => {
|
|
163
|
+
if (pendingNewWidget.value) return pendingNewWidget.value;
|
|
164
|
+
return editingWidgetIndex.value !== null
|
|
165
|
+
? (editWidgets.value[editingWidgetIndex.value] ?? null)
|
|
166
|
+
: null;
|
|
167
|
+
},
|
|
168
|
+
set: (v) => {
|
|
169
|
+
if (pendingNewWidget.value) {
|
|
170
|
+
pendingNewWidget.value = v;
|
|
171
|
+
} else if (editingWidgetIndex.value !== null && v) {
|
|
172
|
+
editWidgets.value[editingWidgetIndex.value] = v;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
});
|
|
71
176
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
177
|
+
watch(editingWidgetIndex, (v) => {
|
|
178
|
+
showWidgetModal.value = v !== null;
|
|
179
|
+
});
|
|
76
180
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
} else if (windowWidth.value >= 1920) {
|
|
82
|
-
maxPerLine = 7;
|
|
83
|
-
} else if (windowWidth.value >= 1600) {
|
|
84
|
-
maxPerLine = 6;
|
|
85
|
-
} else if (windowWidth.value >= 1366) {
|
|
86
|
-
maxPerLine = 5;
|
|
87
|
-
} else if (windowWidth.value >= 1200) {
|
|
88
|
-
maxPerLine = 4;
|
|
181
|
+
watch(showWidgetModal, (v) => {
|
|
182
|
+
if (!v) {
|
|
183
|
+
editingWidgetIndex.value = null;
|
|
184
|
+
pendingNewWidget.value = null;
|
|
89
185
|
}
|
|
186
|
+
});
|
|
90
187
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
function addWidget() {
|
|
189
|
+
pendingNewWidget.value = {
|
|
190
|
+
id: `w_${Date.now()}`,
|
|
191
|
+
type: "counter",
|
|
192
|
+
title: "",
|
|
193
|
+
table: "",
|
|
194
|
+
operation: "count",
|
|
195
|
+
size: "small",
|
|
196
|
+
dateRange: "all",
|
|
197
|
+
searchArray: { and: [[null, "=", null]] },
|
|
198
|
+
};
|
|
199
|
+
showWidgetModal.value = true;
|
|
200
|
+
}
|
|
94
201
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
202
|
+
function removeWidget(index: number) {
|
|
203
|
+
editWidgets.value.splice(index, 1);
|
|
204
|
+
}
|
|
98
205
|
|
|
99
|
-
|
|
100
|
-
|
|
206
|
+
function openEditor(index: number) {
|
|
207
|
+
editingWidgetIndex.value = index;
|
|
208
|
+
}
|
|
101
209
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
210
|
+
function closeEditor() {
|
|
211
|
+
showWidgetModal.value = false;
|
|
212
|
+
}
|
|
105
213
|
|
|
106
|
-
function
|
|
107
|
-
|
|
214
|
+
function confirmWidget() {
|
|
215
|
+
// Newly added widgets must have a source table before they can render.
|
|
216
|
+
if (pendingNewWidget.value) {
|
|
217
|
+
if (!pendingNewWidget.value.table) {
|
|
218
|
+
window.$message.error(t("selectTable"));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
editWidgets.value.push(pendingNewWidget.value);
|
|
222
|
+
}
|
|
223
|
+
showWidgetModal.value = false;
|
|
108
224
|
}
|
|
109
225
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
226
|
+
function updateWidth(widget: Widget, size: string) {
|
|
227
|
+
if (size === "small" || size === "medium" || size === "large") {
|
|
228
|
+
widget.size = size;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
114
231
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
232
|
+
const sizeOptions = [
|
|
233
|
+
{ label: t("small"), value: "small" },
|
|
234
|
+
{ label: t("medium"), value: "medium" },
|
|
235
|
+
{ label: t("large"), value: "large" },
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
const widgetRenderers = {
|
|
239
|
+
counter: DashboardWidgetCounter,
|
|
240
|
+
line: DashboardWidgetLineChart,
|
|
241
|
+
bar: DashboardWidgetBarChart,
|
|
242
|
+
pie: DashboardWidgetPieChart,
|
|
243
|
+
table: DashboardWidgetTable,
|
|
244
|
+
} as const;
|
|
245
|
+
|
|
246
|
+
function widgetRenderer(
|
|
247
|
+
type: Widget["type"],
|
|
248
|
+
): (typeof widgetRenderers)[keyof typeof widgetRenderers] | null {
|
|
249
|
+
return widgetRenderers[type] ?? null;
|
|
250
|
+
}
|
|
118
251
|
|
|
119
252
|
function getSpan(widget: Widget): number {
|
|
120
|
-
// Tables always get the full row so columns can breathe.
|
|
121
|
-
if (widget.type === "table") return 24;
|
|
122
253
|
switch (widget.size) {
|
|
123
254
|
case "large":
|
|
124
255
|
return 24;
|
|
125
256
|
case "medium":
|
|
126
|
-
return
|
|
257
|
+
return 16;
|
|
127
258
|
default:
|
|
128
|
-
return
|
|
259
|
+
return 8;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Tall widgets (charts/tables) height exceeds a counter, so the layout lets
|
|
264
|
+
// short widgets stack in the free columns beside them.
|
|
265
|
+
function isTall(widget: Widget): boolean {
|
|
266
|
+
return widget.type !== "counter";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const MAX_STACKED_SHORTS = 4;
|
|
270
|
+
|
|
271
|
+
type WidgetPlacement = {
|
|
272
|
+
colStart: number;
|
|
273
|
+
rowStart: number;
|
|
274
|
+
colSpan: number;
|
|
275
|
+
rowSpan: number;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Explicit 24-column layout. A tall widget (chart/table) and the short
|
|
279
|
+
// widgets that fit in its remaining width form one block: the tall spans
|
|
280
|
+
// one row per stacked short, and the shorts stack one-per-row on the free
|
|
281
|
+
// side — regardless of whether they come before or after the tall in the
|
|
282
|
+
// list, and direction-safe (RTL/LTR use logical columns).
|
|
283
|
+
function computePlacements(widgets: Widget[]): Record<string, WidgetPlacement> {
|
|
284
|
+
const result: Record<string, WidgetPlacement> = {};
|
|
285
|
+
const n = widgets.length;
|
|
286
|
+
|
|
287
|
+
// Place a run of widgets on successive flow lines, left to right
|
|
288
|
+
// (logical), wrapping when a line overflows 24 columns.
|
|
289
|
+
const placeFlow = (items: Widget[], startRow: number): number => {
|
|
290
|
+
let row = startRow;
|
|
291
|
+
let col = 1;
|
|
292
|
+
for (const item of items) {
|
|
293
|
+
const span = getSpan(item);
|
|
294
|
+
if (col + span - 1 > 24) {
|
|
295
|
+
row += 1;
|
|
296
|
+
col = 1;
|
|
297
|
+
}
|
|
298
|
+
result[item.id] = {
|
|
299
|
+
colStart: col,
|
|
300
|
+
rowStart: row,
|
|
301
|
+
colSpan: span,
|
|
302
|
+
rowSpan: 1,
|
|
303
|
+
};
|
|
304
|
+
col += span;
|
|
305
|
+
}
|
|
306
|
+
return row + 1;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
let cursorRow = 1;
|
|
310
|
+
let i = 0;
|
|
311
|
+
while (i < n) {
|
|
312
|
+
const widget = widgets[i];
|
|
313
|
+
|
|
314
|
+
if (!isTall(widget)) {
|
|
315
|
+
// Collect a contiguous run of shorts that fit on one flow line.
|
|
316
|
+
const run = [widget];
|
|
317
|
+
let runWidth = getSpan(widget);
|
|
318
|
+
let j = i + 1;
|
|
319
|
+
while (
|
|
320
|
+
j < n &&
|
|
321
|
+
!isTall(widgets[j]) &&
|
|
322
|
+
runWidth + getSpan(widgets[j]) <= 24
|
|
323
|
+
) {
|
|
324
|
+
run.push(widgets[j]);
|
|
325
|
+
runWidth += getSpan(widgets[j]);
|
|
326
|
+
j += 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A tall widget right after the run can host it: stack the run
|
|
330
|
+
// in its own column and let the tall span the hosting rows.
|
|
331
|
+
if (j < n && isTall(widgets[j])) {
|
|
332
|
+
const tall = widgets[j];
|
|
333
|
+
const remaining = 24 - getSpan(tall);
|
|
334
|
+
if (remaining > 0 && run.every((s) => getSpan(s) <= remaining)) {
|
|
335
|
+
const rowSpan = Math.max(2, Math.min(run.length, MAX_STACKED_SHORTS));
|
|
336
|
+
const assignCount = Math.min(run.length, rowSpan);
|
|
337
|
+
const maxShortSpan = Math.max(
|
|
338
|
+
...run.slice(0, assignCount).map(getSpan),
|
|
339
|
+
);
|
|
340
|
+
run.slice(0, assignCount).forEach((s, t) => {
|
|
341
|
+
result[s.id] = {
|
|
342
|
+
colStart: 1,
|
|
343
|
+
rowStart: cursorRow + t,
|
|
344
|
+
colSpan: getSpan(s),
|
|
345
|
+
rowSpan: 1,
|
|
346
|
+
};
|
|
347
|
+
});
|
|
348
|
+
result[tall.id] = {
|
|
349
|
+
colStart: 1 + maxShortSpan,
|
|
350
|
+
rowStart: cursorRow,
|
|
351
|
+
colSpan: getSpan(tall),
|
|
352
|
+
rowSpan,
|
|
353
|
+
};
|
|
354
|
+
cursorRow += rowSpan;
|
|
355
|
+
const leftover = run.slice(assignCount);
|
|
356
|
+
if (leftover.length) cursorRow = placeFlow(leftover, cursorRow);
|
|
357
|
+
i = j + 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Unhosted shorts: flow normally.
|
|
363
|
+
cursorRow = placeFlow(run, cursorRow);
|
|
364
|
+
i = j;
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Tall widget, possibly followed by shorts that fit beside it.
|
|
369
|
+
const remaining = 24 - getSpan(widget);
|
|
370
|
+
const shorts: Widget[] = [];
|
|
371
|
+
let j = i + 1;
|
|
372
|
+
while (
|
|
373
|
+
j < n &&
|
|
374
|
+
!isTall(widgets[j]) &&
|
|
375
|
+
remaining > 0 &&
|
|
376
|
+
getSpan(widgets[j]) <= remaining
|
|
377
|
+
) {
|
|
378
|
+
shorts.push(widgets[j]);
|
|
379
|
+
j += 1;
|
|
380
|
+
}
|
|
381
|
+
const rowSpan = Math.max(2, Math.min(shorts.length, MAX_STACKED_SHORTS));
|
|
382
|
+
const assignCount = Math.min(shorts.length, rowSpan);
|
|
383
|
+
result[widget.id] = {
|
|
384
|
+
colStart: 1,
|
|
385
|
+
rowStart: cursorRow,
|
|
386
|
+
colSpan: getSpan(widget),
|
|
387
|
+
rowSpan,
|
|
388
|
+
};
|
|
389
|
+
shorts.slice(0, assignCount).forEach((s, t) => {
|
|
390
|
+
result[s.id] = {
|
|
391
|
+
colStart: 1 + getSpan(widget),
|
|
392
|
+
rowStart: cursorRow + t,
|
|
393
|
+
colSpan: getSpan(s),
|
|
394
|
+
rowSpan: 1,
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
cursorRow += rowSpan;
|
|
398
|
+
const leftover = shorts.slice(assignCount);
|
|
399
|
+
if (leftover.length) cursorRow = placeFlow(leftover, cursorRow);
|
|
400
|
+
i = j;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// While a drag is in progress the layout is frozen: vue-draggable-plus would
|
|
407
|
+
// otherwise reorder the model mid-drag and recompute placements under the
|
|
408
|
+
// pointer, making tall (multi-row) widgets jump and breaking the drop.
|
|
409
|
+
const dragPlacements = ref<Record<string, WidgetPlacement> | null>(null);
|
|
410
|
+
const dragStartOrder = ref<Widget[]>([]);
|
|
411
|
+
const draggedWidgetId = ref<string | null>(null);
|
|
412
|
+
|
|
413
|
+
const placements = computed<Record<string, WidgetPlacement>>(() => {
|
|
414
|
+
if (dragPlacements.value) return dragPlacements.value;
|
|
415
|
+
const widgets = props.editMode
|
|
416
|
+
? editWidgets.value
|
|
417
|
+
: (props.dashboard.widgets ?? []);
|
|
418
|
+
return computePlacements(widgets);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
type DragEventPayload = {
|
|
422
|
+
oldIndex?: number;
|
|
423
|
+
originalEvent?: {
|
|
424
|
+
clientX?: number;
|
|
425
|
+
clientY?: number;
|
|
426
|
+
touches?: Array<{ clientX?: number; clientY?: number }>;
|
|
427
|
+
changedTouches?: Array<{ clientX?: number; clientY?: number }>;
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Prevent vue-draggable-plus from reordering the model while dragging. The
|
|
432
|
+
// drop position is translated to a list order ourselves (see onDragEnd), so a
|
|
433
|
+
// live reorder would only fight the frozen layout.
|
|
434
|
+
function ignoreReorder() {}
|
|
435
|
+
|
|
436
|
+
function onDragStart(evt: DragEventPayload) {
|
|
437
|
+
dragStartOrder.value = [...editWidgets.value];
|
|
438
|
+
draggedWidgetId.value =
|
|
439
|
+
evt.oldIndex != null
|
|
440
|
+
? (dragStartOrder.value[evt.oldIndex]?.id ?? null)
|
|
441
|
+
: null;
|
|
442
|
+
dragPlacements.value = computePlacements(editWidgets.value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function onDragEnd(evt: DragEventPayload) {
|
|
446
|
+
const id = draggedWidgetId.value;
|
|
447
|
+
const pt = evt.originalEvent;
|
|
448
|
+
const clientX =
|
|
449
|
+
pt?.touches?.[0]?.clientX ??
|
|
450
|
+
pt?.changedTouches?.[0]?.clientX ??
|
|
451
|
+
pt?.clientX;
|
|
452
|
+
const clientY =
|
|
453
|
+
pt?.touches?.[0]?.clientY ??
|
|
454
|
+
pt?.changedTouches?.[0]?.clientY ??
|
|
455
|
+
pt?.clientY;
|
|
456
|
+
if (id && clientX != null && clientY != null) {
|
|
457
|
+
reorderToDropPoint(id, clientX, clientY);
|
|
129
458
|
}
|
|
459
|
+
draggedWidgetId.value = null;
|
|
460
|
+
dragStartOrder.value = [];
|
|
461
|
+
dragPlacements.value = null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function rectContainsPoint(rect: DOMRect, x: number, y: number) {
|
|
465
|
+
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function rectDistance(rect: DOMRect, x: number, y: number) {
|
|
469
|
+
const dx = Math.max(rect.left - x, 0, x - rect.right);
|
|
470
|
+
const dy = Math.max(rect.top - y, 0, y - rect.bottom);
|
|
471
|
+
return Math.hypot(dx, dy);
|
|
130
472
|
}
|
|
473
|
+
|
|
474
|
+
// SortableJS maps a drop to a DOM index, but with an auto layout a stacked
|
|
475
|
+
// column has no "before" zone, so dropping a tall widget over the counters
|
|
476
|
+
// keeps it where it was. Instead, translate the pointer position into a list
|
|
477
|
+
// order: drop onto a column and the widget lands on that column's side.
|
|
478
|
+
function reorderToDropPoint(
|
|
479
|
+
draggedId: string,
|
|
480
|
+
clientX: number,
|
|
481
|
+
clientY: number,
|
|
482
|
+
) {
|
|
483
|
+
const items = [...editWidgets.value];
|
|
484
|
+
const curIndex = items.findIndex((w) => w.id === draggedId);
|
|
485
|
+
if (curIndex === -1) return;
|
|
486
|
+
|
|
487
|
+
const cells = Array.from(
|
|
488
|
+
document.querySelectorAll(".dashboard-edit .dashboard-grid-item"),
|
|
489
|
+
)
|
|
490
|
+
.map((el) => {
|
|
491
|
+
const rect = el.getBoundingClientRect();
|
|
492
|
+
return { id: el.getAttribute("data-widget-id"), rect };
|
|
493
|
+
})
|
|
494
|
+
.filter((c): c is { id: string; rect: DOMRect } => c.id != null);
|
|
495
|
+
|
|
496
|
+
let anchor = cells[0] ?? null;
|
|
497
|
+
let best = Number.POSITIVE_INFINITY;
|
|
498
|
+
for (const cell of cells) {
|
|
499
|
+
const dist = rectContainsPoint(cell.rect, clientX, clientY)
|
|
500
|
+
? 0
|
|
501
|
+
: rectDistance(cell.rect, clientX, clientY);
|
|
502
|
+
if (dist < best) {
|
|
503
|
+
best = dist;
|
|
504
|
+
anchor = cell;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (!anchor || anchor.id === draggedId) return;
|
|
508
|
+
|
|
509
|
+
const frozen = dragPlacements.value;
|
|
510
|
+
if (!frozen) return;
|
|
511
|
+
const anchorIndex = items.findIndex((w) => w.id === anchor.id);
|
|
512
|
+
if (anchorIndex === -1) return;
|
|
513
|
+
|
|
514
|
+
const draggedPlacement = frozen[draggedId];
|
|
515
|
+
const anchorPlacement = frozen[anchor.id];
|
|
516
|
+
const sameColumn =
|
|
517
|
+
draggedPlacement != null &&
|
|
518
|
+
anchorPlacement != null &&
|
|
519
|
+
draggedPlacement.colStart === anchorPlacement.colStart &&
|
|
520
|
+
draggedPlacement.colSpan === anchorPlacement.colSpan;
|
|
521
|
+
|
|
522
|
+
// Column group: the contiguous run of widgets sharing the anchor column.
|
|
523
|
+
const anchorColStart = anchorPlacement?.colStart ?? -1;
|
|
524
|
+
let groupStart = anchorIndex;
|
|
525
|
+
while (
|
|
526
|
+
groupStart > 0 &&
|
|
527
|
+
frozen[items[groupStart - 1].id]?.colStart === anchorColStart
|
|
528
|
+
) {
|
|
529
|
+
groupStart -= 1;
|
|
530
|
+
}
|
|
531
|
+
let groupEnd = anchorIndex;
|
|
532
|
+
while (
|
|
533
|
+
groupEnd < items.length - 1 &&
|
|
534
|
+
frozen[items[groupEnd + 1].id]?.colStart === anchorColStart
|
|
535
|
+
) {
|
|
536
|
+
groupEnd += 1;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let dest: number;
|
|
540
|
+
if (sameColumn) {
|
|
541
|
+
// Same stacked column: reorder vertically by the pointer's row.
|
|
542
|
+
const anchorRect = cells.find((c) => c.id === anchor.id)?.rect;
|
|
543
|
+
dest =
|
|
544
|
+
anchorRect && clientY < anchorRect.top + anchorRect.height / 2
|
|
545
|
+
? anchorIndex
|
|
546
|
+
: anchorIndex + 1;
|
|
547
|
+
} else {
|
|
548
|
+
// Other column: insert at the edge of the anchor's column group on the
|
|
549
|
+
// side the pointer is on (direction-aware).
|
|
550
|
+
const anchorRect = cells.find((c) => c.id === anchor.id)?.rect;
|
|
551
|
+
const rtl =
|
|
552
|
+
getComputedStyle(
|
|
553
|
+
document.querySelector(".dashboard-edit") ?? document.body,
|
|
554
|
+
).direction === "rtl";
|
|
555
|
+
const onStartSide =
|
|
556
|
+
anchorRect != null &&
|
|
557
|
+
(rtl
|
|
558
|
+
? clientX > anchorRect.left + anchorRect.width / 2
|
|
559
|
+
: clientX < anchorRect.left + anchorRect.width / 2);
|
|
560
|
+
dest = onStartSide ? groupStart : groupEnd + 1;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const [item] = items.splice(curIndex, 1);
|
|
564
|
+
if (item && curIndex < dest) dest -= 1;
|
|
565
|
+
items.splice(dest, 0, item);
|
|
566
|
+
editWidgets.value = items;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function widgetGridStyle(widget: Widget) {
|
|
570
|
+
const p = placements.value[widget.id];
|
|
571
|
+
if (!p) return {};
|
|
572
|
+
return {
|
|
573
|
+
gridColumn: `${p.colStart} / span ${p.colSpan}`,
|
|
574
|
+
gridRow: `${p.rowStart} / span ${p.rowSpan}`,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
defineExpose({ addWidget });
|
|
131
579
|
</script>
|
|
580
|
+
|
|
581
|
+
<style scoped>
|
|
582
|
+
.dashboard-grid {
|
|
583
|
+
display: grid;
|
|
584
|
+
grid-template-columns: repeat(24, 1fr);
|
|
585
|
+
/* Dense auto-fill lets short widgets stack into gaps left by tall ones. */
|
|
586
|
+
grid-auto-flow: row dense;
|
|
587
|
+
gap: 12px;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.dashboard-grid-item {
|
|
591
|
+
min-width: 0;
|
|
592
|
+
/* Let the card stretch to the full cell height so its content can be
|
|
593
|
+
vertically centered (helps short widgets in tall rows). */
|
|
594
|
+
display: flex;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
@media (max-width: 768px) {
|
|
598
|
+
.dashboard-grid-item {
|
|
599
|
+
grid-column: 1 / -1 !important;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
+
<LazyFormDrawer />
|
|
2
3
|
<NSpin :show="loading">
|
|
3
4
|
<NDataTable
|
|
4
5
|
size="small"
|
|
@@ -73,7 +74,8 @@ const columns = computed<DataTableColumns>(() =>
|
|
|
73
74
|
key: field.key,
|
|
74
75
|
minWidth: 150,
|
|
75
76
|
width: 150,
|
|
76
|
-
|
|
77
|
+
resizable: true,
|
|
78
|
+
ellipsis: { tooltip: true },
|
|
77
79
|
render: (row: Item) =>
|
|
78
80
|
h(LazyColumn, {
|
|
79
81
|
field,
|