inicontent 1.0.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/.github/pull_request.yml +18 -0
- package/CONTRIBUTING.md +67 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/app/app.vue +106 -0
- package/app/assets/main.css +974 -0
- package/app/components/Api/EndpointCard.vue +169 -0
- package/app/components/Api/SchemaDocsTable.vue +208 -0
- package/app/components/Asset/Card.vue +1363 -0
- package/app/components/Asset/Grid.vue +295 -0
- package/app/components/Asset/Icon.vue +38 -0
- package/app/components/Asset/Preview.vue +486 -0
- package/app/components/Asset/Scanner.vue +700 -0
- package/app/components/Asset/Thumb.vue +708 -0
- package/app/components/Auth/Reset.vue +99 -0
- package/app/components/Auth/index.vue +346 -0
- package/app/components/ChatInterface.vue +2326 -0
- package/app/components/ChatPagePreview.vue +34 -0
- package/app/components/Column/Array.vue +29 -0
- package/app/components/Column/Asset.vue +21 -0
- package/app/components/Column/Boolean.vue +12 -0
- package/app/components/Column/Color.vue +9 -0
- package/app/components/Column/Date.vue +57 -0
- package/app/components/Column/Edit.vue +215 -0
- package/app/components/Column/Email.vue +9 -0
- package/app/components/Column/Html.vue +27 -0
- package/app/components/Column/Id.vue +20 -0
- package/app/components/Column/Locale.vue +32 -0
- package/app/components/Column/Object.vue +18 -0
- package/app/components/Column/Password.vue +7 -0
- package/app/components/Column/Role.vue +15 -0
- package/app/components/Column/S.vue +14 -0
- package/app/components/Column/Table/Single.vue +49 -0
- package/app/components/Column/Table/index.vue +42 -0
- package/app/components/Column/Tags.vue +31 -0
- package/app/components/Column/Text.vue +9 -0
- package/app/components/Column/Url.vue +9 -0
- package/app/components/Column/index.vue +41 -0
- package/app/components/Dashboard/Editor.vue +221 -0
- package/app/components/Dashboard/Grid.vue +270 -0
- package/app/components/Dashboard/View.vue +131 -0
- package/app/components/Dashboard/Widget/BarChart.vue +73 -0
- package/app/components/Dashboard/Widget/Counter.vue +51 -0
- package/app/components/Dashboard/Widget/LineChart.vue +76 -0
- package/app/components/Dashboard/Widget/PieChart.vue +75 -0
- package/app/components/Dashboard/Widget/Table.vue +94 -0
- package/app/components/Dashboard/WidgetEditor.vue +274 -0
- package/app/components/Data/Array.vue +102 -0
- package/app/components/Data/Html.vue +9 -0
- package/app/components/Data/Icon.vue +9 -0
- package/app/components/Data/Object.vue +10 -0
- package/app/components/Data/S.vue +16 -0
- package/app/components/Data/Table.vue +53 -0
- package/app/components/Data/Value.vue +34 -0
- package/app/components/Data/index.vue +14 -0
- package/app/components/Field/Array.vue +324 -0
- package/app/components/Field/Asset/Actions.vue +97 -0
- package/app/components/Field/Asset/index.vue +639 -0
- package/app/components/Field/Boolean.vue +26 -0
- package/app/components/Field/Checkbox.vue +48 -0
- package/app/components/Field/Color.vue +27 -0
- package/app/components/Field/Date.vue +30 -0
- package/app/components/Field/Email.vue +51 -0
- package/app/components/Field/EmailTemplate.vue +420 -0
- package/app/components/Field/Html/ColorPicker.vue +127 -0
- package/app/components/Field/Html/Editor.vue +544 -0
- package/app/components/Field/Html/index.vue +25 -0
- package/app/components/Field/Icon.vue +114 -0
- package/app/components/Field/JSON.vue +113 -0
- package/app/components/Field/Mention.vue +36 -0
- package/app/components/Field/Number.vue +27 -0
- package/app/components/Field/Object.vue +92 -0
- package/app/components/Field/Password.vue +55 -0
- package/app/components/Field/Radio.vue +47 -0
- package/app/components/Field/S.vue +44 -0
- package/app/components/Field/Select.vue +49 -0
- package/app/components/Field/Table.vue +348 -0
- package/app/components/Field/Tags.vue +113 -0
- package/app/components/Field/Text.vue +31 -0
- package/app/components/Field/Textarea.vue +33 -0
- package/app/components/Field/Url.vue +40 -0
- package/app/components/Field/Wrapper.vue +128 -0
- package/app/components/Field/index.vue +68 -0
- package/app/components/FloatingChatbot.vue +73 -0
- package/app/components/Form/Card.vue +151 -0
- package/app/components/Form/Drawer.vue +195 -0
- package/app/components/Form/index.vue +621 -0
- package/app/components/Header.vue +328 -0
- package/app/components/Offline/ConflictModal.vue +200 -0
- package/app/components/Offline/SyncStatus.vue +249 -0
- package/app/components/PlatformFeatureLanding.vue +65 -0
- package/app/components/Table/Backups.vue +638 -0
- package/app/components/Table/Flows.vue +814 -0
- package/app/components/Table/Grid.vue +296 -0
- package/app/components/Table/Icon.vue +36 -0
- package/app/components/Table/Logs.vue +181 -0
- package/app/components/Table/Schedules.vue +722 -0
- package/app/components/Table/Search/Button.vue +256 -0
- package/app/components/Table/Search/Items.vue +286 -0
- package/app/components/Table/Search/index.vue +87 -0
- package/app/components/Table/Settings/Schema.vue +801 -0
- package/app/components/Table/Settings/index.vue +631 -0
- package/app/components/Table/TranslateDrawer.vue +526 -0
- package/app/components/Table/Views/Kanban.vue +289 -0
- package/app/components/Table/Views/Table.vue +1273 -0
- package/app/components/Table/index.vue +890 -0
- package/app/composables/Translation/define.ts +41 -0
- package/app/composables/Translation/fetch.ts +107 -0
- package/app/composables/Translation/languages.ts +1 -0
- package/app/composables/Translation/loadCoreTranslations.ts +38 -0
- package/app/composables/Translation/translate.ts +250 -0
- package/app/composables/databaseCookies.ts +79 -0
- package/app/composables/fieldValidator.ts +72 -0
- package/app/composables/fieldsList.ts +301 -0
- package/app/composables/formatDatabase.ts +101 -0
- package/app/composables/index.ts +444 -0
- package/app/composables/openDrawer.ts +118 -0
- package/app/composables/optimizeFile.ts +287 -0
- package/app/composables/renderLabel.ts +88 -0
- package/app/composables/schemaClipboard.ts +95 -0
- package/app/composables/search.ts +353 -0
- package/app/composables/setThemeConfig.ts +48 -0
- package/app/composables/translationValue.ts +169 -0
- package/app/composables/useAssetPreview.ts +84 -0
- package/app/composables/useAssetUploader.ts +56 -0
- package/app/composables/useDashboardData.ts +310 -0
- package/app/composables/useDocumentThumbnail.ts +351 -0
- package/app/composables/useEcharts.ts +33 -0
- package/app/composables/useLocalDatabaseConfig.ts +51 -0
- package/app/composables/useOcr.ts +300 -0
- package/app/composables/useOfflineFetch.ts +194 -0
- package/app/composables/useOfflineItem.ts +107 -0
- package/app/composables/useOfflineQueue.ts +302 -0
- package/app/composables/useOfflineSync.ts +235 -0
- package/app/composables/usePasskeyAuth.ts +156 -0
- package/app/composables/usePdfCompressor.ts +180 -0
- package/app/composables/usePlatformRedirect.ts +72 -0
- package/app/composables/useRealtimeSync.ts +233 -0
- package/app/composables/useReferencedItems.ts +180 -0
- package/app/composables/useScanner.ts +664 -0
- package/app/composables/useSubscription.ts +650 -0
- package/app/composables/useThumbnailCache.ts +136 -0
- package/app/composables/useVideoCompressor.ts +194 -0
- package/app/error.vue +112 -0
- package/app/layouts/api.vue +188 -0
- package/app/layouts/dashboard.vue +91 -0
- package/app/layouts/default.vue +228 -0
- package/app/layouts/table.vue +471 -0
- package/app/locales/ar.ts +996 -0
- package/app/locales/en.ts +655 -0
- package/app/locales/es.ts +996 -0
- package/app/locales/fr.ts +997 -0
- package/app/middleware/dashboard.ts +33 -0
- package/app/middleware/database.ts +83 -0
- package/app/middleware/global.ts +1 -0
- package/app/middleware/sid-ingest.global.ts +29 -0
- package/app/middleware/table.ts +54 -0
- package/app/middleware/user.ts +26 -0
- package/app/pages/[[database]]/admin/api/auth/index.vue +306 -0
- package/app/pages/[[database]]/admin/api/index.vue +18 -0
- package/app/pages/[[database]]/admin/api/tables/[table]/index.vue +497 -0
- package/app/pages/[[database]]/admin/api/tables/index.vue +87 -0
- package/app/pages/[[database]]/admin/billing/index.vue +16 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/edit.vue +66 -0
- package/app/pages/[[database]]/admin/dashboards/[id]/index.vue +117 -0
- package/app/pages/[[database]]/admin/dashboards/index.vue +20 -0
- package/app/pages/[[database]]/admin/index.vue +28 -0
- package/app/pages/[[database]]/admin/settings.vue +526 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/edit.vue +143 -0
- package/app/pages/[[database]]/admin/tables/[table]/[id]/index.vue +146 -0
- package/app/pages/[[database]]/admin/tables/[table]/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/new.vue +12 -0
- package/app/pages/[[database]]/admin/tables/[table]/schedules.vue +10 -0
- package/app/pages/[[database]]/admin/tables/[table]/settings.vue +30 -0
- package/app/pages/[[database]]/admin/tables/assets/[...path].vue +24 -0
- package/app/pages/[[database]]/admin/tables/assets/flows.vue +10 -0
- package/app/pages/[[database]]/admin/tables/backups/index.vue +10 -0
- package/app/pages/[[database]]/admin/tables/blocks/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/index.vue +20 -0
- package/app/pages/[[database]]/admin/tables/pages/index.vue +16 -0
- package/app/pages/[[database]]/admin/tables/templates/index.vue +16 -0
- package/app/pages/[[database]]/api/index.vue +120 -0
- package/app/pages/[[database]]/auth/index.vue +12 -0
- package/app/pages/[[database]]/auth/reset.vue +10 -0
- package/app/pages/[[database]]/index.vue +10 -0
- package/app/plugins/offline-warmup.client.ts +53 -0
- package/app/plugins/template-globals.ts +9 -0
- package/app/spa-loading-template.html +93 -0
- package/biome.json +53 -0
- package/index.d.ts +380 -0
- package/modules/naiveui.ts +41 -0
- package/nuxt.config.ts +269 -0
- package/package.json +56 -0
- package/pnpm-workspace.yaml +5 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/pwa-192x192.png +0 -0
- package/public/pwa-512x512.png +0 -0
- package/public/pwa-maskable-192x192.png +0 -0
- package/public/pwa-maskable-512x512.png +0 -0
- package/public/vendor/opencv.js +48 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { flattenSchema } from "inibase/utils";
|
|
2
|
+
import Inison from "inison";
|
|
3
|
+
import renderLabel from "~/composables/renderLabel";
|
|
4
|
+
import { generateSearchString } from "~/composables/search";
|
|
5
|
+
|
|
6
|
+
type WidgetDataResult = {
|
|
7
|
+
value: Ref<number | null>;
|
|
8
|
+
data: Ref<Item[]>;
|
|
9
|
+
total: Ref<number>;
|
|
10
|
+
groups: Ref<{ name: string; value: number }[]>;
|
|
11
|
+
timeSeries: Ref<{ date: string; value: number }[]>;
|
|
12
|
+
loading: Ref<boolean>;
|
|
13
|
+
refresh: () => Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function useDashboardData(
|
|
17
|
+
widget: Widget,
|
|
18
|
+
databaseSlug: string,
|
|
19
|
+
dateRangeOverride?: Ref<WidgetDateRange | undefined>,
|
|
20
|
+
): WidgetDataResult {
|
|
21
|
+
const config = useRuntimeConfig();
|
|
22
|
+
const database = useState<Database>("database");
|
|
23
|
+
const sessionID = useScopedCookie<string>("sid", database.value?.slug);
|
|
24
|
+
const Language = useScopedCookie<LanguagesType>(
|
|
25
|
+
"language",
|
|
26
|
+
database.value?.slug,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const value = ref<number | null>(null);
|
|
30
|
+
const data = ref<Item[]>([]);
|
|
31
|
+
const total = ref(0);
|
|
32
|
+
const groups = ref<{ name: string; value: number }[]>([]);
|
|
33
|
+
const timeSeries = ref<{ date: string; value: number }[]>([]);
|
|
34
|
+
const loading = ref(false);
|
|
35
|
+
|
|
36
|
+
function getDateRangeMs(range?: WidgetDateRange): number | null {
|
|
37
|
+
const day = 86400000;
|
|
38
|
+
switch (range) {
|
|
39
|
+
case "7d":
|
|
40
|
+
return 7 * day;
|
|
41
|
+
case "30d":
|
|
42
|
+
return 30 * day;
|
|
43
|
+
case "90d":
|
|
44
|
+
return 90 * day;
|
|
45
|
+
case "1y":
|
|
46
|
+
return 365 * day;
|
|
47
|
+
default:
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function groupByDate(
|
|
53
|
+
items: Item[],
|
|
54
|
+
dateField: string,
|
|
55
|
+
range?: WidgetDateRange,
|
|
56
|
+
) {
|
|
57
|
+
const map = new Map<string, number>();
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
const raw = item[dateField];
|
|
60
|
+
if (!raw) continue;
|
|
61
|
+
const d = new Date(raw as number);
|
|
62
|
+
const rangeMs = getDateRangeMs(range);
|
|
63
|
+
let key: string;
|
|
64
|
+
if (!rangeMs || rangeMs <= 30 * 86400000) {
|
|
65
|
+
key = d.toISOString().slice(0, 10);
|
|
66
|
+
} else if (rangeMs <= 90 * 86400000) {
|
|
67
|
+
const weekStart = new Date(d);
|
|
68
|
+
weekStart.setDate(d.getDate() - d.getDay());
|
|
69
|
+
key = weekStart.toISOString().slice(0, 10);
|
|
70
|
+
} else {
|
|
71
|
+
key = d.toISOString().slice(0, 7);
|
|
72
|
+
}
|
|
73
|
+
map.set(key, (map.get(key) ?? 0) + 1);
|
|
74
|
+
}
|
|
75
|
+
return Array.from(map.entries())
|
|
76
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
77
|
+
.map(([date, value]) => ({ date, value }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getFieldLabel(item: Item, field: string): string {
|
|
81
|
+
const raw = item[field];
|
|
82
|
+
if (raw == null) return "unknown";
|
|
83
|
+
if (typeof raw === "object") {
|
|
84
|
+
const sourceTable = database.value?.tables?.find(
|
|
85
|
+
(t) => t.slug === widget.table,
|
|
86
|
+
);
|
|
87
|
+
const schema = sourceTable?.schema
|
|
88
|
+
? flattenSchema(sourceTable.schema as any)
|
|
89
|
+
: [];
|
|
90
|
+
const fieldDef = schema.find((f: Field) => f.key === field);
|
|
91
|
+
if (fieldDef?.table) {
|
|
92
|
+
const refTable = database.value?.tables?.find(
|
|
93
|
+
(t) => t.slug === fieldDef.table,
|
|
94
|
+
);
|
|
95
|
+
return renderLabel(refTable, raw as Item) || String(raw);
|
|
96
|
+
}
|
|
97
|
+
return String(raw);
|
|
98
|
+
}
|
|
99
|
+
return String(raw);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function groupByField(items: Item[], field: string) {
|
|
103
|
+
const map = new Map<string, number>();
|
|
104
|
+
for (const item of items) {
|
|
105
|
+
const val = getFieldLabel(item, field);
|
|
106
|
+
map.set(val, (map.get(val) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
return Array.from(map.entries())
|
|
109
|
+
.sort(([, a], [, b]) => b - a)
|
|
110
|
+
.map(([name, value]) => ({ name, value }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getColumnsForField(field: string): string[] {
|
|
114
|
+
const sourceTable = database.value?.tables?.find(
|
|
115
|
+
(t) => t.slug === widget.table,
|
|
116
|
+
);
|
|
117
|
+
if (!sourceTable?.columns) return [field];
|
|
118
|
+
// Find dot-path columns that start with this field (e.g. "المساحة.name")
|
|
119
|
+
const nested = sourceTable.columns.filter(
|
|
120
|
+
(col) => col.startsWith(`${field}.`) || col === `${field}.*`,
|
|
121
|
+
);
|
|
122
|
+
// Always include the field itself + any nested sub-columns for label rendering
|
|
123
|
+
return nested.length ? [field, ...nested] : [field];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
127
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function combineSameFieldValues(
|
|
131
|
+
existing: unknown,
|
|
132
|
+
incoming: unknown,
|
|
133
|
+
): unknown {
|
|
134
|
+
if (existing === undefined) return incoming;
|
|
135
|
+
if (existing === incoming) return existing;
|
|
136
|
+
// Reuse the backend-supported grouped shape:
|
|
137
|
+
// { field: { and: { 0: "...", 1: "..." } } }
|
|
138
|
+
const existingGroup =
|
|
139
|
+
isRecord(existing) && isRecord(existing.and)
|
|
140
|
+
? { ...(existing.and as Record<string, unknown>) }
|
|
141
|
+
: null;
|
|
142
|
+
if (existingGroup) {
|
|
143
|
+
existingGroup[Object.keys(existingGroup).length.toString()] =
|
|
144
|
+
String(incoming);
|
|
145
|
+
return { and: existingGroup };
|
|
146
|
+
}
|
|
147
|
+
return { and: { "0": String(existing), "1": String(incoming) } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function mergeWhere(
|
|
151
|
+
a?: Record<string, any>,
|
|
152
|
+
b?: Record<string, any>,
|
|
153
|
+
): Record<string, any> | undefined {
|
|
154
|
+
if (!a) return b;
|
|
155
|
+
if (!b) return a;
|
|
156
|
+
// Both are { and: {...}, or: {...} } — merge each group, keeping
|
|
157
|
+
// both values when date-range and search target the same field.
|
|
158
|
+
const and: Record<string, unknown> = { ...(a.and ?? {}) };
|
|
159
|
+
for (const [key, value] of Object.entries(b.and ?? {})) {
|
|
160
|
+
and[key] = combineSameFieldValues(and[key], value);
|
|
161
|
+
}
|
|
162
|
+
const merged: Record<string, any> = { and };
|
|
163
|
+
const or = { ...(a.or ?? {}), ...(b.or ?? {}) };
|
|
164
|
+
if (Object.keys(or).length) merged.or = or;
|
|
165
|
+
if (!Object.keys(and).length) delete merged.and;
|
|
166
|
+
return Object.keys(merged).length ? merged : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fetchItems(opts: {
|
|
170
|
+
perPage?: number;
|
|
171
|
+
sort?: Record<string, number>;
|
|
172
|
+
where?: Record<string, any>;
|
|
173
|
+
columns?: string[];
|
|
174
|
+
}): Promise<apiResponse<Item[]>> {
|
|
175
|
+
const slug =
|
|
176
|
+
database.value?.slug === "inicontent" && !databaseSlug
|
|
177
|
+
? ""
|
|
178
|
+
: `${databaseSlug}/`;
|
|
179
|
+
return $fetch<apiResponse<Item[]>>(
|
|
180
|
+
`${config.public.apiBase}${slug}${widget.table}`,
|
|
181
|
+
{
|
|
182
|
+
params: {
|
|
183
|
+
locale: Language.value,
|
|
184
|
+
[`${databaseSlug}_sid`]: sessionID.value,
|
|
185
|
+
options: Inison.stringify({
|
|
186
|
+
perPage: opts.perPage ?? 1000,
|
|
187
|
+
page: 1,
|
|
188
|
+
...(opts.sort ? { sort: opts.sort } : {}),
|
|
189
|
+
...(opts.columns ? { columns: opts.columns } : {}),
|
|
190
|
+
}),
|
|
191
|
+
...(opts.where ? { where: Inison.stringify(opts.where) } : {}),
|
|
192
|
+
},
|
|
193
|
+
credentials: "include",
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function refresh() {
|
|
199
|
+
loading.value = true;
|
|
200
|
+
try {
|
|
201
|
+
const activeDateRange = dateRangeOverride?.value ?? widget.dateRange;
|
|
202
|
+
const rangeMs = getDateRangeMs(activeDateRange);
|
|
203
|
+
const dateField = widget.dateField ?? "createdAt";
|
|
204
|
+
const whereDateFlat = rangeMs
|
|
205
|
+
? { [dateField]: `>${Date.now() - rangeMs}` }
|
|
206
|
+
: undefined;
|
|
207
|
+
// Use "display" mode so relative operators (r>=, etc.) stay as
|
|
208
|
+
// strings for the backend to resolve — same as Table view.
|
|
209
|
+
const whereSearch = widget.searchArray
|
|
210
|
+
? generateSearchString(widget.searchArray, "display")
|
|
211
|
+
: undefined;
|
|
212
|
+
// No saved filters → keep the exact legacy shape (flat) so
|
|
213
|
+
// existing dashboards send byte-identical queries. With filters,
|
|
214
|
+
// normalize the date into an `and` group and merge.
|
|
215
|
+
const where = whereSearch
|
|
216
|
+
? mergeWhere(
|
|
217
|
+
whereDateFlat ? { and: { ...whereDateFlat } } : undefined,
|
|
218
|
+
whereSearch as Record<string, any> | undefined,
|
|
219
|
+
)
|
|
220
|
+
: whereDateFlat;
|
|
221
|
+
|
|
222
|
+
switch (widget.type) {
|
|
223
|
+
case "counter": {
|
|
224
|
+
if (
|
|
225
|
+
widget.operation === "count" ||
|
|
226
|
+
!widget.operation ||
|
|
227
|
+
!widget.field
|
|
228
|
+
) {
|
|
229
|
+
const res = await fetchItems({
|
|
230
|
+
perPage: 1,
|
|
231
|
+
columns: ["id"],
|
|
232
|
+
where,
|
|
233
|
+
});
|
|
234
|
+
value.value = res.options?.total ?? 0;
|
|
235
|
+
} else {
|
|
236
|
+
const res = await fetchItems({
|
|
237
|
+
perPage: 1000,
|
|
238
|
+
columns: [widget.field],
|
|
239
|
+
where,
|
|
240
|
+
});
|
|
241
|
+
const items = res.result ?? [];
|
|
242
|
+
const nums = items
|
|
243
|
+
.map((i) => Number(i[widget.field!]))
|
|
244
|
+
.filter((n) => !Number.isNaN(n));
|
|
245
|
+
if (widget.operation === "sum")
|
|
246
|
+
value.value = nums.reduce((a, b) => a + b, 0);
|
|
247
|
+
else if (widget.operation === "max")
|
|
248
|
+
value.value = nums.length ? Math.max(...nums) : 0;
|
|
249
|
+
else if (widget.operation === "min")
|
|
250
|
+
value.value = nums.length ? Math.min(...nums) : 0;
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
case "line": {
|
|
255
|
+
const res = await fetchItems({
|
|
256
|
+
perPage: 1000,
|
|
257
|
+
columns: [dateField],
|
|
258
|
+
sort: { [dateField]: 1 },
|
|
259
|
+
where,
|
|
260
|
+
});
|
|
261
|
+
data.value = res.result ?? [];
|
|
262
|
+
timeSeries.value = groupByDate(
|
|
263
|
+
data.value,
|
|
264
|
+
dateField,
|
|
265
|
+
activeDateRange,
|
|
266
|
+
);
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "bar":
|
|
270
|
+
case "pie": {
|
|
271
|
+
const field = widget.groupBy ?? widget.field;
|
|
272
|
+
if (!field) break;
|
|
273
|
+
const res = await fetchItems({
|
|
274
|
+
perPage: 1000,
|
|
275
|
+
columns: getColumnsForField(field),
|
|
276
|
+
where,
|
|
277
|
+
});
|
|
278
|
+
data.value = res.result ?? [];
|
|
279
|
+
groups.value = groupByField(data.value, field);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "table": {
|
|
283
|
+
const res = await fetchItems({
|
|
284
|
+
perPage: widget.limit ?? 10,
|
|
285
|
+
sort: widget.sortField
|
|
286
|
+
? {
|
|
287
|
+
[widget.sortField]: widget.sortOrder === "desc" ? -1 : 1,
|
|
288
|
+
}
|
|
289
|
+
: undefined,
|
|
290
|
+
columns: widget.columns?.length ? widget.columns : undefined,
|
|
291
|
+
where,
|
|
292
|
+
});
|
|
293
|
+
data.value = res.result ?? [];
|
|
294
|
+
total.value = res.options?.total ?? 0;
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
} catch (e) {
|
|
299
|
+
console.error(`[Dashboard] Widget "${widget.title}" fetch error:`, e);
|
|
300
|
+
} finally {
|
|
301
|
+
loading.value = false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (dateRangeOverride) {
|
|
306
|
+
watch(dateRangeOverride, () => refresh());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { value, data, total, groups, timeSeries, loading, refresh };
|
|
310
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCachedThumbnail,
|
|
3
|
+
cacheThumbnail,
|
|
4
|
+
} from "~/composables/useThumbnailCache";
|
|
5
|
+
import {
|
|
6
|
+
wordExtensions,
|
|
7
|
+
spreadsheetExtensions,
|
|
8
|
+
officeExtensions,
|
|
9
|
+
} from "~/composables";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE_RENDER = 10 * 1024 * 1024; // 10MB limit for Tier 2 rendering
|
|
12
|
+
const THUMBNAIL_SIZE = 200;
|
|
13
|
+
const TIMEOUT_MS = 30000;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tier 1: Extract embedded thumbnail from OOXML ZIP archive.
|
|
17
|
+
* Office files (docx, xlsx, pptx) are ZIP archives that often contain
|
|
18
|
+
* a pre-generated thumbnail at `docProps/thumbnail.jpeg` (or .png).
|
|
19
|
+
*/
|
|
20
|
+
async function extractEmbeddedThumbnail(
|
|
21
|
+
data: ArrayBuffer,
|
|
22
|
+
): Promise<string | undefined> {
|
|
23
|
+
try {
|
|
24
|
+
const JSZip = (await import("jszip")).default;
|
|
25
|
+
const zip = await JSZip.loadAsync(data);
|
|
26
|
+
|
|
27
|
+
// Look for thumbnail in docProps/
|
|
28
|
+
const candidates = [
|
|
29
|
+
"docProps/thumbnail.jpeg",
|
|
30
|
+
"docProps/thumbnail.jpg",
|
|
31
|
+
"docProps/thumbnail.png",
|
|
32
|
+
"docProps/thumbnail.gif",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const path of candidates) {
|
|
36
|
+
const file = zip.file(path);
|
|
37
|
+
if (file) {
|
|
38
|
+
const blob = await file.async("blob");
|
|
39
|
+
const mimeType = path.endsWith(".png") ? "image/png" : "image/jpeg";
|
|
40
|
+
|
|
41
|
+
// Convert to data URL via canvas for consistent sizing
|
|
42
|
+
return await blobToThumbnailDataUrl(blob, mimeType);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Also check case-insensitive and alternative paths
|
|
47
|
+
const allFiles = Object.keys(zip.files);
|
|
48
|
+
for (const filePath of allFiles) {
|
|
49
|
+
const lower = filePath.toLowerCase();
|
|
50
|
+
if (
|
|
51
|
+
lower.startsWith("docprops/thumbnail") &&
|
|
52
|
+
(lower.endsWith(".jpeg") ||
|
|
53
|
+
lower.endsWith(".jpg") ||
|
|
54
|
+
lower.endsWith(".png"))
|
|
55
|
+
) {
|
|
56
|
+
const file = zip.file(filePath);
|
|
57
|
+
if (file) {
|
|
58
|
+
const blob = await file.async("blob");
|
|
59
|
+
const mimeType = lower.endsWith(".png")
|
|
60
|
+
? "image/png"
|
|
61
|
+
: "image/jpeg";
|
|
62
|
+
return await blobToThumbnailDataUrl(blob, mimeType);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.warn("Failed to extract embedded thumbnail:", error);
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert a Blob to a square thumbnail data URL.
|
|
74
|
+
*/
|
|
75
|
+
async function blobToThumbnailDataUrl(
|
|
76
|
+
blob: Blob,
|
|
77
|
+
mimeType: string,
|
|
78
|
+
): Promise<string | undefined> {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const img = new Image();
|
|
81
|
+
const url = URL.createObjectURL(new Blob([blob], { type: mimeType }));
|
|
82
|
+
|
|
83
|
+
img.onload = () => {
|
|
84
|
+
try {
|
|
85
|
+
const canvas = document.createElement("canvas");
|
|
86
|
+
canvas.width = THUMBNAIL_SIZE;
|
|
87
|
+
canvas.height = THUMBNAIL_SIZE;
|
|
88
|
+
const ctx = canvas.getContext("2d");
|
|
89
|
+
if (!ctx) {
|
|
90
|
+
resolve(undefined);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Cover-crop: scale to fill the square, center the content
|
|
95
|
+
const scale = Math.max(
|
|
96
|
+
THUMBNAIL_SIZE / img.width,
|
|
97
|
+
THUMBNAIL_SIZE / img.height,
|
|
98
|
+
);
|
|
99
|
+
const scaledW = img.width * scale;
|
|
100
|
+
const scaledH = img.height * scale;
|
|
101
|
+
const offsetX = (THUMBNAIL_SIZE - scaledW) / 2;
|
|
102
|
+
const offsetY = (THUMBNAIL_SIZE - scaledH) / 2;
|
|
103
|
+
|
|
104
|
+
ctx.drawImage(img, offsetX, offsetY, scaledW, scaledH);
|
|
105
|
+
resolve(canvas.toDataURL("image/jpeg", 0.7));
|
|
106
|
+
} catch {
|
|
107
|
+
resolve(undefined);
|
|
108
|
+
} finally {
|
|
109
|
+
URL.revokeObjectURL(url);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
img.onerror = () => {
|
|
114
|
+
URL.revokeObjectURL(url);
|
|
115
|
+
resolve(undefined);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
img.src = url;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Tier 2a: Render first page of DOCX and capture as thumbnail.
|
|
124
|
+
*/
|
|
125
|
+
async function renderDocxFirstPage(
|
|
126
|
+
data: ArrayBuffer,
|
|
127
|
+
): Promise<string | undefined> {
|
|
128
|
+
const container = document.createElement("div");
|
|
129
|
+
try {
|
|
130
|
+
// Offscreen container sized to A4 aspect ratio — clips to first page
|
|
131
|
+
Object.assign(container.style, {
|
|
132
|
+
position: "fixed",
|
|
133
|
+
left: "-9999px",
|
|
134
|
+
top: "0",
|
|
135
|
+
width: "800px",
|
|
136
|
+
height: "1100px",
|
|
137
|
+
overflow: "hidden",
|
|
138
|
+
background: "white",
|
|
139
|
+
zIndex: "-1",
|
|
140
|
+
});
|
|
141
|
+
document.body.appendChild(container);
|
|
142
|
+
|
|
143
|
+
const { renderAsync } = await import("docx-preview");
|
|
144
|
+
await renderAsync(data, container, undefined, {
|
|
145
|
+
breakPages: true,
|
|
146
|
+
ignoreLastRenderedPageBreak: false,
|
|
147
|
+
ignoreWidth: false,
|
|
148
|
+
ignoreHeight: false,
|
|
149
|
+
ignoreFonts: false,
|
|
150
|
+
inWrapper: true,
|
|
151
|
+
renderHeaders: false,
|
|
152
|
+
renderFooters: false,
|
|
153
|
+
renderFootnotes: false,
|
|
154
|
+
renderEndnotes: false,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const html2canvas = (await import("html2canvas")).default;
|
|
158
|
+
const canvas = await html2canvas(container, {
|
|
159
|
+
width: 800,
|
|
160
|
+
height: 1100,
|
|
161
|
+
scale: 0.5, // render at half-res for speed
|
|
162
|
+
useCORS: true,
|
|
163
|
+
logging: false,
|
|
164
|
+
backgroundColor: "#ffffff",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Crop to square thumbnail
|
|
168
|
+
const thumbCanvas = document.createElement("canvas");
|
|
169
|
+
thumbCanvas.width = THUMBNAIL_SIZE;
|
|
170
|
+
thumbCanvas.height = THUMBNAIL_SIZE;
|
|
171
|
+
const ctx = thumbCanvas.getContext("2d");
|
|
172
|
+
if (!ctx) return undefined;
|
|
173
|
+
|
|
174
|
+
const scale = Math.max(
|
|
175
|
+
THUMBNAIL_SIZE / canvas.width,
|
|
176
|
+
THUMBNAIL_SIZE / canvas.height,
|
|
177
|
+
);
|
|
178
|
+
const scaledW = canvas.width * scale;
|
|
179
|
+
const scaledH = canvas.height * scale;
|
|
180
|
+
const offsetX = (THUMBNAIL_SIZE - scaledW) / 2;
|
|
181
|
+
const offsetY = (THUMBNAIL_SIZE - scaledH) / 2;
|
|
182
|
+
|
|
183
|
+
ctx.drawImage(canvas, offsetX, offsetY, scaledW, scaledH);
|
|
184
|
+
return thumbCanvas.toDataURL("image/jpeg", 0.7);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.warn("Failed to render DOCX first page:", error);
|
|
187
|
+
return undefined;
|
|
188
|
+
} finally {
|
|
189
|
+
container.remove();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Tier 2b: Parse first rows of XLSX and capture as thumbnail.
|
|
195
|
+
*/
|
|
196
|
+
async function renderXlsxFirstPage(
|
|
197
|
+
data: ArrayBuffer,
|
|
198
|
+
): Promise<string | undefined> {
|
|
199
|
+
const container = document.createElement("div");
|
|
200
|
+
try {
|
|
201
|
+
const XLSX = await import("xlsx");
|
|
202
|
+
const workbook = XLSX.read(data, {
|
|
203
|
+
type: "array",
|
|
204
|
+
sheetRows: 15, // only first 15 rows
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const firstSheetName = workbook.SheetNames[0];
|
|
208
|
+
if (!firstSheetName) return undefined;
|
|
209
|
+
|
|
210
|
+
const sheet = workbook.Sheets[firstSheetName];
|
|
211
|
+
if (!sheet) return undefined;
|
|
212
|
+
|
|
213
|
+
const html = XLSX.utils.sheet_to_html(sheet);
|
|
214
|
+
|
|
215
|
+
// Offscreen container with spreadsheet styling
|
|
216
|
+
Object.assign(container.style, {
|
|
217
|
+
position: "fixed",
|
|
218
|
+
left: "-9999px",
|
|
219
|
+
top: "0",
|
|
220
|
+
width: "800px",
|
|
221
|
+
height: "600px",
|
|
222
|
+
overflow: "hidden",
|
|
223
|
+
background: "white",
|
|
224
|
+
zIndex: "-1",
|
|
225
|
+
fontFamily: "monospace, sans-serif",
|
|
226
|
+
fontSize: "12px",
|
|
227
|
+
});
|
|
228
|
+
document.body.appendChild(container);
|
|
229
|
+
|
|
230
|
+
container.innerHTML = `<style>
|
|
231
|
+
table { border-collapse: collapse; width: 100%; }
|
|
232
|
+
td, th {
|
|
233
|
+
border: 1px solid #d0d0d0;
|
|
234
|
+
padding: 4px 8px;
|
|
235
|
+
text-align: left;
|
|
236
|
+
white-space: nowrap;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
text-overflow: ellipsis;
|
|
239
|
+
max-width: 150px;
|
|
240
|
+
font-size: 11px;
|
|
241
|
+
}
|
|
242
|
+
th { background: #f5f5f5; font-weight: 600; }
|
|
243
|
+
tr:nth-child(even) { background: #fafafa; }
|
|
244
|
+
</style>${html}`;
|
|
245
|
+
|
|
246
|
+
const html2canvas = (await import("html2canvas")).default;
|
|
247
|
+
const canvas = await html2canvas(container, {
|
|
248
|
+
width: 800,
|
|
249
|
+
height: 600,
|
|
250
|
+
scale: 0.5,
|
|
251
|
+
useCORS: true,
|
|
252
|
+
logging: false,
|
|
253
|
+
backgroundColor: "#ffffff",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Crop to square thumbnail
|
|
257
|
+
const thumbCanvas = document.createElement("canvas");
|
|
258
|
+
thumbCanvas.width = THUMBNAIL_SIZE;
|
|
259
|
+
thumbCanvas.height = THUMBNAIL_SIZE;
|
|
260
|
+
const ctx = thumbCanvas.getContext("2d");
|
|
261
|
+
if (!ctx) return undefined;
|
|
262
|
+
|
|
263
|
+
const scale = Math.max(
|
|
264
|
+
THUMBNAIL_SIZE / canvas.width,
|
|
265
|
+
THUMBNAIL_SIZE / canvas.height,
|
|
266
|
+
);
|
|
267
|
+
const scaledW = canvas.width * scale;
|
|
268
|
+
const scaledH = canvas.height * scale;
|
|
269
|
+
const offsetX = (THUMBNAIL_SIZE - scaledW) / 2;
|
|
270
|
+
const offsetY = (THUMBNAIL_SIZE - scaledH) / 2;
|
|
271
|
+
|
|
272
|
+
ctx.drawImage(canvas, offsetX, offsetY, scaledW, scaledH);
|
|
273
|
+
return thumbCanvas.toDataURL("image/jpeg", 0.7);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.warn("Failed to render XLSX first page:", error);
|
|
276
|
+
return undefined;
|
|
277
|
+
} finally {
|
|
278
|
+
container.remove();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Main entry point: generate a thumbnail for an Office document.
|
|
284
|
+
* Uses a tiered strategy:
|
|
285
|
+
* Tier 1: Extract embedded thumbnail from OOXML ZIP (fast, any size)
|
|
286
|
+
* Tier 2: Render first page client-side (DOCX/XLSX only, ≤10MB)
|
|
287
|
+
* Tier 3: Return undefined → caller falls back to file-type icon
|
|
288
|
+
*/
|
|
289
|
+
export async function generateDocumentThumbnail(
|
|
290
|
+
asset: Asset,
|
|
291
|
+
): Promise<string | undefined> {
|
|
292
|
+
if (!officeExtensions.includes(asset.extension)) return undefined;
|
|
293
|
+
|
|
294
|
+
const key = String(asset.id ?? asset.publicURL ?? asset.name);
|
|
295
|
+
const cacheKey = `doc-${key}`;
|
|
296
|
+
|
|
297
|
+
// Check cache first
|
|
298
|
+
const cached = await getCachedThumbnail(cacheKey);
|
|
299
|
+
if (cached) return cached;
|
|
300
|
+
|
|
301
|
+
// Fetch file as ArrayBuffer with timeout
|
|
302
|
+
let data: ArrayBuffer;
|
|
303
|
+
try {
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
306
|
+
const response = await fetch(asset.publicURL, {
|
|
307
|
+
signal: controller.signal,
|
|
308
|
+
});
|
|
309
|
+
clearTimeout(timeoutId);
|
|
310
|
+
|
|
311
|
+
if (!response.ok) return undefined;
|
|
312
|
+
data = await response.arrayBuffer();
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.warn("Failed to fetch document for thumbnail:", error);
|
|
315
|
+
return undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Tier 1: Try embedded thumbnail extraction (works for all OOXML, any size)
|
|
319
|
+
const embedded = await extractEmbeddedThumbnail(data);
|
|
320
|
+
if (embedded) {
|
|
321
|
+
await cacheThumbnail(cacheKey, embedded);
|
|
322
|
+
return embedded;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Tier 2: Render first page (only for small files)
|
|
326
|
+
if (asset.size && asset.size > MAX_FILE_SIZE_RENDER) {
|
|
327
|
+
console.warn(
|
|
328
|
+
`Document too large (${Math.round(asset.size / 1024 / 1024)}MB) for first-page rendering. Skipping.`,
|
|
329
|
+
);
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
let rendered: string | undefined;
|
|
334
|
+
|
|
335
|
+
if (wordExtensions.includes(asset.extension) && asset.extension !== "doc") {
|
|
336
|
+
rendered = await renderDocxFirstPage(data);
|
|
337
|
+
} else if (
|
|
338
|
+
spreadsheetExtensions.includes(asset.extension) &&
|
|
339
|
+
asset.extension !== "xls"
|
|
340
|
+
) {
|
|
341
|
+
rendered = await renderXlsxFirstPage(data);
|
|
342
|
+
}
|
|
343
|
+
// Legacy formats (doc, xls) and presentations (ppt, pptx without embedded thumb)
|
|
344
|
+
// → return undefined, caller shows icon
|
|
345
|
+
|
|
346
|
+
if (rendered) {
|
|
347
|
+
await cacheThumbnail(cacheKey, rendered);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return rendered;
|
|
351
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
let registered = false;
|
|
2
|
+
|
|
3
|
+
export const VChart = defineAsyncComponent(async () => {
|
|
4
|
+
const [
|
|
5
|
+
{ use },
|
|
6
|
+
{ CanvasRenderer },
|
|
7
|
+
{ LineChart, BarChart, PieChart },
|
|
8
|
+
components,
|
|
9
|
+
{ default: VChartImpl },
|
|
10
|
+
] = await Promise.all([
|
|
11
|
+
import("echarts/core"),
|
|
12
|
+
import("echarts/renderers"),
|
|
13
|
+
import("echarts/charts"),
|
|
14
|
+
import("echarts/components"),
|
|
15
|
+
import("vue-echarts"),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
if (!registered) {
|
|
19
|
+
use([
|
|
20
|
+
CanvasRenderer,
|
|
21
|
+
LineChart,
|
|
22
|
+
BarChart,
|
|
23
|
+
PieChart,
|
|
24
|
+
components.TitleComponent,
|
|
25
|
+
components.TooltipComponent,
|
|
26
|
+
components.LegendComponent,
|
|
27
|
+
components.GridComponent,
|
|
28
|
+
]);
|
|
29
|
+
registered = true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return VChartImpl;
|
|
33
|
+
});
|