qdadm 1.1.3 → 1.1.6
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/package.json +1 -1
- package/src/components/index.ts +5 -0
- package/src/components/layout/PageNav.vue +15 -5
- package/src/components/lists/ListPage.vue +21 -2
- package/src/components/show/ShowDisplay.vue +393 -0
- package/src/components/show/ShowField.vue +96 -0
- package/src/components/show/ShowPage.vue +343 -0
- package/src/composables/index.ts +11 -0
- package/src/composables/useEntityItemShowPage.ts +687 -0
- package/src/composables/useListPage.ts +35 -3
- package/src/composables/useNavContext.ts +13 -5
- package/src/entity/EntityManager.ts +53 -2
- package/src/index.ts +11 -0
- package/src/styles/_filter-bar.scss +16 -1
package/package.json
CHANGED
package/src/components/index.ts
CHANGED
|
@@ -27,6 +27,11 @@ export { default as FormActions } from './forms/FormActions.vue'
|
|
|
27
27
|
export { default as FormTabs } from './forms/FormTabs.vue'
|
|
28
28
|
export { default as FormTab } from './forms/FormTab.vue'
|
|
29
29
|
|
|
30
|
+
// Show (read-only detail pages)
|
|
31
|
+
export { default as ShowPage } from './show/ShowPage.vue'
|
|
32
|
+
export { default as ShowField } from './show/ShowField.vue'
|
|
33
|
+
export { default as ShowDisplay } from './show/ShowDisplay.vue'
|
|
34
|
+
|
|
30
35
|
// Lists
|
|
31
36
|
export { default as ListPage } from './lists/ListPage.vue'
|
|
32
37
|
export { default as ActionButtons } from './lists/ActionButtons.vue'
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* - showDetailsLink: Show "Details" link in navlinks (default: false)
|
|
17
17
|
*/
|
|
18
18
|
import { computed, watch, inject, type Ref } from 'vue'
|
|
19
|
-
import { useRoute } from 'vue-router'
|
|
19
|
+
import { useRoute, useRouter } from 'vue-router'
|
|
20
20
|
import { getSiblingRoutes, getChildRoutes, type ModuleRoute, type ParentConfig } from '../../module/moduleRegistry'
|
|
21
21
|
import { useOrchestrator } from '../../orchestrator/useOrchestrator.js'
|
|
22
22
|
import type { EntityManager } from '../../entity/EntityManager'
|
|
@@ -37,6 +37,7 @@ const props = defineProps<{
|
|
|
37
37
|
}>()
|
|
38
38
|
|
|
39
39
|
const route = useRoute()
|
|
40
|
+
const router = useRouter()
|
|
40
41
|
const { getManager } = useOrchestrator()
|
|
41
42
|
|
|
42
43
|
// Parent config from route meta
|
|
@@ -44,13 +45,22 @@ const parentConfig = computed<ParentConfig | undefined>(() => route.meta?.parent
|
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Get default item route for an entity manager
|
|
47
|
-
*
|
|
48
|
-
* -
|
|
48
|
+
* Checks which routes actually exist and returns the appropriate one:
|
|
49
|
+
* - Prefers -edit if it exists (editable entity)
|
|
50
|
+
* - Falls back to -show if -edit doesn't exist
|
|
51
|
+
* - Returns null if neither exists
|
|
49
52
|
*/
|
|
50
53
|
function getDefaultItemRoute(manager: EntityManager<EntityRecord> | null): string | null {
|
|
51
54
|
if (!manager) return null
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
|
|
56
|
+
const editRoute = `${manager.routePrefix}-edit`
|
|
57
|
+
const showRoute = `${manager.routePrefix}-show`
|
|
58
|
+
|
|
59
|
+
// Check which routes actually exist and return the first available
|
|
60
|
+
if (router.hasRoute(editRoute)) return editRoute
|
|
61
|
+
if (router.hasRoute(showRoute)) return showRoute
|
|
62
|
+
|
|
63
|
+
return null
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
// Sibling navlinks (routes with same parent)
|
|
@@ -118,6 +118,7 @@ const props = defineProps({
|
|
|
118
118
|
// Filters
|
|
119
119
|
filters: { type: Array as PropType<FilterConfig[]>, default: () => [] },
|
|
120
120
|
filterValues: { type: Object as PropType<Record<string, unknown>>, default: () => ({}) },
|
|
121
|
+
isFilterAtDefault: { type: Function as PropType<(name: string) => boolean>, default: () => () => true },
|
|
121
122
|
|
|
122
123
|
// Row Actions
|
|
123
124
|
getActions: { type: Function as unknown as PropType<((row: unknown) => ResolvedAction[]) | null>, default: null },
|
|
@@ -300,6 +301,24 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
|
|
|
300
301
|
// event.data = row data, event.originalEvent = Event
|
|
301
302
|
emit('row-click', event.data, event.originalEvent as MouseEvent)
|
|
302
303
|
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get filter CSS class based on value and default state
|
|
307
|
+
* - No value (null/empty): no class
|
|
308
|
+
* - Value = default: 'filter-default' (blue/info)
|
|
309
|
+
* - Value ≠ default: 'filter-modified' (orange/warning)
|
|
310
|
+
*/
|
|
311
|
+
function getFilterClass(filter: FilterConfig): Record<string, boolean> {
|
|
312
|
+
const value = localFilterValues.value[filter.name]
|
|
313
|
+
const hasValue = value != null && value !== ''
|
|
314
|
+
if (!hasValue) return {}
|
|
315
|
+
|
|
316
|
+
const isDefault = props.isFilterAtDefault(filter.name)
|
|
317
|
+
return {
|
|
318
|
+
'filter-default': isDefault,
|
|
319
|
+
'filter-modified': !isDefault
|
|
320
|
+
}
|
|
321
|
+
}
|
|
303
322
|
</script>
|
|
304
323
|
|
|
305
324
|
<template>
|
|
@@ -380,7 +399,7 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
|
|
|
380
399
|
:dropdown="true"
|
|
381
400
|
:minLength="0"
|
|
382
401
|
:style="{ minWidth: filter.width || '160px' }"
|
|
383
|
-
:class="
|
|
402
|
+
:class="getFilterClass(filter)"
|
|
384
403
|
:inputClass="'filter-autocomplete-input'"
|
|
385
404
|
/>
|
|
386
405
|
<!-- Standard select filter -->
|
|
@@ -393,7 +412,7 @@ function onRowClick(event: { data: unknown; originalEvent: Event }): void {
|
|
|
393
412
|
:optionValue="filter.optionValue || 'value'"
|
|
394
413
|
:placeholder="filter.placeholder"
|
|
395
414
|
:style="{ minWidth: filter.width || '160px' }"
|
|
396
|
-
:class="
|
|
415
|
+
:class="getFilterClass(filter)"
|
|
397
416
|
/>
|
|
398
417
|
</template>
|
|
399
418
|
<slot name="filters" ></slot>
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ShowDisplay - Auto-renders the appropriate display widget based on field config
|
|
4
|
+
*
|
|
5
|
+
* Takes a field config object and renders the matching display component.
|
|
6
|
+
* Supports hint override but field.type takes precedence if specified.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <ShowDisplay :field="f" :value="data[f.name]" />
|
|
10
|
+
*
|
|
11
|
+
* Display types:
|
|
12
|
+
* - text: simple text
|
|
13
|
+
* - number: formatted number
|
|
14
|
+
* - boolean: Yes/No or icon
|
|
15
|
+
* - date: formatted date
|
|
16
|
+
* - datetime: formatted datetime
|
|
17
|
+
* - select: label from options
|
|
18
|
+
* - email: mailto link
|
|
19
|
+
* - password: masked
|
|
20
|
+
* - textarea: multi-line text
|
|
21
|
+
* - reference: link to entity (requires referenceRoute)
|
|
22
|
+
* - image: image preview
|
|
23
|
+
* - url: clickable link
|
|
24
|
+
* - json: formatted JSON
|
|
25
|
+
* - currency: formatted currency
|
|
26
|
+
* - badge: styled tag
|
|
27
|
+
*/
|
|
28
|
+
import { computed, type PropType } from 'vue'
|
|
29
|
+
import { RouterLink, type RouteLocationRaw } from 'vue-router'
|
|
30
|
+
import Tag from 'primevue/tag'
|
|
31
|
+
|
|
32
|
+
type DisplayType =
|
|
33
|
+
| 'text'
|
|
34
|
+
| 'email'
|
|
35
|
+
| 'password'
|
|
36
|
+
| 'number'
|
|
37
|
+
| 'textarea'
|
|
38
|
+
| 'select'
|
|
39
|
+
| 'boolean'
|
|
40
|
+
| 'date'
|
|
41
|
+
| 'datetime'
|
|
42
|
+
| 'reference'
|
|
43
|
+
| 'image'
|
|
44
|
+
| 'url'
|
|
45
|
+
| 'json'
|
|
46
|
+
| 'currency'
|
|
47
|
+
| 'badge'
|
|
48
|
+
|
|
49
|
+
type DisplayValue = string | number | boolean | Date | null | undefined | unknown
|
|
50
|
+
|
|
51
|
+
interface SelectOption {
|
|
52
|
+
label?: string
|
|
53
|
+
value?: string | number
|
|
54
|
+
[key: string]: unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface FieldConfig {
|
|
58
|
+
name: string
|
|
59
|
+
type?: DisplayType
|
|
60
|
+
// Display options
|
|
61
|
+
dateFormat?: string
|
|
62
|
+
currencyCode?: string
|
|
63
|
+
locale?: string
|
|
64
|
+
booleanLabels?: { true: string; false: string }
|
|
65
|
+
// Reference options
|
|
66
|
+
reference?: string
|
|
67
|
+
referenceRoute?: string | ((value: unknown) => { name: string; params: Record<string, unknown> })
|
|
68
|
+
referenceLabel?: string | ((value: unknown, option?: unknown) => string)
|
|
69
|
+
// Select options (for label lookup)
|
|
70
|
+
options?: SelectOption[]
|
|
71
|
+
optionLabel?: string
|
|
72
|
+
optionValue?: string
|
|
73
|
+
// Badge options
|
|
74
|
+
severity?: string | ((value: unknown) => string)
|
|
75
|
+
// Image options
|
|
76
|
+
imageWidth?: string
|
|
77
|
+
imageHeight?: string
|
|
78
|
+
// Custom render
|
|
79
|
+
render?: (value: unknown) => string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const props = defineProps({
|
|
83
|
+
field: { type: Object as PropType<FieldConfig>, required: true },
|
|
84
|
+
value: { type: [String, Number, Boolean, Date, Object, Array] as PropType<DisplayValue>, default: null },
|
|
85
|
+
hint: { type: String as PropType<DisplayType | null>, default: null }
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Resolve display type: field.type > hint > 'text'
|
|
89
|
+
const displayType = computed<DisplayType>(() => (props.field?.type as DisplayType) || props.hint || 'text')
|
|
90
|
+
|
|
91
|
+
// Check if value is empty
|
|
92
|
+
const isEmpty = computed(() => {
|
|
93
|
+
return props.value === null || props.value === undefined || props.value === ''
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Format text value
|
|
97
|
+
const textValue = computed(() => {
|
|
98
|
+
if (isEmpty.value) return '-'
|
|
99
|
+
if (props.field.render) return props.field.render(props.value)
|
|
100
|
+
return String(props.value)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Format number value
|
|
104
|
+
const numberValue = computed(() => {
|
|
105
|
+
if (isEmpty.value) return '-'
|
|
106
|
+
const num = Number(props.value)
|
|
107
|
+
if (isNaN(num)) return String(props.value)
|
|
108
|
+
const locale = props.field.locale || 'en-US'
|
|
109
|
+
return new Intl.NumberFormat(locale).format(num)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Format currency value
|
|
113
|
+
const currencyValue = computed(() => {
|
|
114
|
+
if (isEmpty.value) return '-'
|
|
115
|
+
const num = Number(props.value)
|
|
116
|
+
if (isNaN(num)) return String(props.value)
|
|
117
|
+
const locale = props.field.locale || 'en-US'
|
|
118
|
+
const currency = props.field.currencyCode || 'USD'
|
|
119
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(num)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Format boolean value
|
|
123
|
+
const booleanValue = computed(() => {
|
|
124
|
+
if (isEmpty.value) return '-'
|
|
125
|
+
const labels = props.field.booleanLabels || { true: 'Yes', false: 'No' }
|
|
126
|
+
return props.value ? labels.true : labels.false
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const booleanIcon = computed(() => {
|
|
130
|
+
if (isEmpty.value) return 'pi-minus'
|
|
131
|
+
return props.value ? 'pi-check' : 'pi-times'
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const booleanIconClass = computed(() => {
|
|
135
|
+
if (isEmpty.value) return 'text-muted'
|
|
136
|
+
return props.value ? 'text-success' : 'text-danger'
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// Format date value
|
|
140
|
+
const dateValue = computed(() => {
|
|
141
|
+
if (isEmpty.value) return '-'
|
|
142
|
+
const v = props.value as unknown
|
|
143
|
+
const date = (typeof v === 'object' && v instanceof Date) ? v : new Date(String(v))
|
|
144
|
+
if (isNaN(date.getTime())) return String(props.value)
|
|
145
|
+
const locale = props.field.locale || 'en-US'
|
|
146
|
+
const format = props.field.dateFormat || 'short'
|
|
147
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: format as 'short' | 'medium' | 'long' | 'full' }).format(date)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Format datetime value
|
|
151
|
+
const datetimeValue = computed(() => {
|
|
152
|
+
if (isEmpty.value) return '-'
|
|
153
|
+
const v = props.value as unknown
|
|
154
|
+
const date = (typeof v === 'object' && v instanceof Date) ? v : new Date(String(v))
|
|
155
|
+
if (isNaN(date.getTime())) return String(props.value)
|
|
156
|
+
const locale = props.field.locale || 'en-US'
|
|
157
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'short', timeStyle: 'short' }).format(date)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
// Format select value (lookup label from options)
|
|
161
|
+
const selectValue = computed(() => {
|
|
162
|
+
if (isEmpty.value) return '-'
|
|
163
|
+
const options = props.field.options || []
|
|
164
|
+
const optionValue = props.field.optionValue || 'value'
|
|
165
|
+
const optionLabel = props.field.optionLabel || 'label'
|
|
166
|
+
|
|
167
|
+
const option = options.find((opt) => opt[optionValue] === props.value)
|
|
168
|
+
if (option) {
|
|
169
|
+
return option[optionLabel] as string
|
|
170
|
+
}
|
|
171
|
+
return String(props.value)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Reference route
|
|
175
|
+
const referenceRoute = computed<RouteLocationRaw | null>(() => {
|
|
176
|
+
if (!props.field.referenceRoute || isEmpty.value) return null
|
|
177
|
+
if (typeof props.field.referenceRoute === 'function') {
|
|
178
|
+
return props.field.referenceRoute(props.value) as RouteLocationRaw
|
|
179
|
+
}
|
|
180
|
+
return { name: props.field.referenceRoute, params: { id: String(props.value) } }
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Reference label
|
|
184
|
+
const referenceLabel = computed(() => {
|
|
185
|
+
if (isEmpty.value) return '-'
|
|
186
|
+
if (typeof props.field.referenceLabel === 'function') {
|
|
187
|
+
return props.field.referenceLabel(props.value)
|
|
188
|
+
}
|
|
189
|
+
if (props.field.referenceLabel) {
|
|
190
|
+
return props.field.referenceLabel
|
|
191
|
+
}
|
|
192
|
+
return String(props.value)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Badge severity
|
|
196
|
+
const badgeSeverity = computed(() => {
|
|
197
|
+
if (typeof props.field.severity === 'function') {
|
|
198
|
+
return props.field.severity(props.value)
|
|
199
|
+
}
|
|
200
|
+
return props.field.severity || 'info'
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// JSON formatted
|
|
204
|
+
const jsonValue = computed(() => {
|
|
205
|
+
if (isEmpty.value) return '-'
|
|
206
|
+
try {
|
|
207
|
+
return JSON.stringify(props.value, null, 2)
|
|
208
|
+
} catch {
|
|
209
|
+
return String(props.value)
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// Image dimensions
|
|
214
|
+
const imageStyle = computed(() => ({
|
|
215
|
+
maxWidth: props.field.imageWidth || '200px',
|
|
216
|
+
maxHeight: props.field.imageHeight || '150px'
|
|
217
|
+
}))
|
|
218
|
+
</script>
|
|
219
|
+
|
|
220
|
+
<template>
|
|
221
|
+
<!-- Empty value -->
|
|
222
|
+
<span v-if="isEmpty" class="show-display show-display--empty">-</span>
|
|
223
|
+
|
|
224
|
+
<!-- Text -->
|
|
225
|
+
<span v-else-if="displayType === 'text'" class="show-display show-display--text">
|
|
226
|
+
{{ textValue }}
|
|
227
|
+
</span>
|
|
228
|
+
|
|
229
|
+
<!-- Email -->
|
|
230
|
+
<a
|
|
231
|
+
v-else-if="displayType === 'email'"
|
|
232
|
+
:href="`mailto:${value}`"
|
|
233
|
+
class="show-display show-display--email"
|
|
234
|
+
>
|
|
235
|
+
{{ value }}
|
|
236
|
+
</a>
|
|
237
|
+
|
|
238
|
+
<!-- Password (masked) -->
|
|
239
|
+
<span v-else-if="displayType === 'password'" class="show-display show-display--password">
|
|
240
|
+
••••••••
|
|
241
|
+
</span>
|
|
242
|
+
|
|
243
|
+
<!-- Number -->
|
|
244
|
+
<span v-else-if="displayType === 'number'" class="show-display show-display--number">
|
|
245
|
+
{{ numberValue }}
|
|
246
|
+
</span>
|
|
247
|
+
|
|
248
|
+
<!-- Currency -->
|
|
249
|
+
<span v-else-if="displayType === 'currency'" class="show-display show-display--currency">
|
|
250
|
+
{{ currencyValue }}
|
|
251
|
+
</span>
|
|
252
|
+
|
|
253
|
+
<!-- Boolean (icon) -->
|
|
254
|
+
<span v-else-if="displayType === 'boolean'" class="show-display show-display--boolean" :class="booleanIconClass">
|
|
255
|
+
<i :class="['pi', booleanIcon]"></i>
|
|
256
|
+
<span class="boolean-label">{{ booleanValue }}</span>
|
|
257
|
+
</span>
|
|
258
|
+
|
|
259
|
+
<!-- Date -->
|
|
260
|
+
<span v-else-if="displayType === 'date'" class="show-display show-display--date">
|
|
261
|
+
{{ dateValue }}
|
|
262
|
+
</span>
|
|
263
|
+
|
|
264
|
+
<!-- Datetime -->
|
|
265
|
+
<span v-else-if="displayType === 'datetime'" class="show-display show-display--datetime">
|
|
266
|
+
{{ datetimeValue }}
|
|
267
|
+
</span>
|
|
268
|
+
|
|
269
|
+
<!-- Select (label lookup) -->
|
|
270
|
+
<span v-else-if="displayType === 'select'" class="show-display show-display--select">
|
|
271
|
+
{{ selectValue }}
|
|
272
|
+
</span>
|
|
273
|
+
|
|
274
|
+
<!-- Textarea (multi-line) -->
|
|
275
|
+
<div v-else-if="displayType === 'textarea'" class="show-display show-display--textarea">
|
|
276
|
+
{{ textValue }}
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
<!-- Reference (link) -->
|
|
280
|
+
<RouterLink
|
|
281
|
+
v-else-if="displayType === 'reference' && referenceRoute"
|
|
282
|
+
:to="referenceRoute"
|
|
283
|
+
class="show-display show-display--reference"
|
|
284
|
+
>
|
|
285
|
+
{{ referenceLabel }}
|
|
286
|
+
</RouterLink>
|
|
287
|
+
<span v-else-if="displayType === 'reference'" class="show-display show-display--reference">
|
|
288
|
+
{{ referenceLabel }}
|
|
289
|
+
</span>
|
|
290
|
+
|
|
291
|
+
<!-- URL -->
|
|
292
|
+
<a
|
|
293
|
+
v-else-if="displayType === 'url'"
|
|
294
|
+
:href="String(value)"
|
|
295
|
+
target="_blank"
|
|
296
|
+
rel="noopener noreferrer"
|
|
297
|
+
class="show-display show-display--url"
|
|
298
|
+
>
|
|
299
|
+
{{ value }}
|
|
300
|
+
<i class="pi pi-external-link" style="font-size: 0.75em; margin-left: 0.25em"></i>
|
|
301
|
+
</a>
|
|
302
|
+
|
|
303
|
+
<!-- Image -->
|
|
304
|
+
<div v-else-if="displayType === 'image'" class="show-display show-display--image">
|
|
305
|
+
<img :src="String(value)" :alt="field.name" :style="imageStyle" />
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
<!-- JSON -->
|
|
309
|
+
<pre v-else-if="displayType === 'json'" class="show-display show-display--json">{{ jsonValue }}</pre>
|
|
310
|
+
|
|
311
|
+
<!-- Badge -->
|
|
312
|
+
<Tag
|
|
313
|
+
v-else-if="displayType === 'badge'"
|
|
314
|
+
:value="String(value)"
|
|
315
|
+
:severity="badgeSeverity"
|
|
316
|
+
class="show-display show-display--badge"
|
|
317
|
+
/>
|
|
318
|
+
|
|
319
|
+
<!-- Fallback to text -->
|
|
320
|
+
<span v-else class="show-display show-display--text">
|
|
321
|
+
{{ textValue }}
|
|
322
|
+
</span>
|
|
323
|
+
</template>
|
|
324
|
+
|
|
325
|
+
<style scoped>
|
|
326
|
+
.show-display {
|
|
327
|
+
display: inline;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.show-display--empty {
|
|
331
|
+
color: var(--p-text-muted-color, #6c757d);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.show-display--email,
|
|
335
|
+
.show-display--url,
|
|
336
|
+
.show-display--reference {
|
|
337
|
+
color: var(--p-primary-color, #3b82f6);
|
|
338
|
+
text-decoration: none;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.show-display--email:hover,
|
|
342
|
+
.show-display--url:hover,
|
|
343
|
+
.show-display--reference:hover {
|
|
344
|
+
text-decoration: underline;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.show-display--password {
|
|
348
|
+
font-family: monospace;
|
|
349
|
+
letter-spacing: 0.1em;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.show-display--number,
|
|
353
|
+
.show-display--currency {
|
|
354
|
+
font-variant-numeric: tabular-nums;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.show-display--boolean {
|
|
358
|
+
display: inline-flex;
|
|
359
|
+
align-items: center;
|
|
360
|
+
gap: 0.5rem;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.show-display--boolean.text-success {
|
|
364
|
+
color: var(--p-green-500, #22c55e);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.show-display--boolean.text-danger {
|
|
368
|
+
color: var(--p-red-500, #ef4444);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.show-display--boolean.text-muted {
|
|
372
|
+
color: var(--p-text-muted-color, #6c757d);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.show-display--textarea {
|
|
376
|
+
white-space: pre-wrap;
|
|
377
|
+
line-height: 1.6;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.show-display--image img {
|
|
381
|
+
border-radius: 4px;
|
|
382
|
+
object-fit: cover;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.show-display--json {
|
|
386
|
+
background: var(--p-surface-100, #f1f5f9);
|
|
387
|
+
padding: 0.75rem;
|
|
388
|
+
border-radius: 4px;
|
|
389
|
+
font-size: 0.875rem;
|
|
390
|
+
overflow-x: auto;
|
|
391
|
+
margin: 0;
|
|
392
|
+
}
|
|
393
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ShowField - Display field wrapper with label
|
|
4
|
+
*
|
|
5
|
+
* Provides consistent styling for field display in ShowPage.
|
|
6
|
+
* Mirrors FormField but for read-only display.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <ShowField :name="f.name" :label="f.label">
|
|
10
|
+
* <ShowDisplay :field="f" :value="data[f.name]" />
|
|
11
|
+
* </ShowField>
|
|
12
|
+
*
|
|
13
|
+
* Or with auto-display:
|
|
14
|
+
* <ShowField :field="f" :value="data[f.name]" />
|
|
15
|
+
*/
|
|
16
|
+
import { computed, type PropType } from 'vue'
|
|
17
|
+
import ShowDisplay from './ShowDisplay.vue'
|
|
18
|
+
|
|
19
|
+
interface FieldConfig {
|
|
20
|
+
name: string
|
|
21
|
+
type?: string
|
|
22
|
+
label?: string
|
|
23
|
+
[key: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const props = defineProps({
|
|
27
|
+
// Field config (for auto-display mode)
|
|
28
|
+
field: { type: Object as PropType<FieldConfig | null>, default: null },
|
|
29
|
+
// Value (for auto-display mode)
|
|
30
|
+
value: { type: [String, Number, Boolean, Date, Object, Array], default: null },
|
|
31
|
+
// Manual props (override field config)
|
|
32
|
+
name: { type: String, default: '' },
|
|
33
|
+
label: { type: String, default: '' },
|
|
34
|
+
// Layout options
|
|
35
|
+
horizontal: { type: Boolean, default: false },
|
|
36
|
+
labelWidth: { type: String, default: '120px' }
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Resolved name
|
|
40
|
+
const fieldName = computed(() => props.name || props.field?.name || '')
|
|
41
|
+
|
|
42
|
+
// Resolved label
|
|
43
|
+
const fieldLabel = computed(() => props.label || props.field?.label || fieldName.value)
|
|
44
|
+
|
|
45
|
+
// Auto-display mode (when field + value provided, no slot content)
|
|
46
|
+
const autoDisplay = computed(() => props.field !== null)
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<div
|
|
51
|
+
class="show-field"
|
|
52
|
+
:class="{
|
|
53
|
+
'show-field--horizontal': horizontal
|
|
54
|
+
}"
|
|
55
|
+
>
|
|
56
|
+
<label class="show-field__label" :style="horizontal ? { minWidth: labelWidth } : {}">
|
|
57
|
+
{{ fieldLabel }}
|
|
58
|
+
</label>
|
|
59
|
+
<div class="show-field__value">
|
|
60
|
+
<!-- Slot for custom content -->
|
|
61
|
+
<slot>
|
|
62
|
+
<!-- Auto-display when field is provided -->
|
|
63
|
+
<ShowDisplay v-if="autoDisplay" :field="(field as any)" :value="(value as any)" />
|
|
64
|
+
</slot>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<style scoped>
|
|
70
|
+
.show-field {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
gap: 0.25rem;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.show-field--horizontal {
|
|
77
|
+
flex-direction: row;
|
|
78
|
+
align-items: flex-start;
|
|
79
|
+
gap: 1rem;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.show-field__label {
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
color: var(--p-text-muted-color, #6c757d);
|
|
85
|
+
font-size: 0.875rem;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.show-field--horizontal .show-field__label {
|
|
89
|
+
padding-top: 0.125rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.show-field__value {
|
|
93
|
+
color: var(--p-text-color, #1e293b);
|
|
94
|
+
line-height: 1.5;
|
|
95
|
+
}
|
|
96
|
+
</style>
|